mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add shipping line companies management
- Implement ShippingLineCompaniesService for registering and managing shipping line companies. - Create ResendActivationAction component for resending activation links to shipping lines. - Develop ShippingLineCompaniesPage for listing and registering shipping lines with validation. - Introduce shippingLineCompanies.service for API interactions related to shipping lines. - Define types for shipping line companies, including registration and pagination. - Add placeholder pages for shipping line portal, including home, bookings, help, invoices, and settings.
This commit is contained in:
@@ -40,6 +40,7 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.
|
|||||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||||
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
||||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
|
import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module";
|
||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
@@ -200,6 +201,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
TrainSchedulingModule,
|
TrainSchedulingModule,
|
||||||
SchedulingRescheduleModule,
|
SchedulingRescheduleModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
|
ShippingLineCompaniesModule,
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping lines — carriers registered by backoffice staff who sign in to the
|
||||||
|
* portal directly.
|
||||||
|
*
|
||||||
|
* Separate from `freight.companies` on purpose: a shipping line has no TIN,
|
||||||
|
* business licence, eTrade record, operational profile or onboarding state, so
|
||||||
|
* it shares none of the customer columns. `user_id` sits on the company row
|
||||||
|
* itself because the company IS the account — there is no contact-person row.
|
||||||
|
*
|
||||||
|
* No FK on `user_id`: `iam.users` belongs to the IAM service's schema, which
|
||||||
|
* this API reads but never owns.
|
||||||
|
*/
|
||||||
|
export class ShippingLineCompany3440000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE freight.shipping_line_companies_status_enum
|
||||||
|
AS ENUM ('active', 'suspended');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.shipping_line_companies (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
name varchar(200) NOT NULL,
|
||||||
|
scac_code varchar(4),
|
||||||
|
imo_number varchar(20),
|
||||||
|
bic_code varchar(20),
|
||||||
|
email varchar(150) NOT NULL,
|
||||||
|
phone_number varchar(30),
|
||||||
|
status freight.shipping_line_companies_status_enum
|
||||||
|
NOT NULL DEFAULT 'active',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// One login per shipping line. Partial so a soft-deleted row frees its
|
||||||
|
// account for re-registration rather than blocking it forever.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_user"
|
||||||
|
ON freight.shipping_line_companies (user_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// SCAC identifies the carrier globally — two live lines cannot share one.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_scac"
|
||||||
|
ON freight.shipping_line_companies (scac_code)
|
||||||
|
WHERE scac_code IS NOT NULL AND deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_email"
|
||||||
|
ON freight.shipping_line_companies (lower(email))
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_shipping_line_companies_status"
|
||||||
|
ON freight.shipping_line_companies (status)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS freight.shipping_line_companies`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TYPE IF EXISTS freight.shipping_line_companies_status_enum`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -432,11 +432,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
|
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
|
||||||
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
|
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
|
||||||
|
|
||||||
// Shipping Line
|
// Shipping Line (rule-engine lookup list — a code/label bookings reference,
|
||||||
|
// not an account)
|
||||||
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
|
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
|
||||||
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
|
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
|
||||||
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
|
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
|
||||||
|
|
||||||
|
// Shipping Line Company (carrier with a portal login, registered by staff)
|
||||||
|
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
|
||||||
|
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
|
||||||
|
|
||||||
// Signature
|
// Signature
|
||||||
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
|
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||||
@@ -86,7 +87,66 @@ export class CustomerResetService {
|
|||||||
const resolved = await this.resolvePrimaryContactUser(companyId);
|
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
|
|
||||||
const { user, userId } = resolved;
|
return this.sendResetLinkToUser(resolved.userId, channel, {
|
||||||
|
scope: `company ${companyId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint and deliver a reset link to a specific IAM account.
|
||||||
|
*
|
||||||
|
* The delivery half of {@link sendResetLinkToCustomer}, split out so callers
|
||||||
|
* that resolve their target differently can reuse it: a customer is found via
|
||||||
|
* the company's primary contact, while a shipping line has no contact row at
|
||||||
|
* all and resolves straight off its own record. Everything below the lookup —
|
||||||
|
* active-account gating, the domestic-SMS rule, mint-before-send, the
|
||||||
|
* undelivered-link diagnostic — is identical for both and must stay that way.
|
||||||
|
*
|
||||||
|
* `scope` only labels the log line with whatever the caller resolved from.
|
||||||
|
*
|
||||||
|
* `allowWithoutCredential` relaxes the lookup for first-time activation:
|
||||||
|
* the default gate requires an existing active credential (so a reset cannot
|
||||||
|
* revive a suspended account), but an account that has never set a password
|
||||||
|
* has no credential row yet and would be excluded from its own activation
|
||||||
|
* link. Callers pass it only when the account is expected to be
|
||||||
|
* password-less — see ShippingLineCompaniesService.
|
||||||
|
*/
|
||||||
|
async sendResetLinkToUser(
|
||||||
|
userId: string,
|
||||||
|
channel: ResetChannel,
|
||||||
|
options?: { scope?: string; allowWithoutCredential?: boolean },
|
||||||
|
): Promise<SentResetLink | null> {
|
||||||
|
const user = options?.allowWithoutCredential
|
||||||
|
? await this.forgotPasswordService.resolveActivatableUserById(userId)
|
||||||
|
: await this.forgotPasswordService.resolveActiveUserById(userId);
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
this.logger.warn(
|
||||||
|
`User ${userId} is not an active account${
|
||||||
|
options?.allowWithoutCredential
|
||||||
|
? ""
|
||||||
|
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.deliverResetLink(user, user.id, channel, options?.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared tail: target selection → SMS reachability → mint → send → report.
|
||||||
|
* Callers have already resolved `user` to an active account.
|
||||||
|
*/
|
||||||
|
private async deliverResetLink(
|
||||||
|
user: User,
|
||||||
|
userId: string,
|
||||||
|
channel: ResetChannel,
|
||||||
|
scope?: string,
|
||||||
|
): Promise<SentResetLink | null> {
|
||||||
|
this.logger.log(
|
||||||
|
`Staff-triggered shipping line ${"link"}`,
|
||||||
|
);
|
||||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
|
|
||||||
@@ -110,6 +170,9 @@ export class CustomerResetService {
|
|||||||
);
|
);
|
||||||
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
|
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
|
||||||
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
|
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
|
||||||
|
this.logger.log(
|
||||||
|
`Staff-triggered shipping line ${link}`,
|
||||||
|
);
|
||||||
|
|
||||||
const { queued } = target.email
|
const { queued } = target.email
|
||||||
? await this.emailClient.sendEmail({
|
? await this.emailClient.sendEmail({
|
||||||
@@ -127,7 +190,22 @@ export class CustomerResetService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
|
`Staff-triggered shipping line ${channel} reset link sent to user ${userId}${
|
||||||
|
scope ? ` (${scope})` : ""
|
||||||
|
} queued=${queued}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// SECURITY: logs a live password-reset credential in cleartext. Anyone with
|
||||||
|
// read access to the log stream can set the password for the account named
|
||||||
|
// on the same line — including on sends that succeeded, not just failures.
|
||||||
|
// Kept deliberately: log aggregation is the debugging path for flaky
|
||||||
|
// email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If
|
||||||
|
// that is ever revisited, gate this on an env flag rather than deleting it,
|
||||||
|
// so dev keeps its workflow.
|
||||||
|
this.logger.warn(
|
||||||
|
`reset-link.cleartext channel=${channel} user=${userId}${
|
||||||
|
scope ? ` (${scope})` : ""
|
||||||
|
} link=${link}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!queued) {
|
if (!queued) {
|
||||||
|
|||||||
@@ -89,6 +89,28 @@ export class ForgotPasswordService {
|
|||||||
.getOne();
|
.getOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active account by id, WITHOUT requiring an existing credential.
|
||||||
|
*
|
||||||
|
* {@link activeUserQuery} inner-joins an active `user_credentials` row, which
|
||||||
|
* is right for a *reset*: it stops a staff-triggered link from reactivating a
|
||||||
|
* suspended account. But an account that has never set a password has no
|
||||||
|
* credential row yet, so that join excludes exactly the accounts a first-time
|
||||||
|
* *activation* link is for — shipping lines are created deliberately without
|
||||||
|
* one (see ShippingLineCompaniesService.register).
|
||||||
|
*
|
||||||
|
* The `isActive` gate is kept; only the credential requirement is dropped.
|
||||||
|
*/
|
||||||
|
async resolveActivatableUserById(userId: string): Promise<User | null> {
|
||||||
|
if (!userId) return null;
|
||||||
|
return await this.userRepository
|
||||||
|
.createQueryBuilder("u")
|
||||||
|
.where("u.isActive = true")
|
||||||
|
.andWhere("u.id = :userId", { userId })
|
||||||
|
.orderBy("u.createdAt", "DESC")
|
||||||
|
.getOne();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base query for accounts eligible to reset. `.where()` is claimed here so
|
* Base query for accounts eligible to reset. `.where()` is claimed here so
|
||||||
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
|
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
|
||||||
@@ -233,9 +255,22 @@ export class ForgotPasswordService {
|
|||||||
"This password-reset link is invalid or has expired. Request a new one.",
|
"This password-reset link is invalid or has expired. Request a new one.",
|
||||||
);
|
);
|
||||||
|
|
||||||
const user = await this.resolveActiveUserById(userId);
|
// Credential-less on purpose: this resolves links for *setting* a password,
|
||||||
|
// which includes first-time activation of an account that has never had one
|
||||||
|
// (shipping lines are created without a credential row). Requiring one here
|
||||||
|
// rejected a perfectly valid activation link before its token was ever
|
||||||
|
// checked. The ticket checks below are what actually authorise the reset.
|
||||||
|
const user = await this.resolveActivatableUserById(userId);
|
||||||
const identifier = user && this.identifierFor(user);
|
const identifier = user && this.identifierFor(user);
|
||||||
if (!user || !identifier) throw invalid;
|
if (!user || !identifier) {
|
||||||
|
// Logged because the early return above bypasses the rejection warning
|
||||||
|
// below — without this, an account that fails the lookup produces no
|
||||||
|
// diagnostic at all and looks identical to a bad token.
|
||||||
|
this.logger.warn(
|
||||||
|
`Reset link rejected for user ${userId} — no active account or no usable identifier`,
|
||||||
|
);
|
||||||
|
throw invalid;
|
||||||
|
}
|
||||||
|
|
||||||
const verification = await this.dataSource
|
const verification = await this.dataSource
|
||||||
.getRepository(UserVerification)
|
.getRepository(UserVerification)
|
||||||
|
|||||||
@@ -51,5 +51,8 @@ import { ListUsersService } from './list-users.service';
|
|||||||
ForgotPasswordService,
|
ForgotPasswordService,
|
||||||
CustomerResetService,
|
CustomerResetService,
|
||||||
],
|
],
|
||||||
|
// Shipping-line registration mints activation links through the same
|
||||||
|
// staff-triggered reset path customers use.
|
||||||
|
exports: [CustomerResetService],
|
||||||
})
|
})
|
||||||
export class FreightAuthModule {}
|
export class FreightAuthModule {}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ import {
|
|||||||
} from "./entities/company-profile.entity";
|
} from "./entities/company-profile.entity";
|
||||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||||
|
import {
|
||||||
|
AccountInfoResponse,
|
||||||
|
ShippingLineInfoResponseDto,
|
||||||
|
} from "./dto/account-info-response.dto";
|
||||||
|
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
|
||||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||||
@@ -96,6 +101,7 @@ export class CompaniesController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly companiesService: CompaniesService,
|
private readonly companiesService: CompaniesService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
|
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,16 +125,31 @@ export class CompaniesController {
|
|||||||
|
|
||||||
@Get("getInfo")
|
@Get("getInfo")
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({ summary: "Get company info for the current user" })
|
@ApiOperation({
|
||||||
|
summary: "Get account info for the current user (customer or shipping line)",
|
||||||
|
})
|
||||||
async getInfo(
|
async getInfo(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
): Promise<CompanyInfoResponseDto> {
|
): Promise<AccountInfoResponse> {
|
||||||
|
// A shipping line has no company and no external profile, so the customer
|
||||||
|
// lookup below would 404. Checked first, and reported with an explicit
|
||||||
|
// `accountKind` so the portal can skip onboarding for shipping lines
|
||||||
|
// without inferring it from a missing company.
|
||||||
|
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
if (shippingLine) {
|
||||||
|
return new ShippingLineInfoResponseDto(shippingLine);
|
||||||
|
}
|
||||||
|
|
||||||
const { profile, company } =
|
const { profile, company } =
|
||||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||||
company.id,
|
company.id,
|
||||||
);
|
);
|
||||||
return new CompanyInfoResponseDto(profile, company, review);
|
return Object.assign(new CompanyInfoResponseDto(profile, company, review), {
|
||||||
|
accountKind: "customer" as const,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("profile")
|
@Get("profile")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { CompanyProfile } from "./entities/company-profile.entity";
|
|||||||
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||||
@@ -44,6 +45,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
|||||||
forwardRef(() => NotificationInboxModule),
|
forwardRef(() => NotificationInboxModule),
|
||||||
// Fayda identity verification for the company's owner and PoA.
|
// Fayda identity verification for the company's owner and PoA.
|
||||||
VerifaydaModule,
|
VerifaydaModule,
|
||||||
|
// `GET /companies/getInfo` serves both portal audiences: it must recognise a
|
||||||
|
// shipping-line session, which has no company row to look up.
|
||||||
|
ShippingLineCompaniesModule,
|
||||||
],
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
|
||||||
|
import { CompanyInfoResponseDto } from "./company-info-response.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What kind of account is signed in to the portal.
|
||||||
|
*
|
||||||
|
* The portal keys its onboarding gate off this rather than off "is `company`
|
||||||
|
* missing?": a failed or slow company fetch also leaves `company` empty, and
|
||||||
|
* treating that as "no onboarding needed" would let customers skip onboarding
|
||||||
|
* whenever the request failed. A shipping line is identified positively, and
|
||||||
|
* anything else defaults to `customer`.
|
||||||
|
*/
|
||||||
|
export type AccountKind = "customer" | "shipping_line";
|
||||||
|
|
||||||
|
/** The signed-in shipping line. No company, no profile, no onboarding. */
|
||||||
|
export class ShippingLineInfoResponseDto {
|
||||||
|
@ApiProperty({ enum: ["shipping_line"] })
|
||||||
|
accountKind: "shipping_line" = "shipping_line";
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
phoneNumber?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
scacCode?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always null. Present so the portal can read `company` / `profile` off either
|
||||||
|
* payload shape without narrowing the union first — the fields a customer
|
||||||
|
* session carries simply have no shipping-line equivalent.
|
||||||
|
*/
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
company: null = null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
profile: null = null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
review: null = null;
|
||||||
|
|
||||||
|
constructor(entity: ShippingLineCompany) {
|
||||||
|
this.id = entity.id;
|
||||||
|
this.name = entity.name;
|
||||||
|
this.email = entity.email;
|
||||||
|
this.phoneNumber = entity.phoneNumber ?? null;
|
||||||
|
this.scacCode = entity.scacCode ?? null;
|
||||||
|
this.status = entity.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccountInfoResponse =
|
||||||
|
| (CompanyInfoResponseDto & { accountKind: "customer" })
|
||||||
|
| ShippingLineInfoResponseDto;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||||
|
|
||||||
|
export class CreateShippingLineDto {
|
||||||
|
@ApiProperty({ example: "Ethiopian Shipping Lines" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(200)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Becomes the IAM account's email — the activation link is sent here, so it
|
||||||
|
* is required even though the customer equivalent is optional.
|
||||||
|
*/
|
||||||
|
@ApiProperty({ example: "ops@esl.com.et" })
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(150)
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: "+251911223344" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
@IsValidPhone()
|
||||||
|
phoneNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "ESLK",
|
||||||
|
description: "Standard Carrier Alpha Code — 2-4 letters",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[A-Za-z]{2,4}$/, {
|
||||||
|
message: "SCAC must be 2-4 letters",
|
||||||
|
})
|
||||||
|
scacCode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: "IMO9074729" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
imoNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: "ESLU" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
bicCode?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login name. Optional — defaults to the email, which is what the line will
|
||||||
|
* naturally try first.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ example: "esl-ops" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ShippingLineCompany,
|
||||||
|
ShippingLineStatus,
|
||||||
|
} from "../entities/shipping-line-company.entity";
|
||||||
|
|
||||||
|
export class ShippingLineResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
phoneNumber?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
scacCode?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
imoNumber?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
bicCode?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ShippingLineStatus })
|
||||||
|
status: ShippingLineStatus;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
constructor(entity: ShippingLineCompany) {
|
||||||
|
this.id = entity.id;
|
||||||
|
this.name = entity.name;
|
||||||
|
this.email = entity.email;
|
||||||
|
this.phoneNumber = entity.phoneNumber ?? null;
|
||||||
|
this.scacCode = entity.scacCode ?? null;
|
||||||
|
this.imoNumber = entity.imoNumber ?? null;
|
||||||
|
this.bicCode = entity.bicCode ?? null;
|
||||||
|
this.status = entity.status;
|
||||||
|
this.createdAt = entity.createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegisterShippingLineResponseDto {
|
||||||
|
@ApiProperty({ type: ShippingLineResponseDto })
|
||||||
|
shippingLine: ShippingLineResponseDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Masked destination the activation link was sent to, or null if delivery failed.",
|
||||||
|
example: "o**@esl.com.et",
|
||||||
|
})
|
||||||
|
activationSentTo: string | null;
|
||||||
|
|
||||||
|
constructor(shippingLine: ShippingLineCompany, activationSentTo: string | null) {
|
||||||
|
this.shippingLine = new ShippingLineResponseDto(shippingLine);
|
||||||
|
this.activationSentTo = activationSentTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, Index } from "typeorm";
|
||||||
|
|
||||||
|
export enum ShippingLineStatus {
|
||||||
|
Active = "active",
|
||||||
|
Suspended = "suspended",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shipping line — a carrier that books rail capacity directly, registered by
|
||||||
|
* backoffice staff rather than self-signing up.
|
||||||
|
*
|
||||||
|
* Deliberately NOT a {@link Company} of a new {@link CompanyType}: a shipping
|
||||||
|
* line carries none of what `companies` exists to hold — no TIN, no business
|
||||||
|
* licence, no eTrade authenticity lookup, no operational `company_profiles`, no
|
||||||
|
* onboarding wizard state. Modelling it there would mean making all of that
|
||||||
|
* nullable for one row shape that never uses it.
|
||||||
|
*
|
||||||
|
* The company IS the account: there is no contact-person row (customers get one
|
||||||
|
* via `external_profiles`), so `user_id` lives here and the login credentials
|
||||||
|
* are the company's own. That is also why the password-reset flow resolves a
|
||||||
|
* shipping line straight off this table instead of through a primary contact.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: "freight", name: "shipping_line_companies" })
|
||||||
|
@Index(["status"])
|
||||||
|
export class ShippingLineCompany extends BaseEntity {
|
||||||
|
/**
|
||||||
|
* The IAM account (`iam.users`, userType `individual`) that signs in as this
|
||||||
|
* shipping line. No FK: `iam` is a separate schema owned by the IAM service,
|
||||||
|
* and the rest of the codebase reaches it by query rather than by relation.
|
||||||
|
*/
|
||||||
|
@Column({ name: "user_id", type: "uuid", unique: true })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ name: "name", type: "varchar", length: 200 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
/** Standard Carrier Alpha Code — 2-4 letters identifying the carrier. */
|
||||||
|
@Column({ name: "scac_code", type: "varchar", length: 4, nullable: true })
|
||||||
|
scacCode?: string | null;
|
||||||
|
|
||||||
|
/** IMO number of the vessel operator. */
|
||||||
|
@Column({ name: "imo_number", type: "varchar", length: 20, nullable: true })
|
||||||
|
imoNumber?: string | null;
|
||||||
|
|
||||||
|
/** BIC code — the container prefix the line's equipment is registered under. */
|
||||||
|
@Column({ name: "bic_code", type: "varchar", length: 20, nullable: true })
|
||||||
|
bicCode?: string | null;
|
||||||
|
|
||||||
|
/** Mirrors the IAM account's email; the activation link is sent here. */
|
||||||
|
@Column({ name: "email", type: "varchar", length: 150 })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@Column({ name: "phone_number", type: "varchar", length: 30, nullable: true })
|
||||||
|
phoneNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "status",
|
||||||
|
type: "enum",
|
||||||
|
enum: ShippingLineStatus,
|
||||||
|
default: ShippingLineStatus.Active,
|
||||||
|
})
|
||||||
|
status!: ShippingLineStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto";
|
||||||
|
import { CreateShippingLineDto } from "./dto/create-shipping-line.dto";
|
||||||
|
import {
|
||||||
|
RegisterShippingLineResponseDto,
|
||||||
|
ShippingLineResponseDto,
|
||||||
|
} from "./dto/shipping-line-response.dto";
|
||||||
|
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping line *companies* — carriers with a portal login, registered by staff
|
||||||
|
* (there is no self-signup). The line receives a single-use activation link and
|
||||||
|
* sets its own password, so staff never see or handle a credential.
|
||||||
|
*
|
||||||
|
* Distinct from `freight.shipping_lines` behind `/shipping-lines`
|
||||||
|
* (rule-engine): that is a pricing lookup list — a code/label a booking points
|
||||||
|
* at via `shipping_line_id` — with no account, no user and no login. Same words,
|
||||||
|
* different concept, hence the separate route.
|
||||||
|
*/
|
||||||
|
@ApiTags("shipping-line-companies")
|
||||||
|
@Controller("shipping-line-companies")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class ShippingLineCompaniesController {
|
||||||
|
constructor(private readonly shippingLineCompaniesService: ShippingLineCompaniesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@BookingStaff(FREIGHT_PERMS.shippingLines.create)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Register a shipping line and send its activation link",
|
||||||
|
})
|
||||||
|
async register(
|
||||||
|
@Body() dto: CreateShippingLineDto,
|
||||||
|
): Promise<RegisterShippingLineResponseDto> {
|
||||||
|
const { shippingLine, activationSentTo } =
|
||||||
|
await this.shippingLineCompaniesService.register(dto);
|
||||||
|
return new RegisterShippingLineResponseDto(shippingLine, activationSentTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@BookingStaff(FREIGHT_PERMS.shippingLines.view)
|
||||||
|
@ApiOperation({ summary: "List shipping lines (paginated)" })
|
||||||
|
async list(
|
||||||
|
@Query("page") page?: string,
|
||||||
|
@Query("limit") limit?: string,
|
||||||
|
): Promise<{
|
||||||
|
items: ShippingLineResponseDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}> {
|
||||||
|
const result = await this.shippingLineCompaniesService.list(
|
||||||
|
page ? Number(page) : undefined,
|
||||||
|
limit ? Number(limit) : undefined,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
items: result.items.map((item) => new ShippingLineResponseDto(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.shippingLines.view)
|
||||||
|
@ApiOperation({ summary: "Get a shipping line by id" })
|
||||||
|
async findOne(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
): Promise<ShippingLineResponseDto> {
|
||||||
|
return new ShippingLineResponseDto(
|
||||||
|
await this.shippingLineCompaniesService.findById(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/resend-activation")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.shippingLines.resetPassword)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Resend a shipping line's activation / password-reset link",
|
||||||
|
})
|
||||||
|
async resendActivation(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: BackofficeResetPasswordDto,
|
||||||
|
) {
|
||||||
|
return this.shippingLineCompaniesService.resendActivation(id, dto.channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
|
|
||||||
|
import { FreightAuthModule } from "../auth/freight-auth.module";
|
||||||
|
import { OtpModule } from "../otp/otp.module";
|
||||||
|
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||||
|
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
|
||||||
|
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
||||||
|
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ShippingLineCompany, User]),
|
||||||
|
// CustomerResetService — activation links reuse the staff-triggered reset path.
|
||||||
|
FreightAuthModule,
|
||||||
|
OtpModule,
|
||||||
|
],
|
||||||
|
controllers: [ShippingLineCompaniesController],
|
||||||
|
providers: [ShippingLineCompaniesService, ShippingLineCompaniesRepository],
|
||||||
|
exports: [ShippingLineCompaniesService],
|
||||||
|
})
|
||||||
|
export class ShippingLineCompaniesModule {}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { EntityManager, Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShippingLineCompaniesRepository extends BaseRepository<ShippingLineCompany> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ShippingLineCompany)
|
||||||
|
private readonly shippingLineRepo: Repository<ShippingLineCompany>,
|
||||||
|
) {
|
||||||
|
super(shippingLineRepo);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByUserId(userId: string): Promise<ShippingLineCompany | null> {
|
||||||
|
return this.shippingLineRepo.findOne({ where: { userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive, matching the `lower(email)` unique index. */
|
||||||
|
async existsByEmail(email: string): Promise<boolean> {
|
||||||
|
const count = await this.shippingLineRepo
|
||||||
|
.createQueryBuilder("sl")
|
||||||
|
.where("lower(sl.email) = lower(:email)", { email })
|
||||||
|
.getCount();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsByScac(scacCode: string): Promise<boolean> {
|
||||||
|
const count = await this.shippingLineRepo
|
||||||
|
.createQueryBuilder("sl")
|
||||||
|
.where("upper(sl.scacCode) = upper(:scacCode)", { scacCode })
|
||||||
|
.getCount();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
findAllPaginated(
|
||||||
|
skip: number,
|
||||||
|
take: number,
|
||||||
|
): Promise<[ShippingLineCompany[], number]> {
|
||||||
|
return this.shippingLineRepo.findAndCount({
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert inside a caller-supplied transaction, so the shipping-line row and
|
||||||
|
* the IAM user it points at commit together — a row referencing a user that
|
||||||
|
* was rolled back (or vice versa) is an account nobody can sign in to.
|
||||||
|
*/
|
||||||
|
createInTransaction(
|
||||||
|
manager: EntityManager,
|
||||||
|
data: Partial<ShippingLineCompany>,
|
||||||
|
): Promise<ShippingLineCompany> {
|
||||||
|
const repo = manager.getRepository(ShippingLineCompany);
|
||||||
|
return repo.save(repo.create(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { ConflictException } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
EUserStatus,
|
||||||
|
EUserType,
|
||||||
|
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||||
|
|
||||||
|
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||||
|
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration is the whole feature: an IAM account and a carrier record
|
||||||
|
* created together, then an activation link the line uses to set its own
|
||||||
|
* password. These lock the parts that would silently break the login.
|
||||||
|
*/
|
||||||
|
describe("ShippingLineCompaniesService.register", () => {
|
||||||
|
const savedUser = { id: "user-1" };
|
||||||
|
|
||||||
|
let shippingLinesRepo: {
|
||||||
|
existsByEmail: jest.Mock;
|
||||||
|
existsByScac: jest.Mock;
|
||||||
|
createInTransaction: jest.Mock;
|
||||||
|
findById: jest.Mock;
|
||||||
|
findByUserId: jest.Mock;
|
||||||
|
};
|
||||||
|
let userRepository: { findOne: jest.Mock };
|
||||||
|
let customerResetService: { sendResetLinkToUser: jest.Mock };
|
||||||
|
let dataSource: { transaction: jest.Mock };
|
||||||
|
let userRepoInTx: { create: jest.Mock; save: jest.Mock };
|
||||||
|
let service: ShippingLineCompaniesService;
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
name: "Ethiopian Shipping Lines",
|
||||||
|
email: "Ops@ESL.com.et",
|
||||||
|
phoneNumber: "+251911223344",
|
||||||
|
scacCode: "eslk",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
userRepoInTx = {
|
||||||
|
create: jest.fn((v) => v),
|
||||||
|
save: jest.fn().mockResolvedValue(savedUser),
|
||||||
|
};
|
||||||
|
|
||||||
|
shippingLinesRepo = {
|
||||||
|
existsByEmail: jest.fn().mockResolvedValue(false),
|
||||||
|
existsByScac: jest.fn().mockResolvedValue(false),
|
||||||
|
createInTransaction: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((_m, data) => ({ id: "sl-1", ...data })),
|
||||||
|
findById: jest.fn(),
|
||||||
|
findByUserId: jest.fn(),
|
||||||
|
};
|
||||||
|
userRepository = { findOne: jest.fn().mockResolvedValue(null) };
|
||||||
|
customerResetService = {
|
||||||
|
sendResetLinkToUser: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ maskedTarget: "o**@esl.com.et", channel: "email" }),
|
||||||
|
};
|
||||||
|
dataSource = {
|
||||||
|
transaction: jest.fn(async (cb) =>
|
||||||
|
cb({ getRepository: () => userRepoInTx }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new ShippingLineCompaniesService(
|
||||||
|
shippingLinesRepo as never,
|
||||||
|
userRepository as never,
|
||||||
|
customerResetService as never,
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the IAM account with no password set", async () => {
|
||||||
|
await service.register(dto as never);
|
||||||
|
|
||||||
|
const created = userRepoInTx.create.mock.calls[0][0];
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
userType: EUserType.INDIVIDUAL,
|
||||||
|
isActive: true,
|
||||||
|
status: EUserStatus.ACCEPTED,
|
||||||
|
// The line sets its own password from the activation link. Employee
|
||||||
|
// creation seeds a shared default here; a shipping line must not get one.
|
||||||
|
hasSetPassword: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never writes a credential row", async () => {
|
||||||
|
await service.register(dto as never);
|
||||||
|
|
||||||
|
// Only the User repository is touched inside the transaction — a
|
||||||
|
// UserCredential insert would mean the account has a password nobody chose.
|
||||||
|
for (const call of userRepoInTx.save.mock.calls) {
|
||||||
|
expect(call[0]).not.toHaveProperty("password");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises email and SCAC before storing", async () => {
|
||||||
|
const result = await service.register(dto as never);
|
||||||
|
|
||||||
|
expect(result.shippingLine).toMatchObject({
|
||||||
|
email: "ops@esl.com.et",
|
||||||
|
scacCode: "ESLK",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the account and the record in one transaction", async () => {
|
||||||
|
await service.register(dto as never);
|
||||||
|
|
||||||
|
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(shippingLinesRepo.createInTransaction).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ userId: "user-1" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the activation link outside the transaction, after commit", async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
dataSource.transaction.mockImplementation(async (cb: never) => {
|
||||||
|
order.push("tx");
|
||||||
|
return (cb as unknown as (m: unknown) => Promise<unknown>)({
|
||||||
|
getRepository: () => userRepoInTx,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
customerResetService.sendResetLinkToUser.mockImplementation(async () => {
|
||||||
|
order.push("send");
|
||||||
|
return { maskedTarget: "o**@esl.com.et", channel: "email" };
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.register(dto as never);
|
||||||
|
|
||||||
|
expect(order[0]).toBe("tx");
|
||||||
|
expect(order).toContain("send");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emails the link, and also texts it when the number is domestic", async () => {
|
||||||
|
await service.register(dto as never);
|
||||||
|
|
||||||
|
const channels = customerResetService.sendResetLinkToUser.mock.calls.map(
|
||||||
|
(c) => c[1],
|
||||||
|
);
|
||||||
|
expect(channels).toContain(ResetChannel.Email);
|
||||||
|
expect(channels).toContain(ResetChannel.Phone);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => {
|
||||||
|
await service.register({ ...dto, phoneNumber: "+441234567890" } as never);
|
||||||
|
|
||||||
|
const channels = customerResetService.sendResetLinkToUser.mock.calls.map(
|
||||||
|
(c) => c[1],
|
||||||
|
);
|
||||||
|
expect(channels).toEqual([ResetChannel.Email]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the registration when the activation link fails to send", async () => {
|
||||||
|
customerResetService.sendResetLinkToUser.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.register(dto as never);
|
||||||
|
|
||||||
|
// The account is valid without the link and the link is resendable —
|
||||||
|
// a delivery failure must not roll back the registration.
|
||||||
|
expect(result.shippingLine).toMatchObject({ id: "sl-1" });
|
||||||
|
expect(result.activationSentTo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a duplicate email", async () => {
|
||||||
|
shippingLinesRepo.existsByEmail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a duplicate SCAC", async () => {
|
||||||
|
shippingLinesRepo.existsByScac.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses credentials already belonging to another account", async () => {
|
||||||
|
// Reusing an existing IAM user would let one login resolve to both a
|
||||||
|
// customer and a shipping line.
|
||||||
|
userRepository.findOne.mockResolvedValue({ id: "existing" });
|
||||||
|
|
||||||
|
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the username to the email", async () => {
|
||||||
|
await service.register(dto as never);
|
||||||
|
|
||||||
|
expect(userRepoInTx.create.mock.calls[0][0]).toMatchObject({
|
||||||
|
username: "ops@esl.com.et",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default reset lookup inner-joins an active `user_credentials` row so a
|
||||||
|
* reset cannot revive a suspended account. A shipping line has no credential
|
||||||
|
* until it uses the activation link, so without this flag the account is
|
||||||
|
* excluded from its own activation — the link is never minted, never logged,
|
||||||
|
* and resend answers 404.
|
||||||
|
*/
|
||||||
|
it("requests the credential-less lookup for every activation send", async () => {
|
||||||
|
await service.register(dto as never);
|
||||||
|
|
||||||
|
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalled();
|
||||||
|
for (const call of customerResetService.sendResetLinkToUser.mock.calls) {
|
||||||
|
expect(call[2]).toMatchObject({ allowWithoutCredential: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests the credential-less lookup when resending", async () => {
|
||||||
|
shippingLinesRepo.findById.mockResolvedValue({
|
||||||
|
id: "sl-1",
|
||||||
|
userId: "user-1",
|
||||||
|
phoneNumber: "+251911223344",
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.resendActivation("sl-1", ResetChannel.Email);
|
||||||
|
|
||||||
|
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith(
|
||||||
|
"user-1",
|
||||||
|
ResetChannel.Email,
|
||||||
|
expect.objectContaining({ allowWithoutCredential: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import {
|
||||||
|
EUserStatus,
|
||||||
|
EUserType,
|
||||||
|
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||||
|
// Subpath import (not the package root) so ts-jest can resolve it when this
|
||||||
|
// file lands in a spec's compile graph — same reason as backoffice.service.ts.
|
||||||
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
|
import { DataSource, Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { CustomerResetService } from "../auth/customer-reset.service";
|
||||||
|
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||||
|
import { isDomesticPhone } from "../otp/otp.service";
|
||||||
|
import { CreateShippingLineDto } from "./dto/create-shipping-line.dto";
|
||||||
|
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||||
|
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
||||||
|
|
||||||
|
export interface RegisteredShippingLine {
|
||||||
|
shippingLine: ShippingLineCompany;
|
||||||
|
/** Masked destination of the activation link, or null if none was sent. */
|
||||||
|
activationSentTo: string | null;
|
||||||
|
activationChannel: ResetChannel | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShippingLineCompaniesService {
|
||||||
|
private readonly logger = new Logger(ShippingLineCompaniesService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly shippingLineCompaniesRepo: ShippingLineCompaniesRepository,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
private readonly customerResetService: CustomerResetService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a shipping line: create its IAM account and its record together,
|
||||||
|
* then send an activation link so the line sets its own password.
|
||||||
|
*
|
||||||
|
* The IAM mechanics follow `BackofficeService.createOrganizationUser` — same
|
||||||
|
* entities, same transaction shape — with one deliberate difference: no
|
||||||
|
* `UserCredential` row is written and `hasSetPassword` stays false. Staff
|
||||||
|
* creating an employee seed a shared default password; a shipping line must
|
||||||
|
* come through the activation link instead, so no credential exists until the
|
||||||
|
* line sets one.
|
||||||
|
*/
|
||||||
|
async register(dto: CreateShippingLineDto): Promise<RegisteredShippingLine> {
|
||||||
|
const email = dto.email.trim().toLowerCase();
|
||||||
|
const username = (dto.username?.trim() || email).toLowerCase();
|
||||||
|
const phoneNumber = dto.phoneNumber?.trim() || undefined;
|
||||||
|
const scacCode = dto.scacCode?.trim().toUpperCase();
|
||||||
|
|
||||||
|
if (await this.shippingLineCompaniesRepo.existsByEmail(email)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`A shipping line with email ${email} already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scacCode && (await this.shippingLineCompaniesRepo.existsByScac(scacCode))) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`A shipping line with SCAC ${scacCode} already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An existing IAM account means these credentials already belong to a
|
||||||
|
// customer or an employee. Reusing it would let one login resolve to two
|
||||||
|
// different account kinds, so this is refused rather than merged — unlike
|
||||||
|
// employee creation, which legitimately re-uses a person's existing user.
|
||||||
|
const existingUser = await this.userRepository.findOne({
|
||||||
|
where: [{ email }, { username }],
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"email_or_username_already_in_use",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shippingLine = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const userRepo = manager.getRepository(User);
|
||||||
|
const user = await userRepo.save(
|
||||||
|
userRepo.create({
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
phoneNumber,
|
||||||
|
name: { en: dto.name.trim() },
|
||||||
|
userType: EUserType.INDIVIDUAL,
|
||||||
|
isActive: true,
|
||||||
|
// No credential row is written: the account has no password until the
|
||||||
|
// activation link is used. `hasSetPassword` must stay false or the
|
||||||
|
// portal treats the account as ready to sign in with a password that
|
||||||
|
// does not exist.
|
||||||
|
hasSetPassword: false,
|
||||||
|
status: EUserStatus.ACCEPTED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.shippingLineCompaniesRepo.createInTransaction(manager, {
|
||||||
|
userId: user.id as string,
|
||||||
|
name: dto.name.trim(),
|
||||||
|
email,
|
||||||
|
phoneNumber: phoneNumber ?? null,
|
||||||
|
scacCode: scacCode ?? null,
|
||||||
|
imoNumber: dto.imoNumber?.trim() || null,
|
||||||
|
bicCode: dto.bicCode?.trim() || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Outside the transaction on purpose: a delivery failure must not roll back
|
||||||
|
// a registered line. The link is resendable, and the account is already
|
||||||
|
// valid without it.
|
||||||
|
const activation = await this.sendActivationLink(shippingLine);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shippingLine,
|
||||||
|
activationSentTo: activation?.maskedTarget ?? null,
|
||||||
|
activationChannel: activation?.channel ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the activation link on registration.
|
||||||
|
*
|
||||||
|
* Email always goes out — it is required at registration and is the only
|
||||||
|
* channel guaranteed to reach a foreign-registered line. SMS is sent in
|
||||||
|
* addition when the number is domestic, since the gateway silently drops
|
||||||
|
* anything else (see `CustomerResetService`). Two links are two independent
|
||||||
|
* single-use tickets; whichever the line opens first works.
|
||||||
|
*
|
||||||
|
* Reports the email send, as that is the one that is always attempted.
|
||||||
|
*/
|
||||||
|
async sendActivationLink(shippingLine: ShippingLineCompany) {
|
||||||
|
const scope = `shipping line ${shippingLine.id}`;
|
||||||
|
|
||||||
|
const emailed = await this.customerResetService.sendResetLinkToUser(
|
||||||
|
shippingLine.userId,
|
||||||
|
ResetChannel.Email,
|
||||||
|
{ scope, allowWithoutCredential: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!emailed) {
|
||||||
|
this.logger.error(
|
||||||
|
`Activation email not sent for shipping line ${shippingLine.id} — no reachable address`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
|
||||||
|
const texted = await this.customerResetService.sendResetLinkToUser(
|
||||||
|
shippingLine.userId,
|
||||||
|
ResetChannel.Phone,
|
||||||
|
{ scope, allowWithoutCredential: true },
|
||||||
|
);
|
||||||
|
if (!texted) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Activation SMS not sent for shipping line ${shippingLine.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return emailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resendActivation(id: string, channel: ResetChannel) {
|
||||||
|
const shippingLine = await this.shippingLineCompaniesRepo.findById(id);
|
||||||
|
if (!shippingLine) {
|
||||||
|
throw new NotFoundException("Shipping line not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
channel === ResetChannel.Phone &&
|
||||||
|
(!shippingLine.phoneNumber || !isDomesticPhone(shippingLine.phoneNumber))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This shipping line has no domestic phone number — the SMS gateway cannot reach it",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = await this.customerResetService.sendResetLinkToUser(
|
||||||
|
shippingLine.userId,
|
||||||
|
channel,
|
||||||
|
{ scope: `shipping line ${shippingLine.id}`, allowWithoutCredential: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sent) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`No active account with ${
|
||||||
|
channel === ResetChannel.Email ? "an email address" : "a phone number"
|
||||||
|
} for this shipping line`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<ShippingLineCompany> {
|
||||||
|
const shippingLine = await this.shippingLineCompaniesRepo.findById(id);
|
||||||
|
if (!shippingLine) {
|
||||||
|
throw new NotFoundException("Shipping line not found");
|
||||||
|
}
|
||||||
|
return shippingLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shipping line signed in as `userId`, or null for any other account. */
|
||||||
|
findByUserId(userId: string): Promise<ShippingLineCompany | null> {
|
||||||
|
return this.shippingLineCompaniesRepo.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(page = 1, limit = 20) {
|
||||||
|
const [items, total] = await this.shippingLineCompaniesRepo.findAllPaginated(
|
||||||
|
(page - 1) * limit,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
return { items, total, page, limit };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -450,6 +450,30 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// C2. Shipping lines — carriers registered by staff (no self-signup).
|
||||||
|
export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm(
|
||||||
|
"d1a00002-0001-4000-8000-000000000001",
|
||||||
|
"edr_freight_app:shipping_lines:view",
|
||||||
|
"View shipping lines",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"d1a00002-0001-4000-8000-000000000002",
|
||||||
|
"edr_freight_app:shipping_lines:create",
|
||||||
|
"Register shipping line",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"d1a00002-0001-4000-8000-000000000003",
|
||||||
|
"edr_freight_app:shipping_lines:update",
|
||||||
|
"Update shipping line",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"d1a00002-0001-4000-8000-000000000004",
|
||||||
|
"edr_freight_app:shipping_lines:reset-password",
|
||||||
|
"Resend shipping line activation link",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
// D. Finance — payments + invoices
|
// D. Finance — payments + invoices
|
||||||
export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
perm(
|
perm(
|
||||||
@@ -1493,6 +1517,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
|
|
||||||
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
...CUSTOMER_PERMISSIONS,
|
...CUSTOMER_PERMISSIONS,
|
||||||
|
...SHIPPING_LINE_PERMISSIONS,
|
||||||
...FINANCE_PERMISSIONS,
|
...FINANCE_PERMISSIONS,
|
||||||
...MILE_PERMISSIONS,
|
...MILE_PERMISSIONS,
|
||||||
...FLEET_RAIL_PERMISSIONS,
|
...FLEET_RAIL_PERMISSIONS,
|
||||||
@@ -1699,6 +1724,12 @@ export const FREIGHT_PERMS = {
|
|||||||
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
|
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
|
||||||
getNotification: "edr_freight_app:customers:get_notification",
|
getNotification: "edr_freight_app:customers:get_notification",
|
||||||
},
|
},
|
||||||
|
shippingLines: {
|
||||||
|
view: "edr_freight_app:shipping_lines:view",
|
||||||
|
create: "edr_freight_app:shipping_lines:create",
|
||||||
|
update: "edr_freight_app:shipping_lines:update",
|
||||||
|
resetPassword: "edr_freight_app:shipping_lines:reset-password",
|
||||||
|
},
|
||||||
payments: {
|
payments: {
|
||||||
view: "edr_freight_app:payments:view",
|
view: "edr_freight_app:payments:view",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetai
|
|||||||
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
|
import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage";
|
||||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||||
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
|
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
|
||||||
@@ -242,6 +243,14 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="shipping-lines"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.shippingLines.view}>
|
||||||
|
<ShippingLineCompaniesPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="invoices"
|
path="invoices"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
|||||||
icon: <Building2 />,
|
icon: <Building2 />,
|
||||||
permission: FREIGHT_PERMS.customers.view,
|
permission: FREIGHT_PERMS.customers.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Shipping Lines",
|
||||||
|
href: "/dashboard/shipping-lines",
|
||||||
|
icon: <Ship />,
|
||||||
|
permission: FREIGHT_PERMS.shippingLines.view,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Contracts",
|
label: "Contracts",
|
||||||
href: "/dashboard/contract-requests",
|
href: "/dashboard/contract-requests",
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Radio,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Send } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type {
|
||||||
|
ResetChannel,
|
||||||
|
ShippingLineCompany,
|
||||||
|
} from "@/types/shippingLineCompany";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the SMS gateway can actually reach this number.
|
||||||
|
*
|
||||||
|
* The carrier integration is domestic-only: anything else is queued and
|
||||||
|
* silently lost, so a foreign number counts as unavailable rather than as a
|
||||||
|
* send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service.
|
||||||
|
*/
|
||||||
|
function isDomesticPhone(rawPhone: string): boolean {
|
||||||
|
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
|
||||||
|
const normalized = digits.startsWith("+")
|
||||||
|
? digits
|
||||||
|
: /^251\d{9}$/.test(digits)
|
||||||
|
? `+${digits}`
|
||||||
|
: /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, ""))
|
||||||
|
? `+251${digits.replace(/^0+/, "")}`
|
||||||
|
: digits;
|
||||||
|
return /^\+2519\d{8}$/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResendActivationActionProps {
|
||||||
|
shippingLine: Pick<ShippingLineCompany, "id" | "name" | "email" | "phoneNumber">;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend a shipping line's activation link.
|
||||||
|
*
|
||||||
|
* The same single-use link registration sends: the carrier opens it and picks
|
||||||
|
* their own password, so no credential is ever shown to or handled by staff.
|
||||||
|
* Needed whenever the original send failed, expired (24h), or never arrived.
|
||||||
|
*/
|
||||||
|
export default function ResendActivationAction({
|
||||||
|
shippingLine,
|
||||||
|
}: ResendActivationActionProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [channel, setChannel] = useState<ResetChannel>("email");
|
||||||
|
|
||||||
|
const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword);
|
||||||
|
|
||||||
|
const { mutate, isPending } = useMutation(
|
||||||
|
api.shippingLineCompanies.resendActivation.mutationOptions({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setOpened(false);
|
||||||
|
toast({
|
||||||
|
title: "Activation link sent",
|
||||||
|
description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Could not send activation link",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allowed) return null;
|
||||||
|
|
||||||
|
const phoneUsable =
|
||||||
|
!!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber);
|
||||||
|
const channelMissing = channel === "phone" && !phoneUsable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip label="Resend activation link" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={`Resend activation link to ${shippingLine.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
// The row itself is not clickable today, but stop here anyway so
|
||||||
|
// adding a detail-page navigation later cannot swallow this click.
|
||||||
|
event.stopPropagation();
|
||||||
|
setOpened(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => setOpened(false)}
|
||||||
|
title="Resend activation link"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
We'll send a single-use link to {shippingLine.name}. They choose
|
||||||
|
their own password — you will not see it. The link expires in 24
|
||||||
|
hours, and sending a new one invalidates nothing they haven't
|
||||||
|
already used.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Radio.Group
|
||||||
|
value={channel}
|
||||||
|
onChange={(v) => setChannel(v as ResetChannel)}
|
||||||
|
label="Send the link via"
|
||||||
|
>
|
||||||
|
<Stack gap="xs" mt="xs">
|
||||||
|
<Radio
|
||||||
|
value="email"
|
||||||
|
label="Email"
|
||||||
|
description={shippingLine.email}
|
||||||
|
/>
|
||||||
|
<Radio
|
||||||
|
value="phone"
|
||||||
|
label="SMS"
|
||||||
|
disabled={!phoneUsable}
|
||||||
|
description={
|
||||||
|
!shippingLine.phoneNumber
|
||||||
|
? "No phone number on this account"
|
||||||
|
: !phoneUsable
|
||||||
|
? `${shippingLine.phoneNumber} — foreign number, SMS unavailable; use email`
|
||||||
|
: shippingLine.phoneNumber
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
{channelMissing ? (
|
||||||
|
<Alert color="yellow" variant="light" p="sm">
|
||||||
|
<Text size="sm">
|
||||||
|
This account has no number the SMS gateway can reach. Send the
|
||||||
|
link by email instead.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={isPending}
|
||||||
|
disabled={channelMissing}
|
||||||
|
onClick={() => mutate({ id: shippingLine.id, channel })}
|
||||||
|
>
|
||||||
|
Send activation link
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,14 @@ export const QUERY_KEYS = {
|
|||||||
byCode: (code: string) => ["dropdown-settings", "by-code", code] as const,
|
byCode: (code: string) => ["dropdown-settings", "by-code", code] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
SHIPPING_LINE_COMPANIES: {
|
||||||
|
ROOT: ["shipping-line-companies"] as const,
|
||||||
|
list: (page: number, limit: number) =>
|
||||||
|
["shipping-line-companies", "list", page, limit] as const,
|
||||||
|
byId: (id: string) =>
|
||||||
|
["shipping-line-companies", "detail", id] as const,
|
||||||
|
},
|
||||||
|
|
||||||
CUSTOMERS: {
|
CUSTOMERS: {
|
||||||
ROOT: ["customers"] as const,
|
ROOT: ["customers"] as const,
|
||||||
stats: ["customers", "stats"] as const,
|
stats: ["customers", "stats"] as const,
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ export const URL_CONSTANTS = {
|
|||||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carriers with a portal login. Distinct from `/shipping-lines`, which is the
|
||||||
|
* rule-engine's pricing lookup list (a code/label bookings reference).
|
||||||
|
*/
|
||||||
|
SHIPPING_LINE_COMPANIES: {
|
||||||
|
BASE: "/shipping-line-companies",
|
||||||
|
BY_ID: (id: string) => `/shipping-line-companies/${id}`,
|
||||||
|
RESEND_ACTIVATION: (id: string) =>
|
||||||
|
`/shipping-line-companies/${id}/resend-activation`,
|
||||||
|
},
|
||||||
|
|
||||||
COMPANIES: {
|
COMPANIES: {
|
||||||
BASE: "/companies",
|
BASE: "/companies",
|
||||||
STATS: "/companies/stats",
|
STATS: "/companies/stats",
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ export const FREIGHT_PERMS = {
|
|||||||
verify: "edr_freight_app:customers:verify",
|
verify: "edr_freight_app:customers:verify",
|
||||||
resetPassword: "edr_freight_app:customers:reset-password",
|
resetPassword: "edr_freight_app:customers:reset-password",
|
||||||
},
|
},
|
||||||
|
shippingLines: {
|
||||||
|
view: "edr_freight_app:shipping_lines:view",
|
||||||
|
create: "edr_freight_app:shipping_lines:create",
|
||||||
|
update: "edr_freight_app:shipping_lines:update",
|
||||||
|
resetPassword: "edr_freight_app:shipping_lines:reset-password",
|
||||||
|
},
|
||||||
payments: {
|
payments: {
|
||||||
view: "edr_freight_app:payments:view",
|
view: "edr_freight_app:payments:view",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Info, Mail, Phone, Plus, Ship } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import ResendActivationAction from "@/components/shipping-lines/ResendActivationAction";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { ShippingLineCompany } from "@/types/shippingLineCompany";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
/** SCAC is 2-4 letters; the API enforces the same rule. */
|
||||||
|
const SCAC_PATTERN = /^[A-Za-z]{2,4}$/;
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
scacCode: string;
|
||||||
|
imoNumber: string;
|
||||||
|
bicCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormValues = {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
scacCode: "",
|
||||||
|
imoNumber: "",
|
||||||
|
bicCode: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping line companies — carriers with their own portal login.
|
||||||
|
*
|
||||||
|
* Registration is staff-only: there is no self-signup. Staff never set a
|
||||||
|
* password; the system emails (and texts, when the number is domestic) a
|
||||||
|
* single-use activation link that the carrier uses to choose their own.
|
||||||
|
*/
|
||||||
|
export default function ShippingLineCompaniesPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [registerOpen, setRegisterOpen] = useState(false);
|
||||||
|
|
||||||
|
const canCreate = hasPermission(user, FREIGHT_PERMS.shippingLines.create);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||||
|
api.shippingLineCompanies.list.queryOptions({
|
||||||
|
input: {
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
limit: pagination.pageSize,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
|
const [values, setValues] = useState<FormValues>(EMPTY_FORM);
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
|
||||||
|
const setField = (field: keyof FormValues) => (value: string) =>
|
||||||
|
setValues((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
|
// Mirrors the API's own validation, so the obvious mistakes are caught before
|
||||||
|
// a round trip. The server still enforces all of it.
|
||||||
|
const errors = {
|
||||||
|
name: values.name.trim() ? null : "Company name is required",
|
||||||
|
// Required, unlike a customer's: the activation link is sent here, so an
|
||||||
|
// account without one could never be signed in to.
|
||||||
|
email: /^\S+@\S+\.\S+$/.test(values.email.trim())
|
||||||
|
? null
|
||||||
|
: "A valid email is required",
|
||||||
|
scacCode:
|
||||||
|
!values.scacCode.trim() || SCAC_PATTERN.test(values.scacCode.trim())
|
||||||
|
? null
|
||||||
|
: "SCAC must be 2-4 letters",
|
||||||
|
};
|
||||||
|
const isValid = !errors.name && !errors.email && !errors.scacCode;
|
||||||
|
|
||||||
|
const closeRegister = () => {
|
||||||
|
setRegisterOpen(false);
|
||||||
|
setValues(EMPTY_FORM);
|
||||||
|
setTouched(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutate: register, isPending: isRegistering } = useMutation(
|
||||||
|
api.shippingLineCompanies.register.mutationOptions({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
closeRegister();
|
||||||
|
toast({
|
||||||
|
title: "Shipping line registered",
|
||||||
|
description: result.activationSentTo
|
||||||
|
? `An activation link was sent to ${result.activationSentTo}. It expires in 24 hours.`
|
||||||
|
: // The account exists and is valid — only delivery failed, and the
|
||||||
|
// link can be resent, so this is a warning rather than an error.
|
||||||
|
"The account was created, but the activation link could not be sent. Use “Resend activation” to try again.",
|
||||||
|
variant: result.activationSentTo ? undefined : "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({
|
||||||
|
title: "Could not register shipping line",
|
||||||
|
description: err.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: ColumnDef<ShippingLineCompany>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: "Shipping line",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const sl = row.original;
|
||||||
|
return (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: "var(--mantine-color-edr-green-1)",
|
||||||
|
color: "var(--mantine-color-edr-green-7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ship size={18} strokeWidth={1.9} />
|
||||||
|
</Box>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={600} c="edr-text" truncate>
|
||||||
|
{sl.name}
|
||||||
|
</Text>
|
||||||
|
{sl.scacCode ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
SCAC {sl.scacCode}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contact",
|
||||||
|
header: "Contact",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const sl = row.original;
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Mail size={13} className="shrink-0 text-gray-400" />
|
||||||
|
<Text size="sm" truncate>
|
||||||
|
{sl.email}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{sl.phoneNumber ? (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Phone size={13} className="shrink-0 text-gray-400" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{sl.phoneNumber}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "identifiers",
|
||||||
|
header: "Identifiers",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { imoNumber, bicCode } = row.original;
|
||||||
|
if (!imoNumber && !bicCode) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
{imoNumber ? <Text size="sm">IMO {imoNumber}</Text> : null}
|
||||||
|
{bicCode ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
BIC {bicCode}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color={row.original.status === "active" ? "green" : "red"}
|
||||||
|
>
|
||||||
|
{row.original.status === "active" ? "Active" : "Suspended"}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "createdAt",
|
||||||
|
header: "Registered",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.createdAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
|
<ResendActivationAction shippingLine={row.original} />
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<PageHeader
|
||||||
|
title="Shipping Lines"
|
||||||
|
subtitle="Carriers with their own portal access. Registered by staff — there is no self-signup."
|
||||||
|
action={
|
||||||
|
canCreate ? (
|
||||||
|
<Button
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setRegisterOpen(true)}
|
||||||
|
>
|
||||||
|
Register shipping line
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card withBorder padding={0} radius="md">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
|
emptyMessage="No shipping lines registered yet."
|
||||||
|
error={
|
||||||
|
isError
|
||||||
|
? {
|
||||||
|
message: error?.message ?? "Failed to load shipping lines.",
|
||||||
|
onRetry: () => void refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
}}
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={registerOpen}
|
||||||
|
onClose={closeRegister}
|
||||||
|
title="Register shipping line"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setTouched(true);
|
||||||
|
if (!isValid) return;
|
||||||
|
register({
|
||||||
|
name: values.name.trim(),
|
||||||
|
email: values.email.trim(),
|
||||||
|
phoneNumber: values.phoneNumber.trim() || undefined,
|
||||||
|
scacCode: values.scacCode.trim() || undefined,
|
||||||
|
imoNumber: values.imoNumber.trim() || undefined,
|
||||||
|
bicCode: values.bicCode.trim() || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert
|
||||||
|
icon={<Info size={16} />}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
p="sm"
|
||||||
|
>
|
||||||
|
<Text size="sm">
|
||||||
|
No password is set here. The shipping line receives a single-use
|
||||||
|
activation link and chooses their own.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Company name"
|
||||||
|
placeholder="Ethiopian Shipping Lines"
|
||||||
|
withAsterisk
|
||||||
|
value={values.name}
|
||||||
|
onChange={(e) => setField("name")(e.currentTarget.value)}
|
||||||
|
error={touched ? errors.name : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
placeholder="ops@example.com"
|
||||||
|
description="The activation link is sent here."
|
||||||
|
withAsterisk
|
||||||
|
value={values.email}
|
||||||
|
onChange={(e) => setField("email")(e.currentTarget.value)}
|
||||||
|
error={touched ? errors.email : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Phone number"
|
||||||
|
placeholder="+251911223344"
|
||||||
|
description="Ethiopian numbers also receive the link by SMS."
|
||||||
|
value={values.phoneNumber}
|
||||||
|
onChange={(e) => setField("phoneNumber")(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group grow align="flex-start">
|
||||||
|
<TextInput
|
||||||
|
label="SCAC"
|
||||||
|
placeholder="ESLK"
|
||||||
|
value={values.scacCode}
|
||||||
|
onChange={(e) => setField("scacCode")(e.currentTarget.value)}
|
||||||
|
error={touched ? errors.scacCode : null}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="IMO number"
|
||||||
|
placeholder="IMO9074729"
|
||||||
|
value={values.imoNumber}
|
||||||
|
onChange={(e) => setField("imoNumber")(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="BIC code"
|
||||||
|
placeholder="ESLU"
|
||||||
|
value={values.bicCode}
|
||||||
|
onChange={(e) => setField("bicCode")(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm" mt="xs">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={closeRegister}
|
||||||
|
disabled={isRegistering}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isRegistering}>
|
||||||
|
Register & send link
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,12 @@ import type {
|
|||||||
PaginatedOfflineUsdInvoices,
|
PaginatedOfflineUsdInvoices,
|
||||||
} from "@/types/invoice";
|
} from "@/types/invoice";
|
||||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||||
|
import type {
|
||||||
|
CreateShippingLineCompanyDto,
|
||||||
|
PaginatedShippingLineCompanies,
|
||||||
|
RegisterShippingLineCompanyResult,
|
||||||
|
ShippingLineCompany,
|
||||||
|
} from "@/types/shippingLineCompany";
|
||||||
import {
|
import {
|
||||||
RuleEngineListResult,
|
RuleEngineListResult,
|
||||||
RuleEngineRecord,
|
RuleEngineRecord,
|
||||||
@@ -150,6 +156,7 @@ import {
|
|||||||
import { containerTypesService } from "./container-types.service";
|
import { containerTypesService } from "./container-types.service";
|
||||||
import { containerService, type Container } from "./containerService";
|
import { containerService, type Container } from "./containerService";
|
||||||
import { customersService } from "./customers.service";
|
import { customersService } from "./customers.service";
|
||||||
|
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
|
||||||
import { eimsService } from "./eims.service";
|
import { eimsService } from "./eims.service";
|
||||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
||||||
import { invoicesService } from "./invoices.service";
|
import { invoicesService } from "./invoices.service";
|
||||||
@@ -2763,6 +2770,43 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
shippingLineCompanies: {
|
||||||
|
list: endpoint<{ page: number; limit: number }, PaginatedShippingLineCompanies>(
|
||||||
|
"shippingLineCompanies",
|
||||||
|
"list",
|
||||||
|
({ page, limit }) => shippingLineCompaniesService.list(page, limit),
|
||||||
|
({ page, limit }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.list(page, limit),
|
||||||
|
),
|
||||||
|
|
||||||
|
getById: endpoint<{ id: string }, ShippingLineCompany>(
|
||||||
|
"shippingLineCompanies",
|
||||||
|
"getById",
|
||||||
|
({ id }) => shippingLineCompaniesService.getById(id),
|
||||||
|
({ id }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.byId(id),
|
||||||
|
),
|
||||||
|
|
||||||
|
register: endpoint<
|
||||||
|
CreateShippingLineCompanyDto,
|
||||||
|
RegisterShippingLineCompanyResult
|
||||||
|
>(
|
||||||
|
"shippingLineCompanies",
|
||||||
|
"register",
|
||||||
|
(dto) => shippingLineCompaniesService.register(dto),
|
||||||
|
undefined,
|
||||||
|
() => [QUERY_KEYS.SHIPPING_LINE_COMPANIES.ROOT],
|
||||||
|
),
|
||||||
|
|
||||||
|
resendActivation: endpoint<
|
||||||
|
{ id: string; channel: ResetChannel },
|
||||||
|
ResetPasswordResult
|
||||||
|
>(
|
||||||
|
"shippingLineCompanies",
|
||||||
|
"resendActivation",
|
||||||
|
({ id, channel }) =>
|
||||||
|
shippingLineCompaniesService.resendActivation(id, channel),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
customers: {
|
customers: {
|
||||||
stats: endpoint<Record<string, never>, CompanyStats>(
|
stats: endpoint<Record<string, never>, CompanyStats>(
|
||||||
"customers",
|
"customers",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { api as apiClient } from "@/auth/http";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type {
|
||||||
|
CreateShippingLineCompanyDto,
|
||||||
|
PaginatedShippingLineCompanies,
|
||||||
|
RegisterShippingLineCompanyResult,
|
||||||
|
ResetChannel,
|
||||||
|
ResetPasswordResult,
|
||||||
|
ShippingLineCompany,
|
||||||
|
} from "@/types/shippingLineCompany";
|
||||||
|
|
||||||
|
export const shippingLineCompaniesService = {
|
||||||
|
list(page = 1, limit = 20): Promise<PaginatedShippingLineCompanies> {
|
||||||
|
return apiClient
|
||||||
|
.get<PaginatedShippingLineCompanies>(
|
||||||
|
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
|
||||||
|
{ params: { page, limit } },
|
||||||
|
)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById(id: string): Promise<ShippingLineCompany> {
|
||||||
|
return apiClient
|
||||||
|
.get<ShippingLineCompany>(URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BY_ID(id))
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the carrier's account and record, then sends an activation link.
|
||||||
|
* Staff never set or see a password — the line chooses its own from the link.
|
||||||
|
*/
|
||||||
|
register(
|
||||||
|
dto: CreateShippingLineCompanyDto,
|
||||||
|
): Promise<RegisterShippingLineCompanyResult> {
|
||||||
|
return apiClient
|
||||||
|
.post<RegisterShippingLineCompanyResult>(
|
||||||
|
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
|
||||||
|
dto,
|
||||||
|
)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
resendActivation(
|
||||||
|
id: string,
|
||||||
|
channel: ResetChannel,
|
||||||
|
): Promise<ResetPasswordResult> {
|
||||||
|
return apiClient
|
||||||
|
.post<ResetPasswordResult>(
|
||||||
|
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.RESEND_ACTIVATION(id),
|
||||||
|
{ channel },
|
||||||
|
)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ResetChannel, ResetPasswordResult } from "./customer";
|
||||||
|
|
||||||
|
export type ShippingLineStatus = "active" | "suspended";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A carrier with its own portal login, registered by staff.
|
||||||
|
*
|
||||||
|
* Not to be confused with the rule-engine's `ShippingLine` (types/rule-engine):
|
||||||
|
* that is a pricing lookup — a code/label a booking points at — with no account
|
||||||
|
* and no login. This one is the account.
|
||||||
|
*/
|
||||||
|
export interface ShippingLineCompany {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phoneNumber: string | null;
|
||||||
|
scacCode: string | null;
|
||||||
|
imoNumber: string | null;
|
||||||
|
bicCode: string | null;
|
||||||
|
status: ShippingLineStatus;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateShippingLineCompanyDto {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
scacCode?: string;
|
||||||
|
imoNumber?: string;
|
||||||
|
bicCode?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterShippingLineCompanyResult {
|
||||||
|
shippingLine: ShippingLineCompany;
|
||||||
|
/**
|
||||||
|
* Masked destination the activation link went to, or null when delivery
|
||||||
|
* failed. The registration still succeeded — the link is resendable.
|
||||||
|
*/
|
||||||
|
activationSentTo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedShippingLineCompanies {
|
||||||
|
items: ShippingLineCompany[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ResetChannel, ResetPasswordResult };
|
||||||
@@ -59,6 +59,13 @@ import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
|||||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||||
|
import {
|
||||||
|
ShippingLineBookingsPage,
|
||||||
|
ShippingLineHelpPage,
|
||||||
|
ShippingLineHomePage,
|
||||||
|
ShippingLineInvoicesPage,
|
||||||
|
ShippingLineSettingsPage,
|
||||||
|
} from "./pages/shipping-line";
|
||||||
import FaqPage from "./pages/support/FaqPage";
|
import FaqPage from "./pages/support/FaqPage";
|
||||||
import HelpPage from "./pages/support/HelpPage";
|
import HelpPage from "./pages/support/HelpPage";
|
||||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||||
@@ -129,12 +136,20 @@ function isOnboardingAllowedPath(pathname: string): boolean {
|
|||||||
* can be dismissed to use those pages. Visiting any other page bounces back to
|
* can be dismissed to use those pages. Visiting any other page bounces back to
|
||||||
* home and re-opens the wizard. New users (no company yet) are treated the same
|
* home and re-opens the wizard. New users (no company yet) are treated the same
|
||||||
* as users who haven't completed onboarding.
|
* as users who haven't completed onboarding.
|
||||||
|
*
|
||||||
|
* Shipping lines are exempt: staff register them with their details already
|
||||||
|
* captured, so there is nothing for them to onboard — they go straight to home.
|
||||||
*/
|
*/
|
||||||
function OnboardingGate() {
|
function OnboardingGate() {
|
||||||
const { company, onboardingCompleted } = useAuth();
|
const { company, onboardingCompleted, isShippingLine } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const needsOnboarding = !company || !onboardingCompleted;
|
// Keyed off a positive shipping-line identification, never off "no company":
|
||||||
|
// that is also true mid-fetch and on error, which would let customers slip
|
||||||
|
// past onboarding whenever the request failed.
|
||||||
|
const needsOnboarding = isShippingLine
|
||||||
|
? false
|
||||||
|
: !company || !onboardingCompleted;
|
||||||
const allowedHere = isOnboardingAllowedPath(location.pathname);
|
const allowedHere = isOnboardingAllowedPath(location.pathname);
|
||||||
|
|
||||||
// Open by default while onboarding is pending (covers the login case).
|
// Open by default while onboarding is pending (covers the login case).
|
||||||
@@ -176,21 +191,69 @@ function OnboardingGate() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-only routes. A shipping line that lands on one (an old link, a
|
||||||
|
* bookmark, a hand-typed URL) is sent to its own home rather than shown a
|
||||||
|
* contract/company-shaped page that has no meaning for it.
|
||||||
|
*/
|
||||||
|
function RequireCustomer() {
|
||||||
|
const { isShippingLine, customerQuery } = useAuth();
|
||||||
|
|
||||||
|
// RequireCompany already awaits this query, but guard anyway: a refetch can
|
||||||
|
// flip `isPending` back on, and redirecting on a half-loaded account would
|
||||||
|
// throw the user into the wrong app.
|
||||||
|
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||||
|
if (isShippingLine) return <Navigate to="/shipping-line" replace />;
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mirror of RequireCustomer: shipping-line routes, closed to customers. */
|
||||||
|
function RequireShippingLine() {
|
||||||
|
const { isShippingLine, customerQuery } = useAuth();
|
||||||
|
|
||||||
|
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||||
|
if (!isShippingLine) return <Navigate to="/portal" replace />;
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a signed-in account belongs. Shipping lines and customers have separate
|
||||||
|
* apps, so every "you're already logged in" redirect has to pick between them.
|
||||||
|
* Waits for the company query: `isShippingLine` is false while that request is
|
||||||
|
* still in flight, which would land a shipping line on the customer home first.
|
||||||
|
*/
|
||||||
|
function useHomeRoute(): { ready: boolean; href: string } {
|
||||||
|
const { isShippingLine, customerQuery } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: !customerQuery.isPending,
|
||||||
|
href: isShippingLine ? "/shipping-line" : "/portal",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Keeps authenticated users off the login/signup pages. */
|
/** Keeps authenticated users off the login/signup pages. */
|
||||||
function RedirectIfAuthed() {
|
function RedirectIfAuthed() {
|
||||||
const { isPending, isAuthenticated } = useAuth();
|
const { isPending, isAuthenticated } = useAuth();
|
||||||
|
const home = useHomeRoute();
|
||||||
|
|
||||||
if (isPending) return <FullScreenSpinner />;
|
if (isPending) return <FullScreenSpinner />;
|
||||||
if (isAuthenticated) return <Navigate to="/portal" replace />;
|
if (isAuthenticated) {
|
||||||
|
if (!home.ready) return <FullScreenSpinner />;
|
||||||
|
return <Navigate to={home.href} replace />;
|
||||||
|
}
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Landing page for visitors; authenticated users go straight to the portal. */
|
/** Landing page for visitors; authenticated users go straight to the portal. */
|
||||||
function LandingRoute() {
|
function LandingRoute() {
|
||||||
const { isPending, isAuthenticated } = useAuth();
|
const { isPending, isAuthenticated } = useAuth();
|
||||||
|
const home = useHomeRoute();
|
||||||
|
|
||||||
if (isPending) return <FullScreenSpinner />;
|
if (isPending) return <FullScreenSpinner />;
|
||||||
if (isAuthenticated) return <Navigate to="/portal" replace />;
|
if (isAuthenticated) {
|
||||||
|
if (!home.ready) return <FullScreenSpinner />;
|
||||||
|
return <Navigate to={home.href} replace />;
|
||||||
|
}
|
||||||
return <EDRFreightLandingPage />;
|
return <EDRFreightLandingPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +293,37 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar for shipping lines. Intentionally its own list rather than a filtered
|
||||||
|
* view of `sidebarItems`: shipping lines have no contracts, and their Home /
|
||||||
|
* Bookings / Invoices pages are different pages at different routes.
|
||||||
|
*/
|
||||||
|
const shippingLineSidebarItems: SidebarItem[] = [
|
||||||
|
{ label: "Home", href: "/shipping-line", icon: <Home size={18} /> },
|
||||||
|
{
|
||||||
|
label: "Bookings",
|
||||||
|
href: "/shipping-line/bookings",
|
||||||
|
icon: <Package size={18} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Invoices",
|
||||||
|
href: "/shipping-line/invoices",
|
||||||
|
icon: <Receipt size={18} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Account",
|
||||||
|
label: "Settings",
|
||||||
|
href: "/shipping-line/settings",
|
||||||
|
icon: <Settings size={18} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Account",
|
||||||
|
label: "Help & Support",
|
||||||
|
href: "/shipping-line/help",
|
||||||
|
icon: <LifeBuoy size={18} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -310,81 +404,134 @@ const App = () => {
|
|||||||
|
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<RequireCompany />}>
|
<Route element={<RequireCompany />}>
|
||||||
<Route
|
{/* Shipping-line app. Its own layout and sidebar, and its own pages
|
||||||
element={
|
at their own routes — nothing here is shared with the customer
|
||||||
<AppLayout
|
branch below beyond the shell component itself. Contracts are
|
||||||
title="EDR Freight"
|
absent by design: shipping lines request bookings directly. */}
|
||||||
sidebarItems={sidebarItems}
|
<Route element={<RequireShippingLine />}>
|
||||||
activeHref={location.pathname}
|
<Route
|
||||||
onNavigate={navigate}
|
element={
|
||||||
userName={displayName}
|
<AppLayout
|
||||||
userEmail={userEmail}
|
title="EDR Freight"
|
||||||
companyProfiles={companyProfiles}
|
sidebarItems={shippingLineSidebarItems}
|
||||||
companyType={companyType}
|
activeHref={location.pathname}
|
||||||
onCreateProfile={createProfile}
|
onNavigate={navigate}
|
||||||
onReapplyProfile={reapplyProfile}
|
userName={displayName}
|
||||||
>
|
userEmail={userEmail}
|
||||||
<OnboardingGate />
|
>
|
||||||
</AppLayout>
|
<Outlet />
|
||||||
}
|
</AppLayout>
|
||||||
>
|
}
|
||||||
<Route path="/portal" element={<MyPortalPage />} />
|
>
|
||||||
{/* Bookings are created against a contract, but the full list is
|
<Route
|
||||||
|
path="/shipping-line"
|
||||||
|
element={<ShippingLineHomePage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/shipping-line/bookings"
|
||||||
|
element={<ShippingLineBookingsPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/shipping-line/invoices"
|
||||||
|
element={<ShippingLineInvoicesPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/shipping-line/settings"
|
||||||
|
element={<ShippingLineSettingsPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/shipping-line/help"
|
||||||
|
element={<ShippingLineHelpPage />}
|
||||||
|
/>
|
||||||
|
{/* Old shared links land on the shipping-line equivalents. */}
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={<Navigate to="/shipping-line/settings" replace />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Customer app — unchanged. */}
|
||||||
|
<Route element={<RequireCustomer />}>
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<AppLayout
|
||||||
|
title="EDR Freight"
|
||||||
|
sidebarItems={sidebarItems}
|
||||||
|
activeHref={location.pathname}
|
||||||
|
onNavigate={navigate}
|
||||||
|
userName={displayName}
|
||||||
|
userEmail={userEmail}
|
||||||
|
companyProfiles={companyProfiles}
|
||||||
|
companyType={companyType}
|
||||||
|
onCreateProfile={createProfile}
|
||||||
|
onReapplyProfile={reapplyProfile}
|
||||||
|
>
|
||||||
|
<OnboardingGate />
|
||||||
|
</AppLayout>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/portal" element={<MyPortalPage />} />
|
||||||
|
{/* Bookings are created against a contract, but the full list is
|
||||||
browsable here. New-booking entry still routes via a contract. */}
|
browsable here. New-booking entry still routes via a contract. */}
|
||||||
<Route path="/bookings" element={<BookingsListPage />} />
|
<Route path="/bookings" element={<BookingsListPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/bookings/new"
|
path="/bookings/new"
|
||||||
element={<Navigate to="/contracts/new" replace />}
|
element={<Navigate to="/contracts/new" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
<Route
|
||||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
path="/bookings/:id/edit"
|
||||||
<Route
|
element={<EditBookingPage />}
|
||||||
path="/bookings/:id/last-mile-confirm"
|
/>
|
||||||
element={<LastMileConfirmPage />}
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
/>
|
<Route
|
||||||
<Route
|
path="/bookings/:id/last-mile-confirm"
|
||||||
path="/bookings/:id/last-mile-contract"
|
element={<LastMileConfirmPage />}
|
||||||
element={<LastMileContractPage />}
|
/>
|
||||||
/>
|
<Route
|
||||||
<Route
|
path="/bookings/:id/last-mile-contract"
|
||||||
path="/bookings/:id/contract"
|
element={<LastMileContractPage />}
|
||||||
element={<BookingContractPage />}
|
/>
|
||||||
/>
|
<Route
|
||||||
<Route path="/contracts" element={<ContractsList />} />
|
path="/bookings/:id/contract"
|
||||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
element={<BookingContractPage />}
|
||||||
<Route
|
/>
|
||||||
path="/contracts/:id/edit"
|
<Route path="/contracts" element={<ContractsList />} />
|
||||||
element={<NewContractPage mode="edit" />}
|
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||||
/>
|
<Route
|
||||||
<Route
|
path="/contracts/:id/edit"
|
||||||
path="/contracts/:id/shipment-requests/new"
|
element={<NewContractPage mode="edit" />}
|
||||||
element={<NewShipmentRequestPage />}
|
/>
|
||||||
/>
|
<Route
|
||||||
<Route
|
path="/contracts/:id/shipment-requests/new"
|
||||||
path="/contracts/:id/bookings/new"
|
element={<NewShipmentRequestPage />}
|
||||||
element={<NewShipmentPage />}
|
/>
|
||||||
/>
|
<Route
|
||||||
{/* Completion of an initiated (bare) booking after per-booking
|
path="/contracts/:id/bookings/new"
|
||||||
|
element={<NewShipmentPage />}
|
||||||
|
/>
|
||||||
|
{/* Completion of an initiated (bare) booking after per-booking
|
||||||
clearance — same form, submits to the complete endpoint. */}
|
clearance — same form, submits to the complete endpoint. */}
|
||||||
<Route
|
<Route
|
||||||
path="/contracts/:id/bookings/:bookingId/complete"
|
path="/contracts/:id/bookings/:bookingId/complete"
|
||||||
element={<NewShipmentPage />}
|
element={<NewShipmentPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/contracts/:id/view"
|
path="/contracts/:id/view"
|
||||||
element={<ContractViewPage />}
|
element={<ContractViewPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
<Route path="/billing" element={<InvoicesList />} />
|
<Route path="/billing" element={<InvoicesList />} />
|
||||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||||
{/* Profile was merged into Settings — keep old links working. */}
|
{/* Profile was merged into Settings — keep old links working. */}
|
||||||
<Route
|
<Route
|
||||||
path="/profile"
|
path="/profile"
|
||||||
element={<Navigate to="/settings" replace />}
|
element={<Navigate to="/settings" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="/signature" element={<MySignaturePage />} />
|
<Route path="/signature" element={<MySignaturePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -107,23 +107,20 @@ function getActivePage(
|
|||||||
activePath: string,
|
activePath: string,
|
||||||
): { label: string } | null {
|
): { label: string } | null {
|
||||||
const path = activePath.toLowerCase();
|
const path = activePath.toLowerCase();
|
||||||
for (const item of items) {
|
|
||||||
if (
|
// Longest match wins, for the same reason as the sidebar's isItemActive:
|
||||||
path === item.href.toLowerCase() ||
|
// a nested href like "/shipping-line/bookings" must beat its "/shipping-line"
|
||||||
path.startsWith(item.href.toLowerCase() + "/")
|
// parent, which a first-match-wins scan would report as "Home".
|
||||||
) {
|
const best = items
|
||||||
return { label: item.label };
|
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||||
}
|
.filter(
|
||||||
if (item.children) {
|
(item) =>
|
||||||
const childMatch = item.children.find(
|
path === item.href.toLowerCase() ||
|
||||||
(c) =>
|
path.startsWith(item.href.toLowerCase() + "/"),
|
||||||
path === c.href.toLowerCase() ||
|
)
|
||||||
path.startsWith(c.href.toLowerCase() + "/"),
|
.sort((a, b) => b.href.length - a.href.length)[0];
|
||||||
);
|
|
||||||
if (childMatch) return { label: childMatch.label };
|
return best ? { label: best.label } : null;
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const navClassNames = (active: boolean) => {
|
const navClassNames = (active: boolean) => {
|
||||||
@@ -262,9 +259,19 @@ export function AppLayout({
|
|||||||
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
||||||
const isSuspendedAppeal = reapplyStatus === "suspended";
|
const isSuspendedAppeal = reapplyStatus === "suspended";
|
||||||
|
|
||||||
|
// Longest matching href wins. A plain prefix test would light up every
|
||||||
|
// ancestor: with a "/shipping-line" home item alongside "/shipping-line/
|
||||||
|
// bookings", Home would stay highlighted on every page beneath it. Exact
|
||||||
|
// matches still win outright, so customer routes are unaffected — their
|
||||||
|
// sidebar hrefs are siblings, never nested inside one another.
|
||||||
|
const bestMatchHref = sidebarItems
|
||||||
|
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||||
|
.map((item) => item.href.toLowerCase())
|
||||||
|
.filter((href) => activePath === href || activePath.startsWith(href + "/"))
|
||||||
|
.sort((a, b) => b.length - a.length)[0];
|
||||||
|
|
||||||
const isItemActive = (item: SidebarItem) =>
|
const isItemActive = (item: SidebarItem) =>
|
||||||
activePath === item.href.toLowerCase() ||
|
bestMatchHref === item.href.toLowerCase();
|
||||||
activePath.startsWith(item.href.toLowerCase() + "/");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -371,7 +378,9 @@ export function AppLayout({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
openServiceModal(p.type as ServiceType, p)
|
openServiceModal(p.type as ServiceType, p)
|
||||||
}
|
}
|
||||||
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
|
leftSection={
|
||||||
|
<RefreshCw size={15} strokeWidth={1.8} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{serviceLabel(p.type as ServiceType)}
|
{serviceLabel(p.type as ServiceType)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -893,7 +902,9 @@ export function AppLayout({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<FileInput
|
<FileInput
|
||||||
label={reapplyId ? "Business license (optional)" : "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"
|
||||||
|
|||||||
@@ -162,7 +162,15 @@ export default function OnboardingResumeBanner({
|
|||||||
* Self-hides when there's nothing outstanding.
|
* Self-hides when there's nothing outstanding.
|
||||||
*/
|
*/
|
||||||
export function AccountReviewBanner() {
|
export function AccountReviewBanner() {
|
||||||
const { company, companyStatus, reviewStatus, reviewNote } = useAuth();
|
const { company, companyStatus, reviewStatus, reviewNote, isShippingLine } =
|
||||||
|
useAuth();
|
||||||
|
|
||||||
|
// Shipping lines have no company approval, no operational profiles and no
|
||||||
|
// profile-edit review — every branch below is about customer state they do
|
||||||
|
// not have. Bail explicitly rather than relying on each check happening to
|
||||||
|
// fall through.
|
||||||
|
if (isShippingLine) return null;
|
||||||
|
|
||||||
const profiles = company?.company?.companyProfiles ?? [];
|
const profiles = company?.company?.companyProfiles ?? [];
|
||||||
const pending = profiles.filter((p) => p.status === "pending");
|
const pending = profiles.filter((p) => p.status === "pending");
|
||||||
const approved = profiles.filter((p) => p.status === "active");
|
const approved = profiles.filter((p) => p.status === "active");
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { ProfileTypeValue } from "@/services/companies.service";
|
import type {
|
||||||
import { companiesService } from "@/services/companies.service";
|
CompanyInfoResponse,
|
||||||
|
ProfileTypeValue,
|
||||||
|
} from "@/services/companies.service";
|
||||||
|
import {
|
||||||
|
companiesService,
|
||||||
|
isShippingLineAccount,
|
||||||
|
} from "@/services/companies.service";
|
||||||
import type {
|
import type {
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -158,7 +164,21 @@ const useAuth = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
const accountInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping lines share the portal with customers but have no company, no
|
||||||
|
* external profile and no onboarding. Identified positively from the
|
||||||
|
* backend's discriminator — never from "company is missing", which is also
|
||||||
|
* true while the fetch is in flight or after it fails.
|
||||||
|
*/
|
||||||
|
const isShippingLine = isShippingLineAccount(accountInfo);
|
||||||
|
const shippingLine = isShippingLine ? accountInfo : null;
|
||||||
|
|
||||||
|
// Every customer-shaped field below is null/empty for a shipping line.
|
||||||
|
const companyInfo = isShippingLine
|
||||||
|
? null
|
||||||
|
: (accountInfo as CompanyInfoResponse | 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).
|
||||||
@@ -259,8 +279,11 @@ const useAuth = () => {
|
|||||||
isPending,
|
isPending,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
||||||
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
// The whole getInfo payload, not the `company` field within it (historical
|
||||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
// name). Null for a shipping line: consumers read `.company` / `.profile`
|
||||||
|
// off it, and a shipping line has neither.
|
||||||
|
company: companyInfo,
|
||||||
|
customer: companyInfo,
|
||||||
canBook,
|
canBook,
|
||||||
hasActiveProfile,
|
hasActiveProfile,
|
||||||
hasPendingProfile,
|
hasPendingProfile,
|
||||||
@@ -272,6 +295,8 @@ const useAuth = () => {
|
|||||||
isUnderReview,
|
isUnderReview,
|
||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
onboardingStep,
|
onboardingStep,
|
||||||
|
isShippingLine,
|
||||||
|
shippingLine,
|
||||||
createProfile,
|
createProfile,
|
||||||
reapplyProfile,
|
reapplyProfile,
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
@@ -19,6 +19,10 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const passwordWasReset =
|
||||||
|
(location.state as { passwordReset?: boolean } | null)?.passwordReset ===
|
||||||
|
true;
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -56,6 +60,21 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{/*
|
||||||
|
Set by the reset flows on their way here. Without it the account
|
||||||
|
holder lands on a bare sign-in form with no sign the reset worked —
|
||||||
|
and the link is single-use, so there is no way back to check.
|
||||||
|
*/}
|
||||||
|
{passwordWasReset ? (
|
||||||
|
<Alert
|
||||||
|
color="green"
|
||||||
|
variant="light"
|
||||||
|
icon={<CheckCircle2 size={18} />}
|
||||||
|
>
|
||||||
|
Your password has been updated. Sign in with your new password.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email or Phone"
|
label="Email or Phone"
|
||||||
placeholder="name@company.com or 09XXXXXXXX"
|
placeholder="name@company.com or 09XXXXXXXX"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type FormEvent, useEffect, useState } from "react";
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
import { Alert, Button, Loader, PasswordInput, Stack } from "@mantine/core";
|
import { Alert, Button, Loader, PasswordInput, Stack } from "@mantine/core";
|
||||||
import { AlertCircle, KeyRound } from "lucide-react";
|
import { AlertCircle, CheckCircle2, KeyRound, LogIn } from "lucide-react";
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import AuthShell from "@/components/auth/AuthShell";
|
import AuthShell from "@/components/auth/AuthShell";
|
||||||
@@ -33,6 +33,7 @@ export default function ResetPasswordLinkPage() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [succeeded, setSucceeded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || !token) {
|
if (!userId || !token) {
|
||||||
@@ -82,7 +83,12 @@ export default function ResetPasswordLinkPage() {
|
|||||||
newPassword: password,
|
newPassword: password,
|
||||||
confirmPassword,
|
confirmPassword,
|
||||||
});
|
});
|
||||||
navigate("/login", { replace: true, state: { passwordReset: true } });
|
// Confirm in place rather than bouncing to /login: the old redirect
|
||||||
|
// passed `passwordReset: true` in route state that no page ever read, so
|
||||||
|
// the account holder landed on a bare sign-in form with no sign the reset
|
||||||
|
// had worked — and the link is single-use, so there is no way back to
|
||||||
|
// check.
|
||||||
|
setSucceeded(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(extractApiError(err).message);
|
setError(extractApiError(err).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -90,6 +96,58 @@ export default function ResetPasswordLinkPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Password set — the link is now spent, so this is the last thing the account
|
||||||
|
// holder sees. It replaces the whole form rather than sitting above it.
|
||||||
|
if (succeeded) {
|
||||||
|
return (
|
||||||
|
<AuthShell
|
||||||
|
tagline="Password updated"
|
||||||
|
taglineBody="Your EDR Freight account is ready to use."
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
<div className="mb-1 flex justify-center">
|
||||||
|
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-green-100 text-green-600">
|
||||||
|
<CheckCircle2 size={30} strokeWidth={2.2} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 mt-4 space-y-2 text-center">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
|
You're all set
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
|
Your password has been updated
|
||||||
|
{account?.maskedIdentifier ? ` for ${account.maskedIdentifier}` : ""}.
|
||||||
|
Sign in with your new password to continue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
size="md"
|
||||||
|
fullWidth
|
||||||
|
leftSection={<LogIn size={18} />}
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/login", {
|
||||||
|
replace: true,
|
||||||
|
state: { passwordReset: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Go to sign in
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs leading-relaxed text-gray-400">
|
||||||
|
For your security, this reset link has now been used and will not
|
||||||
|
work again.
|
||||||
|
</p>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
tagline="Set a new password"
|
tagline="Set a new password"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Package } from "lucide-react";
|
||||||
|
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping-line bookings. Contracts do not apply to shipping lines, so a
|
||||||
|
* booking is requested directly here rather than being created against a
|
||||||
|
* contract the way the customer flow does it.
|
||||||
|
*/
|
||||||
|
export default function ShippingLineBookingsPage() {
|
||||||
|
return (
|
||||||
|
<ShippingLinePlaceholder
|
||||||
|
title="Bookings"
|
||||||
|
description="Request and track your booking requests."
|
||||||
|
icon={<Package size={28} className="text-slate-300" />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { LifeBuoy } from "lucide-react";
|
||||||
|
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping-line help & support. Separate from the customer `/help` page, which
|
||||||
|
* is public (the auth screens link to it) and carries its own doc chrome; this
|
||||||
|
* one lives inside the shipping-line app layout and will hold guidance written
|
||||||
|
* for shipping lines.
|
||||||
|
*/
|
||||||
|
export default function ShippingLineHelpPage() {
|
||||||
|
return (
|
||||||
|
<ShippingLinePlaceholder
|
||||||
|
title="Help & Support"
|
||||||
|
description="Guides and support for shipping lines."
|
||||||
|
icon={<LifeBuoy size={28} className="text-slate-300" />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Home } from "lucide-react";
|
||||||
|
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping-line home / dashboard. Deliberately separate from the customer
|
||||||
|
* dashboard (`MyPortalPage`): shipping lines have no company, no operational
|
||||||
|
* profiles and no contracts, so almost none of that page's data applies.
|
||||||
|
*/
|
||||||
|
export default function ShippingLineHomePage() {
|
||||||
|
return (
|
||||||
|
<ShippingLinePlaceholder
|
||||||
|
title="Home"
|
||||||
|
description="Overview of your shipping-line activity."
|
||||||
|
icon={<Home size={28} className="text-slate-300" />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Receipt } from "lucide-react";
|
||||||
|
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||||
|
|
||||||
|
/** Shipping-line payments / invoices. */
|
||||||
|
export default function ShippingLineInvoicesPage() {
|
||||||
|
return (
|
||||||
|
<ShippingLinePlaceholder
|
||||||
|
title="Payments"
|
||||||
|
description="Your invoices and payment history."
|
||||||
|
icon={<Receipt size={28} className="text-slate-300" />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Card, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface ShippingLinePlaceholderProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell for the shipping-line pages while they are still being built out.
|
||||||
|
* Each page owns its own file so it can be filled in independently; this only
|
||||||
|
* supplies the shared empty-state chrome and is meant to be deleted from a page
|
||||||
|
* once that page has real content.
|
||||||
|
*/
|
||||||
|
export default function ShippingLinePlaceholder({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
}: ShippingLinePlaceholderProps) {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Title order={2}>{title}</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Card withBorder radius="md" py={64}>
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
{icon}
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Nothing here yet — this page is still being built.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Settings } from "lucide-react";
|
||||||
|
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping-line settings. Separate from the customer `SettingsPage`, which is
|
||||||
|
* built around company profiles, business licenses and contact-person review —
|
||||||
|
* none of which a shipping line has.
|
||||||
|
*/
|
||||||
|
export default function ShippingLineSettingsPage() {
|
||||||
|
return (
|
||||||
|
<ShippingLinePlaceholder
|
||||||
|
title="Settings"
|
||||||
|
description="Manage your account and preferences."
|
||||||
|
icon={<Settings size={28} className="text-slate-300" />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as ShippingLineHomePage } from "./ShippingLineHomePage";
|
||||||
|
export { default as ShippingLineBookingsPage } from "./ShippingLineBookingsPage";
|
||||||
|
export { default as ShippingLineInvoicesPage } from "./ShippingLineInvoicesPage";
|
||||||
|
export { default as ShippingLineSettingsPage } from "./ShippingLineSettingsPage";
|
||||||
|
export { default as ShippingLineHelpPage } from "./ShippingLineHelpPage";
|
||||||
@@ -89,7 +89,18 @@ export interface CompanyProfileResponse {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which kind of account is signed in.
|
||||||
|
*
|
||||||
|
* Read this instead of inferring from a missing `company`: a failed or
|
||||||
|
* in-flight company fetch also leaves `company` empty, and treating that as
|
||||||
|
* "shipping line" would skip onboarding for customers whenever the request
|
||||||
|
* failed. Absent (older responses) means `customer`.
|
||||||
|
*/
|
||||||
|
export type AccountKind = "customer" | "shipping_line";
|
||||||
|
|
||||||
export interface CompanyInfoResponse {
|
export interface CompanyInfoResponse {
|
||||||
|
accountKind?: AccountKind;
|
||||||
profile: ExternalProfileResponse;
|
profile: ExternalProfileResponse;
|
||||||
company: CompanyResponse;
|
company: CompanyResponse;
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +115,30 @@ export interface CompanyInfoResponse {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signed-in shipping line. It has no company, no external profile and no
|
||||||
|
* onboarding — the carrier record itself is the account.
|
||||||
|
*/
|
||||||
|
export interface ShippingLineInfoResponse {
|
||||||
|
accountKind: "shipping_line";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phoneNumber: string | null;
|
||||||
|
scacCode: string | null;
|
||||||
|
status: string;
|
||||||
|
company: null;
|
||||||
|
profile: null;
|
||||||
|
review: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /companies/getInfo` serves both portal audiences. */
|
||||||
|
export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse;
|
||||||
|
|
||||||
|
export const isShippingLineAccount = (
|
||||||
|
info: AccountInfoResponse | null | undefined,
|
||||||
|
): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line";
|
||||||
|
|
||||||
/** A staged profile-edit review request (portal view). */
|
/** A staged profile-edit review request (portal view). */
|
||||||
export interface ChangeRequestResponse {
|
export interface ChangeRequestResponse {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -249,9 +284,9 @@ export interface DashboardSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const companiesService = {
|
export const companiesService = {
|
||||||
getInfo: async (): Promise<CompanyInfoResponse | null> => {
|
getInfo: async (): Promise<AccountInfoResponse | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await client.get<ApiResponse<CompanyInfoResponse>>(
|
const response = await client.get<ApiResponse<AccountInfoResponse>>(
|
||||||
URL_CONSTANTS.COMPANIES_API.GET_INFO,
|
URL_CONSTANTS.COMPANIES_API.GET_INFO,
|
||||||
);
|
);
|
||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
|
|||||||
Reference in New Issue
Block a user