mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
merge
This commit is contained in:
0
.pnpm-store/v11/.pnpm-needs-build-marker
Normal file
0
.pnpm-store/v11/.pnpm-needs-build-marker
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"dependencies":{"pnpm":"11.1.1"}}
|
||||||
BIN
.pnpm-store/v11/index.db
Normal file
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
@@ -37,7 +37,7 @@
|
|||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/swagger": "^11.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
|
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz",
|
||||||
"amqp-connection-manager": "^5.0.0",
|
"amqp-connection-manager": "^5.0.0",
|
||||||
"amqplib": "^2.0.1",
|
"amqplib": "^2.0.1",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ import { ContainersModule } from './modules/container-management/containers.modu
|
|||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||||
import { RoutesModule } from './modules/routes/routes.module';
|
import { RoutesModule } from './modules/routes/routes.module';
|
||||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||||
import { FacilitiesModule } from './modules/facilities/facilities.module';
|
|
||||||
import { OverviewModule } from './modules/overview/overview.module';
|
import { OverviewModule } from './modules/overview/overview.module';
|
||||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||||
import { DriversModule } from './modules/drivers/drivers.module';
|
import { DriversModule } from './modules/drivers/drivers.module';
|
||||||
@@ -123,7 +122,6 @@ import { LastMileModule } from './modules/last-mile/last-mile.module';
|
|||||||
ContainersModule,
|
ContainersModule,
|
||||||
CargoesModule,
|
CargoesModule,
|
||||||
RoutesModule,
|
RoutesModule,
|
||||||
FacilitiesModule,
|
|
||||||
WarehousesModule,
|
WarehousesModule,
|
||||||
OverviewModule,
|
OverviewModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ export function deriveTradeDirection(
|
|||||||
originYard: YardLike,
|
originYard: YardLike,
|
||||||
destinationYard: YardLike,
|
destinationYard: YardLike,
|
||||||
): ScheduleTradeDirection {
|
): ScheduleTradeDirection {
|
||||||
const originCountry = originYard.country?.trim();
|
const originCountry = originYard.country?.trim().toLowerCase();
|
||||||
const destinationCountry = destinationYard.country?.trim();
|
const destinationCountry = destinationYard.country?.trim().toLowerCase();
|
||||||
|
|
||||||
if (originCountry === 'Djibouti') {
|
if (originCountry === 'djibouti') {
|
||||||
return 'IMPORT';
|
return 'IMPORT';
|
||||||
}
|
}
|
||||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
|
||||||
return 'EXPORT';
|
return 'EXPORT';
|
||||||
}
|
}
|
||||||
return 'DOMESTIC';
|
return 'DOMESTIC';
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddVehicleCodeAndPlates1810000000002 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const hasCode = await queryRunner.hasColumn('freight.vehicles', 'code');
|
||||||
|
if (!hasCode) {
|
||||||
|
await queryRunner.addColumn(
|
||||||
|
'freight.vehicles',
|
||||||
|
new TableColumn({ name: 'code', type: 'varchar', isNullable: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPower = await queryRunner.hasColumn('freight.vehicles', 'power_plate_no');
|
||||||
|
if (!hasPower) {
|
||||||
|
await queryRunner.addColumn(
|
||||||
|
'freight.vehicles',
|
||||||
|
new TableColumn({ name: 'power_plate_no', type: 'varchar', isNullable: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTrailer = await queryRunner.hasColumn('freight.vehicles', 'trailer_plate_no');
|
||||||
|
if (!hasTrailer) {
|
||||||
|
await queryRunner.addColumn(
|
||||||
|
'freight.vehicles',
|
||||||
|
new TableColumn({ name: 'trailer_plate_no', type: 'varchar', isNullable: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropColumn('freight.vehicles', 'trailer_plate_no');
|
||||||
|
await queryRunner.dropColumn('freight.vehicles', 'power_plate_no');
|
||||||
|
await queryRunner.dropColumn('freight.vehicles', 'code');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company-profile references are now minted only when a profile is approved
|
||||||
|
* (status → Active); pending profiles carry NULL. Drop the NOT NULL constraint
|
||||||
|
* on freight.company_profiles.reference. The existing unique index is kept —
|
||||||
|
* Postgres treats NULLs as distinct, so multiple pending (NULL) profiles don't
|
||||||
|
* collide.
|
||||||
|
*/
|
||||||
|
export class MakeCompanyProfileReferenceNullable1810000000002
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "MakeCompanyProfileReferenceNullable1810000000002";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" DROP NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Reinstating NOT NULL requires every row to have a reference; any pending
|
||||||
|
// (NULL) profiles get a placeholder so the constraint can be re-applied.
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."company_profiles" SET "reference" = 'PENDING-' || left(replace("id"::text, '-', ''), 12) WHERE "reference" IS NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" SET NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the public.otp_verifications table backing the OTP module
|
||||||
|
* (OtpVerification entity). One row per phone, holding the latest server-issued
|
||||||
|
* code and whether that phone has been verified.
|
||||||
|
*/
|
||||||
|
export class CreateOtpVerifications1810000000003
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "CreateOtpVerifications1810000000003";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const exists = await queryRunner.hasTable("otp_verifications");
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: "otp_verifications",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
type: "uuid",
|
||||||
|
isPrimary: true,
|
||||||
|
default: "gen_random_uuid()",
|
||||||
|
},
|
||||||
|
{ name: "phone", type: "varchar", isUnique: true },
|
||||||
|
{ name: "otp", type: "varchar" },
|
||||||
|
{ name: "verified", type: "boolean", default: false },
|
||||||
|
{ name: "created_at", type: "timestamptz", default: "now()" },
|
||||||
|
{ name: "updated_at", type: "timestamptz", default: "now()" },
|
||||||
|
{ name: "deleted_at", type: "timestamptz", isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable("otp_verifications", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact email/phone for an external profile is sourced from IAM (the user's
|
||||||
|
* identity) and from the company record, so the duplicated `email`/`phone`
|
||||||
|
* columns on external_profiles are redundant and are dropped. Dropping `email`
|
||||||
|
* also removes its UNIQUE constraint.
|
||||||
|
*/
|
||||||
|
export class DropEmailPhoneFromExternalProfiles1820000000011
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'DropEmailPhoneFromExternalProfiles1820000000011';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.external_profiles DROP COLUMN IF EXISTS email;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.external_profiles DROP COLUMN IF EXISTS phone;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Re-added as nullable (the original email was UNIQUE NOT NULL) since the
|
||||||
|
// dropped values cannot be recovered to satisfy those constraints.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.external_profiles ADD COLUMN IF NOT EXISTS email varchar(150);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.external_profiles ADD COLUMN IF NOT EXISTS phone varchar(20);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -392,6 +392,17 @@ export class BookingsService {
|
|||||||
tradeDirection,
|
tradeDirection,
|
||||||
fallbackType,
|
fallbackType,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A customer booking under their own account may only do so once the
|
||||||
|
// resolved operational profile has been approved by the backoffice. Staff-
|
||||||
|
// and government-initiated bookings (companyId supplied explicitly) bypass
|
||||||
|
// this gate.
|
||||||
|
const customerSelfBooking = !dto.companyId && !!userId;
|
||||||
|
if (customerSelfBooking && companyProfileId) {
|
||||||
|
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||||
|
companyProfileId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsConsolidation =
|
const needsConsolidation =
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto";
|
|||||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||||
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
||||||
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
||||||
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||||
@@ -226,6 +227,17 @@ export class CompaniesController {
|
|||||||
await this.companiesService.setOnboardingStep(user.id, dto.step);
|
await this.companiesService.setOnboardingStep(user.id, dto.step);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("onboarding/requirements")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)",
|
||||||
|
})
|
||||||
|
async getOnboardingRequirements(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<OnboardingRequirementsResponseDto> {
|
||||||
|
return this.companiesService.getOnboardingRequirements(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("onboarding/complete")
|
@Post("onboarding/complete")
|
||||||
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
||||||
async completeOnboarding(
|
async completeOnboarding(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { FilesModule } from "../files/files.module";
|
import { FilesModule } from "../files/files.module";
|
||||||
|
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||||
import { MinioModule } from "../minio/minio.module";
|
import { MinioModule } from "../minio/minio.module";
|
||||||
import { CompaniesController } from "./companies.controller";
|
import { CompaniesController } from "./companies.controller";
|
||||||
import { CompaniesService } from "./companies.service";
|
import { CompaniesService } from "./companies.service";
|
||||||
@@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||||
HttpModule,
|
HttpModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
|
FileUploadSettingsModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
],
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
@@ -12,7 +13,10 @@ import {
|
|||||||
DashboardScope,
|
DashboardScope,
|
||||||
} from "./company-dashboard.repository";
|
} from "./company-dashboard.repository";
|
||||||
import { MinioService } from "../minio/minio.service";
|
import { MinioService } from "../minio/minio.service";
|
||||||
|
import { FilesService } from "../files/files.service";
|
||||||
|
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
@@ -53,9 +57,67 @@ export class CompaniesService {
|
|||||||
private readonly profilesRepo: ExternalProfileRepository,
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
private readonly etradeService: ETradeService,
|
private readonly etradeService: ETradeService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required company-information fields that must be filled before onboarding can
|
||||||
|
* be submitted. The backend owns this list so the portal never has to know
|
||||||
|
* which fields are mandatory — it just renders what's reported outstanding.
|
||||||
|
* `get` reads the value from the company (some live in the attributes blob).
|
||||||
|
*/
|
||||||
|
private readonly REQUIRED_COMPANY_INFO: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
get: (company: Company) => unknown;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "tinNumber",
|
||||||
|
label: "Company TIN",
|
||||||
|
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
|
||||||
|
},
|
||||||
|
{ key: "companyEmail", label: "Company email", get: (c) => c.email },
|
||||||
|
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone },
|
||||||
|
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
|
||||||
|
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
|
||||||
|
{
|
||||||
|
key: "contactPersonName",
|
||||||
|
label: "Contact person name",
|
||||||
|
get: (c) => c.attributes?.contactPersonName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contactPersonPhone",
|
||||||
|
label: "Contact person phone",
|
||||||
|
get: (c) => c.attributes?.contactPersonPhone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "generalManagerName",
|
||||||
|
label: "General manager name",
|
||||||
|
get: (c) => c.attributes?.generalManagerName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "generalManagerEmail",
|
||||||
|
label: "General manager email",
|
||||||
|
get: (c) => c.attributes?.generalManagerEmail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "generalManagerPhone",
|
||||||
|
label: "General manager phone",
|
||||||
|
get: (c) => c.attributes?.generalManagerPhone,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The nationality-based document setting code for a company. */
|
||||||
|
private documentSettingCodeFor(
|
||||||
|
nationality: CompanyNationality | null | undefined,
|
||||||
|
): string {
|
||||||
|
return nationality === CompanyNationality.Foreign
|
||||||
|
? "company_onboarding_documents_foreign"
|
||||||
|
: "company_onboarding_documents_ethiopian";
|
||||||
|
}
|
||||||
|
|
||||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -77,10 +139,12 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
const existingProfile = await this.profilesRepo.findByUserId(
|
||||||
|
identity.userId,
|
||||||
|
);
|
||||||
if (existingProfile) {
|
if (existingProfile) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Profile with email ${identity.email} already exists`,
|
`Profile for user ${identity.userId} already exists`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +178,6 @@ export class CompaniesService {
|
|||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
firstName: identity.firstName,
|
firstName: identity.firstName,
|
||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
email: identity.email,
|
|
||||||
phone: normalizeE164(identity.phone) ?? identity.phone,
|
|
||||||
jobTitle: dto.jobTitle ?? null,
|
jobTitle: dto.jobTitle ?? null,
|
||||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||||
activeProfileType,
|
activeProfileType,
|
||||||
@@ -134,15 +196,13 @@ export class CompaniesService {
|
|||||||
input.type,
|
input.type,
|
||||||
);
|
);
|
||||||
if (existing) continue;
|
if (existing) continue;
|
||||||
const reference = await this.companyProfilesRepo.generateReference(
|
// No reference yet — these profiles await backoffice approval, which
|
||||||
input.type,
|
// is when the reference is minted (see setCompanyProfileStatus).
|
||||||
);
|
|
||||||
await this.companyProfilesRepo.create({
|
await this.companyProfilesRepo.create({
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
reference,
|
|
||||||
businessLicense: input.businessLicense ?? null,
|
businessLicense: input.businessLicense ?? null,
|
||||||
status: ProfileStatus.Active,
|
status: ProfileStatus.Pending,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||||
@@ -191,15 +251,6 @@ export class CompaniesService {
|
|||||||
return this.getCompanyInfoByUserId(identity.userId);
|
return this.getCompanyInfoByUserId(identity.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A profile may exist for the same email under a different IAM id — block
|
|
||||||
// duplicates as the final create does.
|
|
||||||
const byEmail = await this.profilesRepo.findByEmail(identity.email);
|
|
||||||
if (byEmail) {
|
|
||||||
throw new ConflictException(
|
|
||||||
`Profile with email ${identity.email} already exists`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||||
const activeProfileType =
|
const activeProfileType =
|
||||||
@@ -224,8 +275,6 @@ export class CompaniesService {
|
|||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
firstName: identity.firstName,
|
firstName: identity.firstName,
|
||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
email: identity.email,
|
|
||||||
phone: normalizeE164(identity.phone) ?? identity.phone,
|
|
||||||
isPrimaryContact: true,
|
isPrimaryContact: true,
|
||||||
activeProfileType,
|
activeProfileType,
|
||||||
onboardingStep: "company",
|
onboardingStep: "company",
|
||||||
@@ -251,12 +300,11 @@ export class CompaniesService {
|
|||||||
type,
|
type,
|
||||||
);
|
);
|
||||||
if (existing) continue;
|
if (existing) continue;
|
||||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
|
||||||
await this.companyProfilesRepo.create({
|
await this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
type,
|
type,
|
||||||
reference,
|
status: ProfileStatus.Pending,
|
||||||
status: ProfileStatus.Active,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,6 +575,8 @@ export class CompaniesService {
|
|||||||
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
|
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
|
||||||
if (dto.contactPersonPhone !== undefined)
|
if (dto.contactPersonPhone !== undefined)
|
||||||
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
||||||
|
if (dto.contactVerifiedPhone !== undefined)
|
||||||
|
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
|
||||||
if (dto.generalManagerName !== undefined)
|
if (dto.generalManagerName !== undefined)
|
||||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||||
if (dto.generalManagerEmail !== undefined)
|
if (dto.generalManagerEmail !== undefined)
|
||||||
@@ -576,10 +626,10 @@ export class CompaniesService {
|
|||||||
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
||||||
await this.findCompanyById(dto.companyId);
|
await this.findCompanyById(dto.companyId);
|
||||||
|
|
||||||
const existing = await this.profilesRepo.findByEmail(dto.email);
|
const existing = await this.profilesRepo.findByUserId(dto.userId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Profile with email ${dto.email} already exists`,
|
`Profile for user ${dto.userId} already exists`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,12 +672,33 @@ export class CompaniesService {
|
|||||||
profileId: string,
|
profileId: string,
|
||||||
status: ProfileStatus,
|
status: ProfileStatus,
|
||||||
): Promise<CompanyProfile> {
|
): Promise<CompanyProfile> {
|
||||||
const updated = await this.companyProfilesRepo.updateStatus(
|
const existing = await this.companyProfilesRepo.findById(profileId);
|
||||||
profileId,
|
if (!existing)
|
||||||
status,
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
);
|
|
||||||
|
// A reference number is only minted the first time a profile is approved
|
||||||
|
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||||
|
const patch: Partial<CompanyProfile> = { status };
|
||||||
|
if (status === ProfileStatus.Active && !existing.reference) {
|
||||||
|
patch.reference = await this.companyProfilesRepo.generateReference(
|
||||||
|
existing.type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.companyProfilesRepo.update(profileId, patch);
|
||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
|
|
||||||
|
// Approving any profile promotes a pending company to active, so the
|
||||||
|
// customer can start working as soon as their first profile is cleared.
|
||||||
|
if (status === ProfileStatus.Active) {
|
||||||
|
const company = await this.companiesRepo.findById(updated.companyId);
|
||||||
|
if (company && company.status === CompanyStatus.Pending) {
|
||||||
|
await this.companiesRepo.update(updated.companyId, {
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +720,7 @@ export class CompaniesService {
|
|||||||
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Company already has a ${type} profile (${existing.reference})`,
|
`Company already has a ${type} profile (${existing.reference ?? "pending approval"})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,6 +884,100 @@ export class CompaniesService {
|
|||||||
await this.profilesRepo.update(profile.id, { onboardingStep: step });
|
await this.profilesRepo.update(profile.id, { onboardingStep: step });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-driven onboarding requirements for the current user's company.
|
||||||
|
*
|
||||||
|
* The backend resolves the nationality-based document set, checks which
|
||||||
|
* company documents and per-profile licenses are already uploaded, and reports
|
||||||
|
* exactly what is still outstanding. The portal renders this list verbatim and
|
||||||
|
* relies on `isComplete` to decide when to auto-finish — it never decides for
|
||||||
|
* itself which documents apply or which fields are mandatory.
|
||||||
|
*/
|
||||||
|
async getOnboardingRequirements(
|
||||||
|
userId: string,
|
||||||
|
): Promise<OnboardingRequirementsResponseDto> {
|
||||||
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
|
||||||
|
// 1. Required company-information fields.
|
||||||
|
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
|
||||||
|
(f) => !f.get(company),
|
||||||
|
).map((f) => ({ key: f.key, label: f.label }));
|
||||||
|
|
||||||
|
// 2. Nationality-based company documents + which are already uploaded.
|
||||||
|
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
|
||||||
|
const [setting, uploadedFiles] = await Promise.all([
|
||||||
|
this.fileUploadSettingsService
|
||||||
|
.getByCode(documentSettingCode)
|
||||||
|
.catch(() => null),
|
||||||
|
this.filesService.findByResource(company.id, "companies"),
|
||||||
|
]);
|
||||||
|
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
|
||||||
|
const documents = (setting?.fields ?? [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||||
|
.map((f) => ({
|
||||||
|
fileKey: f.fileKey,
|
||||||
|
fileLabel: f.fileLabel,
|
||||||
|
helpText: f.helpText ?? null,
|
||||||
|
isRequired: f.isRequired,
|
||||||
|
isMultiple: f.isMultiple,
|
||||||
|
maxFiles: f.maxFiles,
|
||||||
|
allowedExtensions: f.allowedExtensions,
|
||||||
|
maxSizeMb: f.maxSizeMb,
|
||||||
|
displayOrder: f.displayOrder,
|
||||||
|
uploaded: uploadedCodes.has(f.fileKey),
|
||||||
|
}));
|
||||||
|
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
||||||
|
|
||||||
|
// 3. Per-operational-profile business licenses.
|
||||||
|
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({
|
||||||
|
profileId: p.id,
|
||||||
|
type: p.type,
|
||||||
|
reference: p.reference ?? "",
|
||||||
|
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0,
|
||||||
|
}));
|
||||||
|
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
||||||
|
|
||||||
|
const outstanding = [
|
||||||
|
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||||
|
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
||||||
|
...missingLicenses.map(
|
||||||
|
(p) =>
|
||||||
|
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Progress spans every required item the user has to satisfy: company-info
|
||||||
|
// fields, required documents and one license per operational profile.
|
||||||
|
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||||
|
const total =
|
||||||
|
this.REQUIRED_COMPANY_INFO.length +
|
||||||
|
requiredDocCount +
|
||||||
|
licenseProfiles.length;
|
||||||
|
const completed =
|
||||||
|
total -
|
||||||
|
(missingInfo.length + missingDocs.length + missingLicenses.length);
|
||||||
|
|
||||||
|
return new OnboardingRequirementsResponseDto({
|
||||||
|
documentSettingCode,
|
||||||
|
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
||||||
|
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
|
||||||
|
documents,
|
||||||
|
licenseProfiles,
|
||||||
|
progress: { completed, total },
|
||||||
|
isComplete: outstanding.length === 0,
|
||||||
|
onboardingCompleted: profile.onboardingCompleted,
|
||||||
|
outstanding,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit onboarding for review. Validation is delegated entirely to
|
||||||
|
* getOnboardingRequirements (the same source of truth the portal renders), so
|
||||||
|
* the gate can never drift from what the UI shows. On success the company and
|
||||||
|
* all its operational profiles move to PENDING — the backoffice approves each
|
||||||
|
* profile before it can be used (see setCompanyProfileStatus).
|
||||||
|
*/
|
||||||
async markOnboardingComplete(
|
async markOnboardingComplete(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
@@ -821,23 +986,21 @@ export class CompaniesService {
|
|||||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
|
||||||
const companyId = profile.company?.id ?? profile.companyId;
|
const companyId = profile.company?.id ?? profile.companyId;
|
||||||
const company = await this.findCompanyById(companyId);
|
|
||||||
|
|
||||||
// Guard against finishing on a still-draft company (TIN never filled in).
|
const requirements = await this.getOnboardingRequirements(userId);
|
||||||
if (!company.tin || company.tin.startsWith("D")) {
|
if (!requirements.isComplete) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Company information is incomplete — please fill in your company details before finishing.",
|
requirements.outstanding[0] ??
|
||||||
|
"Your onboarding is incomplete. Please complete all required steps before submitting.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every operational profile must have at least one business-license file
|
// Send every operational profile in for approval; the company itself becomes
|
||||||
// (stored directly on the profile).
|
// active once the backoffice approves at least one profile.
|
||||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
for (const cp of profiles) {
|
for (const cp of profiles) {
|
||||||
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
|
if (cp.status !== ProfileStatus.Pending) {
|
||||||
throw new BadRequestException(
|
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
|
||||||
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,6 +1015,25 @@ export class CompaniesService {
|
|||||||
return this.getCompanyInfoByUserId(userId);
|
return this.getCompanyInfoByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block a customer from booking under a profile that isn't approved yet.
|
||||||
|
* Called from the booking-create path for self-service bookings; staff- and
|
||||||
|
* government-initiated bookings bypass this. No-op when the profile can't be
|
||||||
|
* found (defensive — resolution is best-effort upstream).
|
||||||
|
*/
|
||||||
|
async assertCompanyProfileApprovedForBooking(
|
||||||
|
companyProfileId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
||||||
|
if (!profile) return;
|
||||||
|
if (profile.status !== ProfileStatus.Active) {
|
||||||
|
const role = profile.type.replace(/_/g, " ");
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorize and resolve a company_profile that must belong to the current
|
* Authorize and resolve a company_profile that must belong to the current
|
||||||
* user's company — used before accepting/returning its license files.
|
* user's company — used before accepting/returning its license files.
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async generateReference(type: ProfileType): Promise<string> {
|
async generateReference(type: ProfileType): Promise<string> {
|
||||||
const seqName = SEQUENCE_MAP[type];
|
// The sequences live in the same schema as the entity (e.g. "freight"), but
|
||||||
|
// the connection's search_path is "public" — so the sequence MUST be
|
||||||
|
// schema-qualified or `nextval` fails with "relation does not exist".
|
||||||
|
const schema = this.repository.metadata.schema ?? "public";
|
||||||
|
const seqName = `"${schema}".${SEQUENCE_MAP[type]}`;
|
||||||
const result = await this.repository.query(
|
const result = await this.repository.query(
|
||||||
`SELECT nextval('${seqName}') AS next_id`,
|
`SELECT nextval('${seqName}') AS next_id`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
|
||||||
|
|
||||||
export class CreateExternalProfileDto {
|
export class CreateExternalProfileDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -20,16 +19,6 @@ export class CreateExternalProfileDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
lastName!: string;
|
lastName!: string;
|
||||||
|
|
||||||
@IsEmail()
|
|
||||||
@IsNotEmpty()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
@IsValidPhone()
|
|
||||||
phone?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Server-driven description of what a company still needs to finish onboarding.
|
||||||
|
*
|
||||||
|
* The portal renders this verbatim instead of deciding for itself which
|
||||||
|
* documents apply or which fields are mandatory: the backend resolves the
|
||||||
|
* nationality-based document set, checks which files are already uploaded, and
|
||||||
|
* reports exactly what is outstanding. `isComplete` is the single source of
|
||||||
|
* truth the wizard uses to auto-finish.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OnboardingInfoField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingDocumentField {
|
||||||
|
fileKey: string;
|
||||||
|
fileLabel: string;
|
||||||
|
helpText: string | null;
|
||||||
|
isRequired: boolean;
|
||||||
|
isMultiple: boolean;
|
||||||
|
maxFiles: number;
|
||||||
|
allowedExtensions: string[];
|
||||||
|
maxSizeMb: number;
|
||||||
|
displayOrder: number;
|
||||||
|
/** True when a file with this code is already stored for the company. */
|
||||||
|
uploaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingLicenseProfile {
|
||||||
|
profileId: string;
|
||||||
|
type: string;
|
||||||
|
reference: string;
|
||||||
|
/** True when at least one business-license file is stored on the profile. */
|
||||||
|
uploaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnboardingRequirementsResponseDto {
|
||||||
|
/** Resolved document setting code (by nationality) the docs were drawn from. */
|
||||||
|
documentSettingCode: string;
|
||||||
|
nationality: string;
|
||||||
|
|
||||||
|
/** Required company-information fields and whether each is filled. */
|
||||||
|
companyInfo: {
|
||||||
|
complete: boolean;
|
||||||
|
missingFields: OnboardingInfoField[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The document fields the portal should render, with upload state. */
|
||||||
|
documents: OnboardingDocumentField[];
|
||||||
|
|
||||||
|
/** Per-operational-profile business-license requirements. */
|
||||||
|
licenseProfiles: OnboardingLicenseProfile[];
|
||||||
|
|
||||||
|
/** Overall setup progress across fields + documents + licenses. */
|
||||||
|
progress: { completed: number; total: number };
|
||||||
|
|
||||||
|
/** True once every required field, document and license is satisfied. */
|
||||||
|
isComplete: boolean;
|
||||||
|
|
||||||
|
/** Whether the user has already submitted onboarding (awaiting approval). */
|
||||||
|
onboardingCompleted: boolean;
|
||||||
|
|
||||||
|
/** Human-readable list of everything still outstanding (empty when complete). */
|
||||||
|
outstanding: string[];
|
||||||
|
|
||||||
|
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
|
||||||
|
this.documentSettingCode = init.documentSettingCode;
|
||||||
|
this.nationality = init.nationality;
|
||||||
|
this.companyInfo = init.companyInfo;
|
||||||
|
this.documents = init.documents;
|
||||||
|
this.licenseProfiles = init.licenseProfiles;
|
||||||
|
this.progress = init.progress;
|
||||||
|
this.isComplete = init.isComplete;
|
||||||
|
this.onboardingCompleted = init.onboardingCompleted;
|
||||||
|
this.outstanding = init.outstanding;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ export class ProfileResponseDto {
|
|||||||
contactPersonPosition: string | null;
|
contactPersonPosition: string | null;
|
||||||
contactPersonEmail: string | null;
|
contactPersonEmail: string | null;
|
||||||
contactPersonPhone: string | null;
|
contactPersonPhone: string | null;
|
||||||
|
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
|
||||||
|
contactVerifiedPhone: string | null;
|
||||||
generalManagerName: string | null;
|
generalManagerName: string | null;
|
||||||
generalManagerEmail: string | null;
|
generalManagerEmail: string | null;
|
||||||
generalManagerPhone: string | null;
|
generalManagerPhone: string | null;
|
||||||
@@ -81,6 +83,7 @@ export class ProfileResponseDto {
|
|||||||
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
|
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
|
||||||
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
||||||
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||||
|
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
|
||||||
this.generalManagerName = attrs.generalManagerName ?? null;
|
this.generalManagerName = attrs.generalManagerName ?? null;
|
||||||
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
||||||
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class ResponseCompanyProfileDto {
|
|||||||
this.id = profile.id;
|
this.id = profile.id;
|
||||||
this.companyId = profile.companyId;
|
this.companyId = profile.companyId;
|
||||||
this.type = profile.type;
|
this.type = profile.type;
|
||||||
this.reference = profile.reference;
|
this.reference = profile.reference ?? '';
|
||||||
this.status = profile.status;
|
this.status = profile.status;
|
||||||
this.businessLicense = profile.businessLicense;
|
this.businessLicense = profile.businessLicense;
|
||||||
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ export class ResponseExternalProfileDto {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
|
||||||
phone?: string | null;
|
|
||||||
nationalId?: string | null;
|
nationalId?: string | null;
|
||||||
jobTitle?: string | null;
|
jobTitle?: string | null;
|
||||||
isPrimaryContact: boolean;
|
isPrimaryContact: boolean;
|
||||||
@@ -34,8 +32,6 @@ export class ResponseExternalProfileDto {
|
|||||||
this.companyId = profile.companyId;
|
this.companyId = profile.companyId;
|
||||||
this.firstName = profile.firstName;
|
this.firstName = profile.firstName;
|
||||||
this.lastName = profile.lastName;
|
this.lastName = profile.lastName;
|
||||||
this.email = profile.email;
|
|
||||||
this.phone = profile.phone;
|
|
||||||
this.nationalId = profile.nationalId;
|
this.nationalId = profile.nationalId;
|
||||||
this.jobTitle = profile.jobTitle;
|
this.jobTitle = profile.jobTitle;
|
||||||
this.isPrimaryContact = profile.isPrimaryContact;
|
this.isPrimaryContact = profile.isPrimaryContact;
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ export class UpdateProfileDto {
|
|||||||
@IsValidPhone()
|
@IsValidPhone()
|
||||||
contactPersonPhone?: string;
|
contactPersonPhone?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contact-person phone that completed SMS OTP verification. Persisted so
|
||||||
|
* the onboarding "verify" step can resume its "done" state after a refresh
|
||||||
|
* (compared against the current contactPersonPhone on the client).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsValidPhone()
|
||||||
|
contactVerifiedPhone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
generalManagerName?: string;
|
generalManagerName?: string;
|
||||||
|
|||||||
@@ -40,14 +40,19 @@ export class CompanyProfile extends BaseEntity {
|
|||||||
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
|
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
|
||||||
type!: ProfileType;
|
type!: ProfileType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Official profile reference (e.g. "EX-00001"). Minted only when the profile
|
||||||
|
* is approved (status → Active); pending/unapproved profiles carry NULL.
|
||||||
|
* The unique index tolerates this because Postgres treats NULLs as distinct.
|
||||||
|
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.
|
||||||
|
*/
|
||||||
@Column({
|
@Column({
|
||||||
name: "reference",
|
name: "reference",
|
||||||
type: "varchar",
|
type: "varchar",
|
||||||
length: 20,
|
length: 20,
|
||||||
nullable: false,
|
nullable: true,
|
||||||
unique: true,
|
|
||||||
})
|
})
|
||||||
reference!: string;
|
reference!: string | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: "status",
|
name: "status",
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ export class ExternalProfile extends BaseEntity {
|
|||||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||||
lastName!: string;
|
lastName!: string;
|
||||||
|
|
||||||
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
|
||||||
phone?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
|
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
|
||||||
nationalId?: string | null;
|
nationalId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,4 @@ export class ExternalProfileRepository extends BaseRepository<ExternalProfile> {
|
|||||||
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
|
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
|
||||||
return this.repository.find({ where: { companyId } as any });
|
return this.repository.find({ where: { companyId } as any });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByEmail(email: string): Promise<ExternalProfile | null> {
|
|
||||||
return this.repository.findOne({ where: { email } as any });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class FirstMileController {
|
|||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
|
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
|
||||||
acceptBooking(@Param('reference') reference: string) {
|
acceptBooking(@Param('reference') reference: string) {
|
||||||
return this.firstMileService.acceptBooking(reference);
|
return this.firstMileService.acceptBookingByReference(reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ import { Module, forwardRef } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||||
import { FirstMile } from './entities/first-mile.entity';
|
import { FirstMile } from './entities/first-mile.entity';
|
||||||
import { FirstMileController } from './first-mile.controller';
|
import { FirstMileController } from './first-mile.controller';
|
||||||
import { FirstMileRepository } from './first-mile.repository';
|
import { FirstMileRepository } from './first-mile.repository';
|
||||||
import { FirstMileService } from './first-mile.service';
|
import { FirstMileService } from './first-mile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([FirstMile]), forwardRef(() => BookingsModule)],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([FirstMile]),
|
||||||
|
forwardRef(() => BookingsModule),
|
||||||
|
VehiclesModule,
|
||||||
|
DriversModule,
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
controllers: [FirstMileController],
|
controllers: [FirstMileController],
|
||||||
providers: [FirstMileRepository, FirstMileService],
|
providers: [FirstMileRepository, FirstMileService],
|
||||||
exports: [FirstMileRepository, FirstMileService],
|
exports: [FirstMileRepository, FirstMileService],
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
import { FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
|
import { DriversService } from '../drivers/drivers.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||||
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
||||||
@@ -26,9 +29,14 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FirstMileService {
|
export class FirstMileService {
|
||||||
|
private readonly logger = new Logger(FirstMileService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly firstMileRepository: FirstMileRepository,
|
private readonly firstMileRepository: FirstMileRepository,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly vehiclesService: VehiclesService,
|
||||||
|
private readonly driversService: DriversService,
|
||||||
|
private readonly notificationsService: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,8 +44,8 @@ export class FirstMileService {
|
|||||||
* paid before any first-mile work proceeds. Throws if the reference is
|
* paid before any first-mile work proceeds. Throws if the reference is
|
||||||
* unknown or the booking has not reached PAID status.
|
* unknown or the booking has not reached PAID status.
|
||||||
*/
|
*/
|
||||||
async acceptBooking(bookingReference: string): Promise<FirstMile | null> {
|
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
return null;
|
return null;
|
||||||
@@ -53,6 +61,22 @@ export class FirstMileService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
|
||||||
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.create({
|
||||||
|
bookingId: booking.id,
|
||||||
|
advancedPayment: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
async findAll(filter: FirstMileListFilter = {}): Promise<{
|
async findAll(filter: FirstMileListFilter = {}): Promise<{
|
||||||
data: FirstMile[];
|
data: FirstMile[];
|
||||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||||
@@ -119,7 +143,7 @@ export class FirstMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
||||||
await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
const updated = await this.firstMileRepository.update(id, {
|
const updated = await this.firstMileRepository.update(id, {
|
||||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||||
@@ -135,9 +159,45 @@ export class FirstMileService {
|
|||||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify assigned driver on every explicit vehicle assignment or reassignment
|
||||||
|
if (dto.vehicleId) {
|
||||||
|
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
||||||
|
try {
|
||||||
|
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||||
|
if (!vehicle.assignedDriverId) {
|
||||||
|
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const driver = await this.driversService.findById(vehicle.assignedDriverId);
|
||||||
|
if (!driver.phoneNumber) {
|
||||||
|
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
|
||||||
|
|
||||||
|
await this.notificationsService.notifyDriverVehicleAssignment({
|
||||||
|
driverPhone: driver.phoneNumber,
|
||||||
|
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
|
||||||
|
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
|
||||||
|
bookingReference: booking?.reference ?? record.bookingId,
|
||||||
|
pickupAddress: booking?.firstMilePickupAddress,
|
||||||
|
destinationYard: booking?.originYard?.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.firstMileRepository.softDelete(id);
|
await this.firstMileRepository.softDelete(id);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class LastMileController {
|
|||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||||
acceptBooking(@Param('reference') reference: string) {
|
acceptBooking(@Param('reference') reference: string) {
|
||||||
return this.lastMileService.acceptBooking(reference);
|
return this.lastMileService.acceptBookingByReference(reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||||
import { LastMile } from './entities/last-mile.entity';
|
import { LastMile } from './entities/last-mile.entity';
|
||||||
import { LastMileController } from './last-mile.controller';
|
import { LastMileController } from './last-mile.controller';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { LastMileService } from './last-mile.service';
|
import { LastMileService } from './last-mile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([LastMile]),
|
||||||
|
BookingsModule,
|
||||||
|
VehiclesModule,
|
||||||
|
DriversModule,
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
controllers: [LastMileController],
|
controllers: [LastMileController],
|
||||||
providers: [LastMileRepository, LastMileService],
|
providers: [LastMileRepository, LastMileService],
|
||||||
exports: [LastMileRepository, LastMileService],
|
exports: [LastMileRepository, LastMileService],
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
import { FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
|
import { DriversService } from '../drivers/drivers.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||||
@@ -26,9 +29,14 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LastMileService {
|
export class LastMileService {
|
||||||
|
private readonly logger = new Logger(LastMileService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly lastMileRepository: LastMileRepository,
|
private readonly lastMileRepository: LastMileRepository,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly vehiclesService: VehiclesService,
|
||||||
|
private readonly driversService: DriversService,
|
||||||
|
private readonly notificationsService: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async acceptBooking(bookingReference: string): Promise<LastMile> {
|
async acceptBooking(bookingReference: string): Promise<LastMile> {
|
||||||
@@ -46,7 +54,26 @@ export class LastMileService {
|
|||||||
|
|
||||||
return this.create({
|
return this.create({
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
advancedPayment: booking.totalAmount,
|
advancedPayment: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptBookingByReference(bookingReference: string): Promise<LastMile> {
|
||||||
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.create({
|
||||||
|
bookingId: booking.id,
|
||||||
|
advancedPayment: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +143,7 @@ export class LastMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
|
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
|
||||||
await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
const updated = await this.lastMileRepository.update(id, {
|
const updated = await this.lastMileRepository.update(id, {
|
||||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||||
@@ -132,9 +159,50 @@ export class LastMileService {
|
|||||||
throw new NotFoundException(`Last-mile record ${id} not found`);
|
throw new NotFoundException(`Last-mile record ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify assigned driver on every explicit vehicle assignment or reassignment
|
||||||
|
if (dto.vehicleId) {
|
||||||
|
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
|
||||||
|
try {
|
||||||
|
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||||
|
if (!vehicle.assignedDriverId) {
|
||||||
|
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const driver = await this.driversService.findById(vehicle.assignedDriverId);
|
||||||
|
if (!driver.phoneNumber) {
|
||||||
|
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookingWithYards = {
|
||||||
|
reference?: string;
|
||||||
|
lastMileDeliveryAddress?: string | null;
|
||||||
|
destinationYard?: { label?: string } | null;
|
||||||
|
};
|
||||||
|
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
|
||||||
|
|
||||||
|
await this.notificationsService.notifyDriverVehicleAssignment({
|
||||||
|
driverPhone: driver.phoneNumber,
|
||||||
|
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
|
||||||
|
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
|
||||||
|
bookingReference: booking?.reference ?? record.bookingId,
|
||||||
|
pickupAddress: booking?.destinationYard?.label,
|
||||||
|
destinationYard: booking?.lastMileDeliveryAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.lastMileRepository.softDelete(id);
|
await this.lastMileRepository.softDelete(id);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
|
||||||
import { NotificationsService } from "./notifications.service";
|
import { NotificationsService } from "./notifications.service";
|
||||||
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||||
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HttpModule],
|
imports: [ConfigModule],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
|
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
|
||||||
exports: [NotificationsService],
|
exports: [NotificationsService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule { }
|
export class NotificationsModule {}
|
||||||
|
|||||||
@@ -27,9 +27,29 @@ export class NotificationsService {
|
|||||||
if (!strategy) {
|
if (!strategy) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
const sent = await strategy.send(recipient, message)
|
const sent = await strategy.send(recipient, message);
|
||||||
this.logger.log(`is sent - ${sent}`)
|
this.logger.log(`is sent - ${sent}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async notifyDriverVehicleAssignment(params: {
|
||||||
|
driverPhone: string;
|
||||||
|
driverName: string;
|
||||||
|
vehiclePlateNumber: string;
|
||||||
|
bookingReference: string;
|
||||||
|
pickupAddress?: string | null;
|
||||||
|
destinationYard?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { driverPhone, driverName, vehiclePlateNumber, bookingReference, pickupAddress, destinationYard } = params;
|
||||||
|
const message =
|
||||||
|
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
|
||||||
|
`Booking: ${bookingReference}. Vehicle: ${vehiclePlateNumber}. ` +
|
||||||
|
(pickupAddress ? `Pickup: ${pickupAddress}. ` : '') +
|
||||||
|
(destinationYard ? `Destination: ${destinationYard}.` : '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.directSend('sms', driverPhone, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to notify driver ${driverName} (${driverPhone}): ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
import { Injectable} from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { NotificationStrategy } from "./notification.strategy";
|
|
||||||
import { HttpService } from '@nestjs/axios';
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { NotificationStrategy } from "./notification.strategy";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsNotificationStrategy implements NotificationStrategy {
|
export class SmsNotificationStrategy implements NotificationStrategy {
|
||||||
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { }
|
constructor(private readonly configService: ConfigService) {}
|
||||||
async send(recipient: string, message: string) {
|
|
||||||
const url = this.configService.get("OZIKING_SMS_URL")
|
|
||||||
const body = {
|
|
||||||
to: recipient,
|
|
||||||
text: message
|
|
||||||
}
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.httpService.post(
|
|
||||||
url,
|
|
||||||
body,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.status === 201;
|
async send(recipient: string, message: string): Promise<boolean> {
|
||||||
}
|
const url =
|
||||||
|
this.configService.get<string>("OZIKING_SMS_URL") ??
|
||||||
|
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
to: recipient,
|
||||||
|
sourceId: this.configService.get<string>("OZIKING_SOURCE_ID") ?? "EDR",
|
||||||
|
sourceName: this.configService.get<string>("OZIKING_SOURCE_NAME") ?? "EDR Freight",
|
||||||
|
appKey: this.configService.get<string>("OZIKING_APP_KEY") ?? "",
|
||||||
|
text: message,
|
||||||
|
callbackUrl: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
accept: "*/*",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,9 @@ export class OtpController {
|
|||||||
@Post("send")
|
@Post("send")
|
||||||
async sendOtp(
|
async sendOtp(
|
||||||
@Body("phone")
|
@Body("phone")
|
||||||
phone: string,
|
phone: string
|
||||||
@Body("otp")
|
|
||||||
otp: string
|
|
||||||
) {
|
) {
|
||||||
return this.otpService.sendOtp(
|
return this.otpService.sendOtp(phone);
|
||||||
phone,otp
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ export class OtpService {
|
|||||||
// Send OTP
|
// Send OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async sendOtp(phone: string, otp: string) {
|
async sendOtp(phone: string) {
|
||||||
try {
|
try {
|
||||||
// generate otp
|
// The verification code is generated server-side — never supplied by the
|
||||||
// const otp =
|
// caller — so the OTP stays a secret known only to the server and the
|
||||||
// this.generateOtp();
|
// recipient of the SMS.
|
||||||
|
const otp = this.generateOtp();
|
||||||
|
|
||||||
// find existing phone
|
// find existing phone
|
||||||
const existingPhone =
|
const existingPhone =
|
||||||
|
|||||||
@@ -64,4 +64,4 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
|||||||
controllers: [PaymentController, InternalPaymentController],
|
controllers: [PaymentController, InternalPaymentController],
|
||||||
exports: [PaymentService],
|
exports: [PaymentService],
|
||||||
})
|
})
|
||||||
export class PaymentModule { }
|
export class PaymentModule { }
|
||||||
@@ -436,7 +436,7 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("bulk/schedules/:id/cancel")
|
@Post('bulk/schedules/:id/cancel')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
||||||
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
AllocationLoadType,
|
AllocationLoadType,
|
||||||
SchedulingStatus,
|
SchedulingStatus,
|
||||||
TrainCheckpointKind,
|
TrainCheckpointKind,
|
||||||
@@ -1005,12 +1005,29 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrivingLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
await manager.query(
|
||||||
if (arrivingLocoIds.length) {
|
`UPDATE freight.bookings b
|
||||||
await manager.getRepository(Locomotive).update(
|
SET status = $2,
|
||||||
{ id: In(arrivingLocoIds) },
|
scheduling_status = $3
|
||||||
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
|
FROM freight.train_schedule_bookings tsb
|
||||||
);
|
WHERE tsb.booking_id = b.id
|
||||||
|
AND tsb.train_schedule_id = $1
|
||||||
|
AND tsb.deleted_at IS NULL
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`,
|
||||||
|
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (schedule.trainSet?.locomotiveId) {
|
||||||
|
const loco = await manager
|
||||||
|
.getRepository(Locomotive)
|
||||||
|
.findOne({ where: { id: schedule.trainSet.locomotiveId } });
|
||||||
|
if (loco) {
|
||||||
|
await manager.getRepository(Locomotive).update(loco.id, {
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
currentYardId: schedule.destinationStationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||||
|
|||||||
@@ -37,4 +37,16 @@ export class CreateVehicleDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
assignedDriverName?: string;
|
assignedDriverName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
powerPlateNo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
trailerPlateNo?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,13 @@ export class Vehicle extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'assigned_driver_name', nullable: true })
|
@Column({ name: 'assigned_driver_name', nullable: true })
|
||||||
assignedDriverName?: string;
|
assignedDriverName?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'code', nullable: true })
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'power_plate_no', nullable: true })
|
||||||
|
powerPlateNo?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'trailer_plate_no', nullable: true })
|
||||||
|
trailerPlateNo?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,13 +67,12 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createVehicle(vehicleData: any): Promise<Vehicle> {
|
async createVehicle(vehicleData: Partial<Vehicle>): Promise<Vehicle> {
|
||||||
const vehicle = this.repository.create(vehicleData);
|
const vehicle = this.repository.create(vehicleData);
|
||||||
const vehicles = await this.repository.save(vehicle);
|
return this.repository.save(vehicle);
|
||||||
return vehicles?.[0] as Vehicle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateVehicle(vehicle: Vehicle): Promise<Vehicle> {
|
async updateVehicle(vehicle: Vehicle): Promise<Vehicle> {
|
||||||
return (await this.repository.save(vehicle)) as Vehicle;
|
return this.repository.save(vehicle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
|||||||
export const WAGON_STATUSES = [
|
export const WAGON_STATUSES = [
|
||||||
WagonStatus.Available,
|
WagonStatus.Available,
|
||||||
WagonStatus.Assigned,
|
WagonStatus.Assigned,
|
||||||
|
WagonStatus.ImportReady,
|
||||||
|
WagonStatus.ExportReady,
|
||||||
WagonStatus.Maintenance,
|
WagonStatus.Maintenance,
|
||||||
WagonStatus.Retired,
|
WagonStatus.Retired,
|
||||||
] as const;
|
] as const;
|
||||||
@@ -65,7 +67,7 @@ export class Wagon extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'current_train_schedule_id' })
|
@JoinColumn({ name: 'current_train_schedule_id' })
|
||||||
currentTrainSchedule?: TrainSchedule | null;
|
currentTrainSchedule?: TrainSchedule | null;
|
||||||
|
|
||||||
/** Fleet master consist grouping — separate from operational train_schedules. */
|
/** Fleet master consist grouping — separate from operational train_schedules. */
|
||||||
@ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
|
@ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
|
||||||
@JoinColumn({ name: 'train_id' })
|
@JoinColumn({ name: 'train_id' })
|
||||||
train!: Train | null;
|
train!: Train | null;
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
warehouseId?: string;
|
warehouseId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
facilityId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
search?: string;
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateTo?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto {
|
|||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
bookingReference?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Legacy alias for bookingReference' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
bookingNumber?: string;
|
bookingNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
|
|||||||
@@ -78,17 +78,13 @@ export class WarehouseAllocationService {
|
|||||||
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
|
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
|
||||||
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
|
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
|
||||||
const rule = await this.findMatchingRule(criteria);
|
const rule = await this.findMatchingRule(criteria);
|
||||||
const yardCode = rule?.targetYardCode;
|
if (!rule) return null;
|
||||||
|
|
||||||
// Resolve yard (by rule code, else first available yard with a zone).
|
// Resolve yard by rule code.
|
||||||
const [yard] = await this.dataSource.query(
|
const [yard] = await this.dataSource.query(
|
||||||
yardCode
|
`SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||||
? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`,
|
||||||
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`
|
[rule.targetYardCode],
|
||||||
: `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
|
||||||
JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL
|
|
||||||
WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`,
|
|
||||||
yardCode ? [yardCode] : [],
|
|
||||||
);
|
);
|
||||||
if (!yard) return null;
|
if (!yard) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, IsNull } from 'typeorm';
|
import { DataSource, FindManyOptions, IsNull, ObjectLiteral, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { Warehouse } from './entities/warehouse.entity';
|
import { Warehouse } from './entities/warehouse.entity';
|
||||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
@@ -26,6 +26,29 @@ export interface WarehouseDashboard {
|
|||||||
export class WarehouseDashboardService {
|
export class WarehouseDashboardService {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
private async safeCount<T extends ObjectLiteral>(
|
||||||
|
repo: Repository<T>,
|
||||||
|
options?: FindManyOptions<T>,
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await repo.count(options);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async safeReceivedToday(startOfToday: Date): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await this.dataSource
|
||||||
|
.getRepository(WarehouseInventory)
|
||||||
|
.createQueryBuilder('inv')
|
||||||
|
.where('inv.arrived_at >= :start', { start: startOfToday })
|
||||||
|
.getCount();
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getDashboard(): Promise<WarehouseDashboard> {
|
async getDashboard(): Promise<WarehouseDashboard> {
|
||||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||||
@@ -47,21 +70,18 @@ export class WarehouseDashboardService {
|
|||||||
delivered,
|
delivered,
|
||||||
receivedToday,
|
receivedToday,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
warehouseRepo.count(),
|
this.safeCount(warehouseRepo),
|
||||||
inventoryRepo.count(),
|
this.safeCount(inventoryRepo),
|
||||||
inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
|
this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
|
||||||
inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }),
|
this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }),
|
||||||
inventoryRepo.count({ where: { status: 'STORED' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'STORED' } }),
|
||||||
inventoryRepo.count({ where: { status: 'RESERVED' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }),
|
||||||
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }),
|
||||||
inventoryRepo.count({ where: { status: 'LOADED' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }),
|
||||||
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }),
|
||||||
inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }),
|
||||||
inventoryRepo.count({ where: { status: 'DELIVERED' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }),
|
||||||
inventoryRepo
|
this.safeReceivedToday(startOfToday),
|
||||||
.createQueryBuilder('inv')
|
|
||||||
.where('inv.arrived_at >= :start', { start: startOfToday })
|
|
||||||
.getCount(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ interface ItemAttributes {
|
|||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
cargoTypeCode: string | null;
|
cargoTypeCode: string | null;
|
||||||
containerTypeCode: string | null;
|
containerTypeCode: string | null;
|
||||||
|
inventoryQuantity: number;
|
||||||
|
bookingContainerCount: number;
|
||||||
facilityId: string | null;
|
facilityId: string | null;
|
||||||
warehouseId: string | null;
|
warehouseId: string | null;
|
||||||
yardId: string | null;
|
yardId: string | null;
|
||||||
@@ -31,6 +33,8 @@ export interface FeePreview {
|
|||||||
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
||||||
elapsedDays: number;
|
elapsedDays: number;
|
||||||
chargeableDays: number;
|
chargeableDays: number;
|
||||||
|
containerCount: number;
|
||||||
|
billableUnits: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +71,7 @@ export class WarehouseFeeService {
|
|||||||
`SELECT inv.arrived_at AS "arrivedAt",
|
`SELECT inv.arrived_at AS "arrivedAt",
|
||||||
inv.gate_cleared_at AS "gateClearedAt",
|
inv.gate_cleared_at AS "gateClearedAt",
|
||||||
inv.release_date AS "releaseDate",
|
inv.release_date AS "releaseDate",
|
||||||
|
inv.quantity AS "inventoryQuantity",
|
||||||
inv.warehouse_id AS "warehouseId",
|
inv.warehouse_id AS "warehouseId",
|
||||||
inv.yard_id AS "yardId",
|
inv.yard_id AS "yardId",
|
||||||
inv.zone_id AS "zoneId",
|
inv.zone_id AS "zoneId",
|
||||||
@@ -74,7 +79,8 @@ export class WarehouseFeeService {
|
|||||||
b.freight_type AS "freightType",
|
b.freight_type AS "freightType",
|
||||||
b.trade_direction AS "tradeDirection",
|
b.trade_direction AS "tradeDirection",
|
||||||
cgt.code AS "cargoTypeCode",
|
cgt.code AS "cargoTypeCode",
|
||||||
ctt.code AS "containerTypeCode"
|
ctt.code AS "containerTypeCode",
|
||||||
|
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
|
||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
@@ -82,6 +88,12 @@ export class WarehouseFeeService {
|
|||||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||||
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
|
||||||
|
FROM freight.booking_container bc
|
||||||
|
WHERE bc.booking_id = inv.booking_id
|
||||||
|
AND bc.deleted_at IS NULL
|
||||||
|
) container_lines ON true
|
||||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
||||||
[inventoryId],
|
[inventoryId],
|
||||||
);
|
);
|
||||||
@@ -131,12 +143,18 @@ export class WarehouseFeeService {
|
|||||||
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
||||||
const freeDays = rule?.freeDays ?? 0;
|
const freeDays = rule?.freeDays ?? 0;
|
||||||
const ratePerDay = Number(rule?.ratePerDay ?? 0);
|
const ratePerDay = Number(rule?.ratePerDay ?? 0);
|
||||||
|
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||||
|
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
|
||||||
|
const containerCount = isContainer
|
||||||
|
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
|
||||||
|
: 1;
|
||||||
|
|
||||||
const elapsedDays = start
|
const elapsedDays = start
|
||||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||||
: 0;
|
: 0;
|
||||||
const chargeableDays = Math.max(0, elapsedDays - freeDays);
|
const chargeableDays = Math.max(0, elapsedDays - freeDays);
|
||||||
const amount = Math.round(chargeableDays * ratePerDay * 100) / 100;
|
const billableUnits = chargeableDays * containerCount;
|
||||||
|
const amount = Math.round(billableUnits * ratePerDay * 100) / 100;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ruleType,
|
ruleType,
|
||||||
@@ -150,6 +168,8 @@ export class WarehouseFeeService {
|
|||||||
endIsOpen,
|
endIsOpen,
|
||||||
elapsedDays,
|
elapsedDays,
|
||||||
chargeableDays,
|
chargeableDays,
|
||||||
|
containerCount,
|
||||||
|
billableUnits,
|
||||||
amount,
|
amount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class WarehouseInspectionService {
|
|||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Create an inspection report for an inventory item and sync its inspectionStatus. */
|
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
|
||||||
async create(inventoryId: string, dto: CreateInspectionReportDto): Promise<WarehouseInspectionReport> {
|
async create(inventoryId: string, dto: CreateInspectionReportDto): Promise<WarehouseInspectionReport> {
|
||||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||||
const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } });
|
const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } });
|
||||||
@@ -29,8 +29,9 @@ export class WarehouseInspectionService {
|
|||||||
const expected = dto.expectedWeight ?? null;
|
const expected = dto.expectedWeight ?? null;
|
||||||
const actual = dto.actualWeight ?? null;
|
const actual = dto.actualWeight ?? null;
|
||||||
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
|
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
|
||||||
|
const inspectedAt = new Date();
|
||||||
|
|
||||||
const report = await this.inspectionRepository.create({
|
const payload = {
|
||||||
inventoryId,
|
inventoryId,
|
||||||
bookingId: inventory.bookingId ?? null,
|
bookingId: inventory.bookingId ?? null,
|
||||||
reportType: dto.reportType,
|
reportType: dto.reportType,
|
||||||
@@ -46,13 +47,27 @@ export class WarehouseInspectionService {
|
|||||||
missingItemsDescription: dto.missingItemsDescription ?? null,
|
missingItemsDescription: dto.missingItemsDescription ?? null,
|
||||||
remarks: dto.remarks ?? null,
|
remarks: dto.remarks ?? null,
|
||||||
inspectedById: dto.inspectedById ?? null,
|
inspectedById: dto.inspectedById ?? null,
|
||||||
inspectedAt: new Date(),
|
inspectedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [existingReport] = await this.inspectionRepository.findAll({
|
||||||
|
where: { inventoryId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
take: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let report: WarehouseInspectionReport;
|
||||||
|
if (existingReport) {
|
||||||
|
await this.inspectionRepository.update(existingReport.id, payload);
|
||||||
|
report = await this.findById(existingReport.id);
|
||||||
|
} else {
|
||||||
|
report = await this.inspectionRepository.create(payload);
|
||||||
|
}
|
||||||
|
|
||||||
// Mirror the latest outcome onto the inventory item so loading rules can read it.
|
// Mirror the latest outcome onto the inventory item so loading rules can read it.
|
||||||
await inventoryRepo.update(inventoryId, {
|
await inventoryRepo.update(inventoryId, {
|
||||||
inspectionStatus: dto.inspectionStatus,
|
inspectionStatus: dto.inspectionStatus,
|
||||||
inspectedAt: new Date(),
|
inspectedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return report;
|
return report;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||||
@@ -226,6 +227,16 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.release(id, dto);
|
return this.inventoryService.release(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/release-document')
|
||||||
|
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
|
||||||
|
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||||
|
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
return res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/deliver')
|
@Post(':id/deliver')
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -75,9 +75,9 @@ export class WarehouseInvoiceService {
|
|||||||
feeType,
|
feeType,
|
||||||
description:
|
description:
|
||||||
p.ruleType === 'STORAGE_FEE'
|
p.ruleType === 'STORAGE_FEE'
|
||||||
? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`
|
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
|
||||||
: `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`,
|
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
|
||||||
quantity: p.chargeableDays,
|
quantity: p.billableUnits,
|
||||||
unitRate: p.ratePerDay,
|
unitRate: p.ratePerDay,
|
||||||
amount: p.amount,
|
amount: p.amount,
|
||||||
currency: p.currency,
|
currency: p.currency,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export class WarehouseYardsController {
|
|||||||
private readonly zonesService: WarehouseZonesService,
|
private readonly zonesService: WarehouseZonesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all warehouse yards' })
|
||||||
|
findAll() {
|
||||||
|
return this.yardsService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export class WarehouseYardsService {
|
|||||||
private readonly warehousesService: WarehousesService,
|
private readonly warehousesService: WarehousesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
findAll(): Promise<WarehouseYard[]> {
|
||||||
|
return this.yardsRepository.findAll({
|
||||||
|
relations: { warehouse: true, zones: true },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
||||||
return this.yardsRepository.findAll({
|
return this.yardsRepository.findAll({
|
||||||
where: { warehouseId },
|
where: { warehouseId },
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
|||||||
export class WarehouseZonesController {
|
export class WarehouseZonesController {
|
||||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all warehouse zones' })
|
||||||
|
findAll() {
|
||||||
|
return this.zonesService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get warehouse zone by ID' })
|
@ApiOperation({ summary: 'Get warehouse zone by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export class WarehouseZonesService {
|
|||||||
private readonly yardsService: WarehouseYardsService,
|
private readonly yardsService: WarehouseYardsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
findAll(): Promise<WarehouseZone[]> {
|
||||||
|
return this.zonesRepository.findAll({
|
||||||
|
relations: { yard: { warehouse: true } },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
findByYard(yardId: string): Promise<WarehouseZone[]> {
|
findByYard(yardId: string): Promise<WarehouseZone[]> {
|
||||||
return this.zonesRepository.findAll({
|
return this.zonesRepository.findAll({
|
||||||
where: { yardId },
|
where: { yardId },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||||
@@ -100,6 +101,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseInvoiceService,
|
WarehouseInvoiceService,
|
||||||
WarehouseSchedulingAdapterService,
|
WarehouseSchedulingAdapterService,
|
||||||
SchedulingReadFacade,
|
SchedulingReadFacade,
|
||||||
|
ContractPdfService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
WarehousesService,
|
WarehousesService,
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ export class Batch5TestDataSeeder {
|
|||||||
serviceTypeId: serviceType.id,
|
serviceTypeId: serviceType.id,
|
||||||
status: 'PAID',
|
status: 'PAID',
|
||||||
paymentStatus: 'PAID',
|
paymentStatus: 'PAID',
|
||||||
|
scheduledDate: now,
|
||||||
|
contractType: 'SPOT',
|
||||||
|
equipmentReturn: 'TERMINAL',
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
totalAmount: 0,
|
||||||
|
isGovernment: false,
|
||||||
tradeDirection: 'EXPORT',
|
tradeDirection: 'EXPORT',
|
||||||
freightType: 'BULK',
|
freightType: 'BULK',
|
||||||
cargoTotalWeightVgm: seed.weight,
|
cargoTotalWeightVgm: seed.weight,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export class WarehouseDemoSeeder {
|
|||||||
): Promise<Booking> =>
|
): Promise<Booking> =>
|
||||||
bookingRepo.save(
|
bookingRepo.save(
|
||||||
bookingRepo.create({
|
bookingRepo.create({
|
||||||
|
...this.demoBookingDefaults(),
|
||||||
reference,
|
reference,
|
||||||
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
|
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
|
||||||
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id,
|
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id,
|
||||||
@@ -237,6 +238,7 @@ export class WarehouseDemoSeeder {
|
|||||||
for (let i = 1; i <= 3; i++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
const b = await bookingRepo.save(
|
const b = await bookingRepo.save(
|
||||||
bookingRepo.create({
|
bookingRepo.create({
|
||||||
|
...this.demoBookingDefaults(),
|
||||||
reference: `WH-DEMO-ARR-${i}`,
|
reference: `WH-DEMO-ARR-${i}`,
|
||||||
originYardId: djibYard.id,
|
originYardId: djibYard.id,
|
||||||
destinationYardId: ethYard.id,
|
destinationYardId: ethYard.id,
|
||||||
@@ -255,4 +257,15 @@ export class WarehouseDemoSeeder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private demoBookingDefaults(): Partial<Booking> {
|
||||||
|
return {
|
||||||
|
scheduledDate: new Date(),
|
||||||
|
contractType: 'SPOT',
|
||||||
|
equipmentReturn: 'TERMINAL',
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
totalAmount: 0,
|
||||||
|
isGovernment: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,16 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { ArrowRight, Building2, Package } from "lucide-react";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import { ArrowRight, Calendar, Package, User } from "lucide-react";
|
||||||
Accordion,
|
import { Group } from "@mantine/core";
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
|
|
||||||
|
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
|
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import type { BookingListRow } from "@/types/booking";
|
import type { BookingListRow } from "@/types/booking";
|
||||||
import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Badge, DataTable, type ColumnDef } from "@edr/ui-common";
|
||||||
function BookingQueueRow({
|
|
||||||
booking,
|
|
||||||
selected,
|
|
||||||
disabled,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
booking: BookingListRow;
|
|
||||||
selected: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Group
|
|
||||||
align="flex-start"
|
|
||||||
wrap="nowrap"
|
|
||||||
p="sm"
|
|
||||||
style={{
|
|
||||||
border: "1px solid var(--mantine-color-gray-3)",
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
|
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Package size={14} />
|
|
||||||
<Text fw={600} size="sm">{booking.reference}</Text>
|
|
||||||
{booking.isGovernment ? (
|
|
||||||
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
|
|
||||||
Government
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
|
|
||||||
{booking.schedulingStatus ? (
|
|
||||||
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
|
|
||||||
<Group gap={6}>
|
|
||||||
<Text size="xs">{booking.originLabel}</Text>
|
|
||||||
<ArrowRight size={12} />
|
|
||||||
<Text size="xs">{booking.destinationLabel}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="sm">
|
|
||||||
<BookingPriorityBadge score={booking.priorityScore} />
|
|
||||||
{booking.serviceTypeLabel ? (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{booking.serviceTypeLabel}
|
|
||||||
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OperationsBookingQueue({
|
export function OperationsBookingQueue({
|
||||||
bookings,
|
bookings,
|
||||||
@@ -82,144 +21,144 @@ export function OperationsBookingQueue({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onAllocate: (bookingIds: string[]) => void;
|
onAllocate: (bookingIds: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { government, commercial } = useMemo(
|
const navigate = useNavigate();
|
||||||
() => groupBookingsForOperationsQueue(bookings),
|
const suppressRowClickRef = useRef(false);
|
||||||
[bookings],
|
|
||||||
|
const suppressRowClick = useCallback(() => {
|
||||||
|
suppressRowClickRef.current = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
suppressRowClickRef.current = false;
|
||||||
|
}, 400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(row: BookingListRow) => {
|
||||||
|
if (suppressRowClickRef.current) return;
|
||||||
|
navigate(`/dashboard/booking-requests/${row.id}`);
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
);
|
);
|
||||||
const [govSelected, setGovSelected] = useState<string[]>([]);
|
|
||||||
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
|
|
||||||
|
|
||||||
const allocatable = (row: BookingListRow) =>
|
const columns: ColumnDef<BookingListRow>[] = [
|
||||||
row.status === "PAID" &&
|
{
|
||||||
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus });
|
id: "booking",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||||
const govSelection = govSelected.length
|
cell: ({ row }) => {
|
||||||
? govSelected
|
const booking = row.original;
|
||||||
: government.filter(allocatable).map((b) => b.id);
|
return (
|
||||||
|
<div className="flex items-center gap-3 py-1.5">
|
||||||
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
|
<div className={bookingTable.rowIcon}>
|
||||||
const existing = selectedByBucket[bucketKey];
|
<Package className="size-4" strokeWidth={1.75} />
|
||||||
if (existing) return existing;
|
</div>
|
||||||
return bucketBookings.filter(allocatable).map((b) => b.id);
|
<div className="min-w-0">
|
||||||
};
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<p className="truncate font-medium text-foreground">{booking.reference}</p>
|
||||||
const toggleGov = (bookingId: string) => {
|
{booking.isGovernment ? (
|
||||||
setGovSelected((prev) => {
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||||
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
|
Government
|
||||||
return base.includes(bookingId)
|
</Badge>
|
||||||
? base.filter((id) => id !== bookingId)
|
) : null}
|
||||||
: [...base, bookingId];
|
</Group>
|
||||||
});
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||||
};
|
<User className="size-3 shrink-0 opacity-70" />
|
||||||
|
{booking.customerLabel}
|
||||||
const toggleBucket = (bucketKey: string, bookingId: string) => {
|
</p>
|
||||||
setSelectedByBucket((prev) => {
|
</div>
|
||||||
const current = prev[bucketKey] ?? [];
|
</div>
|
||||||
const next = current.includes(bookingId)
|
);
|
||||||
? current.filter((id) => id !== bookingId)
|
},
|
||||||
: [...current, bookingId];
|
},
|
||||||
return { ...prev, [bucketKey]: next };
|
{
|
||||||
});
|
id: "route",
|
||||||
};
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
if (isLoading) {
|
const booking = row.original;
|
||||||
return <Text size="sm" c="dimmed">Loading operations queue…</Text>;
|
return (
|
||||||
}
|
<div className="space-y-1 py-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
if (!government.length && !commercial.length) {
|
<span className="max-w-[8rem] truncate">{booking.originLabel}</span>
|
||||||
return (
|
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<Text size="sm" c="dimmed">
|
<span className="max-w-[8rem] truncate">{booking.destinationLabel}</span>
|
||||||
No PAID bookings ready to allocate.
|
</div>
|
||||||
</Text>
|
<div className="flex gap-1.5">
|
||||||
);
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px] uppercase">
|
||||||
}
|
{booking.tradeDirection}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||||
|
{booking.freightType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="space-y-1 py-1">
|
||||||
|
<BookingStatusBadge status={row.original.status} />
|
||||||
|
{row.original.schedulingStatus ? (
|
||||||
|
<SchedulingStatusBadge status={row.original.schedulingStatus} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scheduled",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Calendar className="size-3.5" />
|
||||||
|
{row.original.scheduledDate}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "priority",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Priority</span>,
|
||||||
|
cell: ({ row }) => <BookingPriorityBadge score={row.original.priorityScore} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amount",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Amount</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||||
|
{row.original.paymentCurrency}{" "}
|
||||||
|
{row.original.totalAmount.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Actions</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<BookingActionsMenu
|
||||||
|
row={row.original}
|
||||||
|
variant="table"
|
||||||
|
onSuppressRowClick={suppressRowClick}
|
||||||
|
onAllocateBooking={() => onAllocate([row.original.id])}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<DataTable
|
||||||
{government.length > 0 ? (
|
columns={columns}
|
||||||
<Paper withBorder p="md" radius="md">
|
data={bookings}
|
||||||
<Group justify="space-between" mb="md">
|
status={isLoading ? "loading" : "success"}
|
||||||
<Stack gap={2}>
|
emptyMessage="No PAID bookings ready to load."
|
||||||
<Title order={5}>Government priority</Title>
|
onRowClick={handleRowClick}
|
||||||
<Text size="xs" c="dimmed">
|
containerClassName={cn(
|
||||||
Served first — not grouped by 3-hour window
|
"border-0 shadow-none",
|
||||||
</Text>
|
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
||||||
</Stack>
|
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
||||||
<Group gap="xs">
|
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||||
<Badge variant="light">{govSelection.length} selected</Badge>
|
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
||||||
<Button
|
)}
|
||||||
size="compact-sm"
|
/>
|
||||||
color="violet"
|
|
||||||
disabled={!govSelection.length}
|
|
||||||
onClick={() => onAllocate(govSelection)}
|
|
||||||
>
|
|
||||||
Allocate
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{government.map((booking) => (
|
|
||||||
<BookingQueueRow
|
|
||||||
key={booking.id}
|
|
||||||
booking={booking}
|
|
||||||
selected={govSelection.includes(booking.id)}
|
|
||||||
disabled={!allocatable(booking)}
|
|
||||||
onToggle={() => toggleGov(booking.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{commercial.length > 0 ? (
|
|
||||||
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
|
|
||||||
{commercial.map((bucket) => {
|
|
||||||
const selected = bucketSelection(bucket.key, bucket.bookings);
|
|
||||||
return (
|
|
||||||
<Accordion.Item key={bucket.key} value={bucket.key}>
|
|
||||||
<Accordion.Control>
|
|
||||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Text fw={600} size="sm">{bucket.label}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{bucket.bookings.length} commercial booking
|
|
||||||
{bucket.bookings.length === 1 ? "" : "s"}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Badge variant="light">{selected.length} selected</Badge>
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
color="edr-green"
|
|
||||||
disabled={!selected.length}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocate(selected);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Allocate
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Accordion.Control>
|
|
||||||
<Accordion.Panel>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{bucket.bookings.map((booking) => (
|
|
||||||
<BookingQueueRow
|
|
||||||
key={booking.id}
|
|
||||||
booking={booking}
|
|
||||||
selected={selected.includes(booking.id)}
|
|
||||||
disabled={!allocatable(booking)}
|
|
||||||
onToggle={() => toggleBucket(bucket.key, booking.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Accordion.Panel>
|
|
||||||
</Accordion.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Accordion>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
|||||||
subtitle: "Manage route definitions built from freight yards",
|
subtitle: "Manage route definitions built from freight yards",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/warehouses/list",
|
||||||
|
meta: {
|
||||||
|
title: "Warehouses",
|
||||||
|
subtitle: "Manage warehouses, yards, and zones",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/warehouse-inventory",
|
||||||
|
meta: {
|
||||||
|
title: "Warehouse inventory",
|
||||||
|
subtitle: "Track received items through inspection and loading",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/inventory-inquiry",
|
||||||
|
meta: {
|
||||||
|
title: "Inventory inquiry",
|
||||||
|
subtitle: "Locate cargo, containers, and goods inside the warehouse network",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/warehouses",
|
||||||
|
meta: {
|
||||||
|
title: "Warehouse dashboard",
|
||||||
|
subtitle: "Live overview of warehouse capacity and inventory lifecycle",
|
||||||
|
},
|
||||||
|
},
|
||||||
...getFleetRouteMeta(),
|
...getFleetRouteMeta(),
|
||||||
{
|
{
|
||||||
prefix: "/dashboard/trains/",
|
prefix: "/dashboard/trains/",
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractErrorMessage } from './options';
|
||||||
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse';
|
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
|
||||||
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||||
DRAFT: 'gray',
|
DRAFT: 'gray',
|
||||||
@@ -32,6 +34,9 @@ function fmtDate(iso: string | null) {
|
|||||||
return new Date(iso).toLocaleDateString();
|
return new Date(iso).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const money = (amount: number, currency: string) =>
|
||||||
|
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||||
|
|
||||||
function FeeCard({ fee }: { fee: FeePreview }) {
|
function FeeCard({ fee }: { fee: FeePreview }) {
|
||||||
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
|
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
|
||||||
const configured = Boolean(fee.ruleId);
|
const configured = Boolean(fee.ruleId);
|
||||||
@@ -48,7 +53,7 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Text fw={800} size="lg" c={`${meta.color}.7`}>
|
<Text fw={800} size="lg" c={`${meta.color}.7`}>
|
||||||
{fee.amount.toLocaleString()} {fee.currency}
|
{money(fee.amount, fee.currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -60,10 +65,12 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
|||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Row label="Rule" value={fee.ruleName ?? '—'} />
|
<Row label="Rule" value={fee.ruleName ?? '—'} />
|
||||||
<Row label="Free days" value={String(fee.freeDays)} />
|
<Row label="Free days" value={String(fee.freeDays)} />
|
||||||
<Row label="Rate / day" value={`${fee.ratePerDay.toLocaleString()} ${fee.currency}`} />
|
<Row label="Rate / day" value={money(fee.ratePerDay, fee.currency)} />
|
||||||
<Row label="Period" value={`${fmtDate(fee.startDate)} → ${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
|
<Row label="Period" value={`${fmtDate(fee.startDate)} → ${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
|
||||||
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
|
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
|
||||||
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
||||||
|
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
|
||||||
|
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -106,7 +113,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
|||||||
if (!inventoryId) return;
|
if (!inventoryId) return;
|
||||||
try {
|
try {
|
||||||
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
|
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
|
||||||
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} — ${inv.totalAmount} ${inv.currency}` });
|
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} - ${money(inv.totalAmount, inv.currency)}` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = extractErrorMessage(error);
|
const msg = extractErrorMessage(error);
|
||||||
if (/no payable warehouse fee/i.test(msg)) {
|
if (/no payable warehouse fee/i.test(msg)) {
|
||||||
@@ -121,11 +128,21 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
|||||||
|
|
||||||
const handleGateClearance = async () => {
|
const handleGateClearance = async () => {
|
||||||
if (!inventoryId) return;
|
if (!inventoryId) return;
|
||||||
|
const pdfWindow = window.open('', '_blank');
|
||||||
try {
|
try {
|
||||||
await gateClear.mutateAsync(inventoryId);
|
const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
|
||||||
toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' });
|
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
|
||||||
|
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
|
||||||
|
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
|
||||||
|
toast({
|
||||||
|
title: 'Gate clearance recorded',
|
||||||
|
description: opened
|
||||||
|
? 'The release PDF opened in a browser tab.'
|
||||||
|
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
|
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -165,7 +182,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due
|
{money(Number(activeInvoice.balanceAmount), activeInvoice.currency)} due
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -12,10 +12,8 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Upload } from 'lucide-react';
|
import { Upload } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
|
||||||
import {
|
import {
|
||||||
INSPECTION_REPORT_TYPES,
|
INSPECTION_REPORT_TYPES,
|
||||||
INSPECTION_STATUSES,
|
INSPECTION_STATUSES,
|
||||||
@@ -47,12 +45,9 @@ const STATUS_LABELS: Record<InspectionResultStatus, string> = {
|
|||||||
/** Batch 4.5 — record an inspection / damage report with optional image upload. */
|
/** Batch 4.5 — record an inspection / damage report with optional image upload. */
|
||||||
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
|
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const createReport = useMutation(
|
const createReport = useCreateInspectionReport();
|
||||||
api.warehouses.createInspectionReport.mutationOptions(),
|
const uploadAttachments = useUploadInspectionAttachments();
|
||||||
);
|
const reportsQuery = useInspectionReports(opened ? inventoryId ?? undefined : undefined);
|
||||||
const uploadAttachments = useMutation(
|
|
||||||
api.warehouses.uploadInspectionAttachments.mutationOptions(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
|
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
|
||||||
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
|
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
|
||||||
@@ -82,6 +77,27 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
|
|||||||
setFiles([]);
|
setFiles([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) return;
|
||||||
|
const report = reportsQuery.data?.[0];
|
||||||
|
if (!report) {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReportType(report.reportType);
|
||||||
|
setInspectionStatus(report.inspectionStatus);
|
||||||
|
setHasDamage(report.hasDamage ?? false);
|
||||||
|
setDamageDescription(report.damageDescription ?? '');
|
||||||
|
setHasWeightLoss(report.hasWeightLoss ?? false);
|
||||||
|
setExpectedWeight(report.expectedWeight == null ? '' : Number(report.expectedWeight));
|
||||||
|
setActualWeight(report.actualWeight == null ? '' : Number(report.actualWeight));
|
||||||
|
setHasMissingItems(report.hasMissingItems ?? false);
|
||||||
|
setMissingItemsDescription(report.missingItemsDescription ?? '');
|
||||||
|
setRemarks(report.remarks ?? '');
|
||||||
|
setFiles([]);
|
||||||
|
}, [opened, reportsQuery.data]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!inventoryId) return;
|
if (!inventoryId) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||||
|
|
||||||
|
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
|
import { InventoryStatusBadge } from './badges';
|
||||||
|
import { formatDate, formatNumber } from './options';
|
||||||
|
|
||||||
|
interface InventoryDetailModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
item: WarehouseInventoryItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{value || '-'}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl">
|
||||||
|
{!item ? (
|
||||||
|
<Text c="dimmed">No inventory item selected.</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="lg" fw={800}>
|
||||||
|
{item.booking?.reference ?? item.bookingId ?? item.id}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Inventory ID: {item.id}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<InventoryStatusBadge status={item.status} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider label="Location" labelPosition="left" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<DetailRow label="Warehouse" value={item.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : '-'} />
|
||||||
|
<DetailRow label="Yard" value={item.yard ? `${item.yard.name} (${item.yard.code})` : '-'} />
|
||||||
|
<DetailRow label="Zone" value={item.zone ? `${item.zone.name} (${item.zone.code})` : '-'} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Divider label="Booking & item" labelPosition="left" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
|
||||||
|
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
|
||||||
|
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
|
||||||
|
<DetailRow label="Container ID" value={item.containerId ?? '-'} />
|
||||||
|
<DetailRow label="Cargo ID" value={item.cargoId ?? '-'} />
|
||||||
|
<DetailRow label="Goods ID" value={item.goodsId ?? '-'} />
|
||||||
|
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
||||||
|
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
|
||||||
|
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Divider label="Lifecycle" labelPosition="left" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<DetailRow label="Inspection" value={<Badge variant="light">{item.inspectionStatus ?? 'Not inspected'}</Badge>} />
|
||||||
|
<DetailRow label="Arrived" value={formatDate(item.arrivedAt)} />
|
||||||
|
<DetailRow label="Stored" value={formatDate(item.storedAt)} />
|
||||||
|
<DetailRow label="Reserved" value={formatDate(item.reservedAt)} />
|
||||||
|
<DetailRow label="Inspected" value={formatDate(item.inspectedAt)} />
|
||||||
|
<DetailRow label="Ready for loading" value={formatDate(item.readyForLoadingAt)} />
|
||||||
|
<DetailRow label="Loaded" value={formatDate(item.loadedAt)} />
|
||||||
|
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
|
||||||
|
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
|
||||||
|
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
|
||||||
|
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
|
||||||
|
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{item.notes && (
|
||||||
|
<>
|
||||||
|
<Divider label="Notes" labelPosition="left" />
|
||||||
|
<Text size="sm">{item.notes}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||||
|
|
||||||
|
import type { InventoryInquiryResult } from '@/types/warehouse';
|
||||||
|
import { InventoryStatusBadge } from './badges';
|
||||||
|
import { formatDate, formatNumber } from './options';
|
||||||
|
|
||||||
|
interface InventoryInquiryDetailModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
result: InventoryInquiryResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{value || '-'}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemLabel(result: InventoryInquiryResult) {
|
||||||
|
if (result.containerNumber) return `Container ${result.containerNumber}`;
|
||||||
|
if (result.cargoType) return result.cargoType;
|
||||||
|
if (result.cargoDescription) return result.cargoDescription;
|
||||||
|
if (result.goodsId) return `Goods ${result.goodsId}`;
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryInquiryDetailModal({ opened, onClose, result }: InventoryInquiryDetailModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Inventory inquiry detail" centered size="xl">
|
||||||
|
{!result ? (
|
||||||
|
<Text c="dimmed">No inquiry result selected.</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="lg" fw={800}>
|
||||||
|
{result.bookingReference ?? result.bookingNumber ?? result.bookingId ?? result.id}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Inventory ID: {result.inventoryId ?? 'Not yet in warehouse inventory'}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{result.status ? (
|
||||||
|
<InventoryStatusBadge status={result.status} />
|
||||||
|
) : (
|
||||||
|
<Badge variant="light" color={result.trainStatus === 'ARRIVED' ? 'orange' : 'blue'}>
|
||||||
|
{result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider label="Booking" labelPosition="left" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<DetailRow label="Booking reference" value={result.bookingReference ?? result.bookingNumber ?? '-'} />
|
||||||
|
<DetailRow label="Booking status" value={result.bookingStatus ?? '-'} />
|
||||||
|
<DetailRow label="Customer" value={result.customerName ?? '-'} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Divider label="Item" labelPosition="left" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<DetailRow label="Item" value={itemLabel(result)} />
|
||||||
|
<DetailRow label="Quantity" value={formatNumber(result.quantity)} />
|
||||||
|
<DetailRow label="Weight" value={`${formatNumber(result.weight)} kg`} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Divider label="Location" labelPosition="left" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<DetailRow label="Warehouse" value={result.warehouse ? `${result.warehouse.name} (${result.warehouse.code})` : '-'} />
|
||||||
|
<DetailRow label="Yard" value={result.yard ? `${result.yard.name} (${result.yard.code})` : '-'} />
|
||||||
|
<DetailRow label="Zone" value={result.zone ? `${result.zone.name} (${result.zone.code})` : '-'} />
|
||||||
|
<DetailRow label="Current location" value={result.locationSummary ?? '-'} />
|
||||||
|
<DetailRow label="Train" value={result.trainNumber ?? '-'} />
|
||||||
|
<DetailRow label="Route" value={result.route ?? '-'} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Divider label="Dates" labelPosition="left" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<DetailRow label="Arrived" value={formatDate(result.arrivedAt)} />
|
||||||
|
<DetailRow label="Ready for loading" value={formatDate(result.readyForLoadingAt)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ import { useMutation } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||||
import { FeePreviewModal } from './FeePreviewModal';
|
import { FeePreviewModal } from './FeePreviewModal';
|
||||||
import { InspectionReportModal } from './InspectionReportModal';
|
import { InspectionReportModal } from './InspectionReportModal';
|
||||||
|
import { InventoryDetailModal } from './InventoryDetailModal';
|
||||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
@@ -17,6 +19,7 @@ import { ReleaseOrderModal } from './ReleaseOrderModal';
|
|||||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractErrorMessage } from './options';
|
||||||
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
interface InventoryWorkbenchProps {
|
interface InventoryWorkbenchProps {
|
||||||
items: WarehouseInventoryItem[];
|
items: WarehouseInventoryItem[];
|
||||||
@@ -33,6 +36,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
@@ -91,10 +95,46 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadReleaseDocument = async (item: WarehouseInventoryItem) => {
|
||||||
|
setBusyId(item.id);
|
||||||
|
const pdfWindow = window.open('', '_blank');
|
||||||
|
try {
|
||||||
|
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||||
|
const blob = response.data;
|
||||||
|
const filename = `release-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`;
|
||||||
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||||
|
toast({ title: opened ? 'Release exit paper opened' : 'Release exit paper downloaded' });
|
||||||
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Release paper preview failed',
|
||||||
|
description: extractErrorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeInventory = async (item: WarehouseInventoryItem) => {
|
||||||
|
setBusyId(item.id);
|
||||||
|
try {
|
||||||
|
const stored = await storeMutation.mutateAsync(item.id);
|
||||||
|
toast({
|
||||||
|
title: 'Inventory stored',
|
||||||
|
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
|
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'store':
|
case 'store':
|
||||||
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored');
|
return storeInventory(item);
|
||||||
case 'reserve':
|
case 'reserve':
|
||||||
setReserveItem(item);
|
setReserveItem(item);
|
||||||
return;
|
return;
|
||||||
@@ -151,8 +191,10 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
onAdvance={advance}
|
onAdvance={advance}
|
||||||
onMove={setMoveItem}
|
onMove={setMoveItem}
|
||||||
onHistory={setHistoryItem}
|
onHistory={setHistoryItem}
|
||||||
|
onView={setViewItem}
|
||||||
onInspect={setInspectItem}
|
onInspect={setInspectItem}
|
||||||
onFeePreview={setFeeItem}
|
onFeePreview={setFeeItem}
|
||||||
|
onReleaseDocument={downloadReleaseDocument}
|
||||||
onLastMile={onLastMile}
|
onLastMile={onLastMile}
|
||||||
selectedIds={selected}
|
selectedIds={selected}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
@@ -174,6 +216,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
onClose={() => setHistoryItem(null)}
|
onClose={() => setHistoryItem(null)}
|
||||||
item={historyItem}
|
item={historyItem}
|
||||||
/>
|
/>
|
||||||
|
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
|
||||||
<InspectionReportModal
|
<InspectionReportModal
|
||||||
opened={Boolean(inspectItem)}
|
opened={Boolean(inspectItem)}
|
||||||
onClose={() => setInspectItem(null)}
|
onClose={() => setInspectItem(null)}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { useMutation } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractErrorMessage } from './options';
|
||||||
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
interface ReleaseOrderModalProps {
|
interface ReleaseOrderModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@@ -19,6 +21,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||||
const [reference, setReference] = useState('');
|
const [reference, setReference] = useState('');
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (opened) setReference(item?.releaseOrderReference ?? '');
|
if (opened) setReference(item?.releaseOrderReference ?? '');
|
||||||
@@ -26,36 +29,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
const pdfWindow = window.open('', '_blank');
|
||||||
try {
|
try {
|
||||||
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } });
|
const released = await releaseMutation.mutateAsync({
|
||||||
toast({ title: 'Release order issued' });
|
id: item.id,
|
||||||
|
payload: { reference: reference.trim() || undefined },
|
||||||
|
});
|
||||||
|
setDownloading(true);
|
||||||
|
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||||
|
const blob = response.data;
|
||||||
|
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
|
||||||
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||||
|
toast({
|
||||||
|
title: 'Release exit paper issued',
|
||||||
|
description: opened
|
||||||
|
? 'The PDF opened in a browser tab for printing or saving.'
|
||||||
|
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
|
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal opened={opened} onClose={onClose} title="Issue release order (DO)" centered size="md">
|
<Modal opened={opened} onClose={onClose} title="Issue release exit paper" centered size="md">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Records the delivery order / release order sent to the customer. Once issued, the goods can be
|
Creates the warehouse release document with booking, customer, cargo and location details. The
|
||||||
picked up and delivered.
|
printed paper authorizes the goods to leave the warehouse gate.
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Release order reference"
|
label="Release document reference"
|
||||||
placeholder="e.g. DO-2026-001"
|
placeholder="e.g. REL-2026-001"
|
||||||
value={reference}
|
value={reference}
|
||||||
onChange={(e) => setReference(e.currentTarget.value)}
|
onChange={(e) => setReference(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end" mt="sm">
|
<Group justify="flex-end" mt="sm">
|
||||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}>
|
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}>
|
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||||
Issue release order
|
Issue & view exit paper
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
|
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
|
||||||
import { Building2, Eye, MapPin, Pencil } from 'lucide-react';
|
import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
@@ -35,56 +35,101 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
|||||||
return (
|
return (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
{warehouses.map((warehouse) => (
|
{warehouses.map((warehouse) => (
|
||||||
<Card key={warehouse.id} withBorder radius="md" padding="lg">
|
<Card
|
||||||
<Stack gap="sm">
|
key={warehouse.id}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding={0}
|
||||||
|
style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderColor: 'var(--mantine-color-gray-2)',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box h={3} bg={warehouse.status === 'ACTIVE' ? 'green.5' : 'gray.4'} />
|
||||||
|
<Stack gap="md" p="lg">
|
||||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
<div>
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
<Text fw={700}>{warehouse.name}</Text>
|
<Box
|
||||||
<Text size="xs" c="dimmed">
|
style={{
|
||||||
{warehouse.code}
|
width: 38,
|
||||||
</Text>
|
height: 38,
|
||||||
</div>
|
borderRadius: 8,
|
||||||
<WarehouseStatusBadge status={warehouse.status} />
|
display: 'flex',
|
||||||
</Group>
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
<Group gap="xs">
|
flexShrink: 0,
|
||||||
<WarehouseTypeBadge type={warehouse.type} />
|
background: 'var(--mantine-color-green-0)',
|
||||||
</Group>
|
color: 'var(--mantine-color-green-7)',
|
||||||
|
border: '1px solid var(--mantine-color-green-2)',
|
||||||
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
|
}}
|
||||||
<Group gap={6} c="dimmed">
|
>
|
||||||
<Building2 size={14} />
|
<Building2 size={18} />
|
||||||
<Text size="sm">{stationNameById.get(warehouse.stationId)}</Text>
|
</Box>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={800} size="md" truncate>
|
||||||
|
{warehouse.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} truncate>
|
||||||
|
{warehouse.code}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
|
||||||
|
|
||||||
{warehouse.locationName && (
|
<Stack gap={6} align="flex-end">
|
||||||
<Group gap={6} c="dimmed">
|
<WarehouseStatusBadge status={warehouse.status} />
|
||||||
<MapPin size={14} />
|
<WarehouseTypeBadge type={warehouse.type} />
|
||||||
<Text size="sm">{warehouse.locationName}</Text>
|
</Stack>
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Weight
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Containers
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group justify="flex-end" gap="xs" mt="xs">
|
<Stack gap={8}>
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
|
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
|
||||||
<Eye size={16} />
|
<Group gap={8} c="dimmed" wrap="nowrap">
|
||||||
</ActionIcon>
|
<Building2 size={15} />
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
|
<Text size="sm" truncate>
|
||||||
<Pencil size={16} />
|
{stationNameById.get(warehouse.stationId)}
|
||||||
</ActionIcon>
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{warehouse.locationName && (
|
||||||
|
<Group gap={8} c="dimmed" wrap="nowrap">
|
||||||
|
<MapPin size={15} />
|
||||||
|
<Text size="sm" truncate>
|
||||||
|
{warehouse.locationName}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
<CapacityRow
|
||||||
|
icon={Weight}
|
||||||
|
label="Weight"
|
||||||
|
current={warehouse.currentWeight}
|
||||||
|
capacity={warehouse.capacityWeight}
|
||||||
|
/>
|
||||||
|
<CapacityRow
|
||||||
|
icon={Package}
|
||||||
|
label="Containers"
|
||||||
|
current={warehouse.currentContainers}
|
||||||
|
capacity={warehouse.capacityContainers}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="xs" mt="auto">
|
||||||
|
<Tooltip label="View warehouse" withArrow>
|
||||||
|
<ActionIcon variant="light" color="gray" onClick={() => onView(warehouse)} aria-label="View warehouse">
|
||||||
|
<Eye size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Edit warehouse" withArrow>
|
||||||
|
<ActionIcon variant="light" color="orange" onClick={() => onEdit(warehouse)} aria-label="Edit warehouse">
|
||||||
|
<Pencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -92,3 +137,40 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const capacityPercent = (current?: number | null, capacity?: number | null) => {
|
||||||
|
if (!capacity || capacity <= 0) return 0;
|
||||||
|
return Math.min(100, Math.max(0, ((current ?? 0) / capacity) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
function CapacityRow({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
current,
|
||||||
|
capacity,
|
||||||
|
}: {
|
||||||
|
icon: typeof Weight;
|
||||||
|
label: string;
|
||||||
|
current?: number | null;
|
||||||
|
capacity?: number | null;
|
||||||
|
}) {
|
||||||
|
const percent = capacityPercent(current, capacity);
|
||||||
|
const color = percent >= 90 ? 'red' : percent >= 70 ? 'orange' : 'green';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap={7} c="dimmed" wrap="nowrap">
|
||||||
|
<Icon size={15} />
|
||||||
|
<Text size="xs" fw={700} tt="uppercase">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{formatCapacity(Number(current) || 0, capacity)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress value={percent} color={color} size="xs" radius="xl" bg="var(--mantine-color-gray-1)" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Stack, Text } from '@mantine/core';
|
import { ActionIcon, Badge, Stack, Table, Text, Tooltip } from '@mantine/core';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
import { Eye } from 'lucide-react';
|
||||||
|
|
||||||
import type { InventoryInquiryResult } from '@/types/warehouse';
|
import type { InventoryInquiryResult } from '@/types/warehouse';
|
||||||
import { InventoryStatusBadge } from './badges';
|
import { InventoryStatusBadge } from './badges';
|
||||||
@@ -7,71 +7,111 @@ import { formatDate, formatNumber } from './options';
|
|||||||
|
|
||||||
interface WarehouseInquiryTableProps {
|
interface WarehouseInquiryTableProps {
|
||||||
results: InventoryInquiryResult[];
|
results: InventoryInquiryResult[];
|
||||||
|
onView?: (result: InventoryInquiryResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dash = '-';
|
||||||
|
|
||||||
const itemDescriptor = (result: InventoryInquiryResult) => {
|
const itemDescriptor = (result: InventoryInquiryResult) => {
|
||||||
if (result.containerNumber) return `Container ${result.containerNumber}`;
|
if (result.containerNumber) return `Container ${result.containerNumber}`;
|
||||||
if (result.cargoType) return `Cargo · ${result.cargoType}`;
|
if (result.cargoType) return `Cargo - ${result.cargoType}`;
|
||||||
if (result.cargoDescription) return `Cargo · ${result.cargoDescription}`;
|
if (result.cargoDescription) return `Cargo - ${result.cargoDescription}`;
|
||||||
if (result.goodsId) return 'Goods';
|
if (result.goodsId) return 'Goods';
|
||||||
return '—';
|
return dash;
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<InventoryInquiryResult>[] = [
|
export function WarehouseInquiryTable({ results, onView }: WarehouseInquiryTableProps) {
|
||||||
{
|
if (results.length === 0) {
|
||||||
id: 'booking',
|
return (
|
||||||
header: 'Booking',
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
cell: ({ row }) => (
|
No matching items. Adjust your search to locate cargo, containers or goods.
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)}
|
|
||||||
</Text>
|
</Text>
|
||||||
),
|
);
|
||||||
},
|
}
|
||||||
{ id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customerName ?? '—' },
|
|
||||||
{ id: 'item', header: 'Item', cell: ({ row }) => itemDescriptor(row.original) },
|
|
||||||
{
|
|
||||||
id: 'warehouse',
|
|
||||||
header: 'Warehouse',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="sm">{row.original.warehouse?.name ?? '—'}</Text>
|
|
||||||
{row.original.warehouse?.code && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{row.original.warehouse.code}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.name ?? '—' },
|
|
||||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.name ?? '—' },
|
|
||||||
{
|
|
||||||
id: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
|
|
||||||
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
|
|
||||||
{
|
|
||||||
id: 'arrived',
|
|
||||||
header: 'Arrived',
|
|
||||||
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ready',
|
|
||||||
header: 'Ready',
|
|
||||||
cell: ({ row }) => <Text size="xs">{formatDate(row.original.readyForLoadingAt)}</Text>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
|
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<Table.ScrollContainer minWidth={1100}>
|
||||||
columns={columns}
|
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||||
data={results}
|
<Table.Thead>
|
||||||
status="success"
|
<Table.Tr>
|
||||||
emptyMessage="No matching items. Adjust your search to locate cargo, containers or goods."
|
<Table.Th>Booking</Table.Th>
|
||||||
containerClassName="border-0 shadow-none"
|
<Table.Th>Customer</Table.Th>
|
||||||
/>
|
<Table.Th>Item</Table.Th>
|
||||||
|
<Table.Th>Warehouse</Table.Th>
|
||||||
|
<Table.Th>Yard</Table.Th>
|
||||||
|
<Table.Th>Zone</Table.Th>
|
||||||
|
<Table.Th>Location</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>Weight</Table.Th>
|
||||||
|
<Table.Th>Arrived</Table.Th>
|
||||||
|
<Table.Th>Ready</Table.Th>
|
||||||
|
{onView ? <Table.Th ta="right">Actions</Table.Th> : null}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{results.map((result) => (
|
||||||
|
<Table.Tr key={result.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{result.bookingReference ?? result.bookingNumber ?? result.bookingId?.slice(0, 8) ?? dash}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{result.customerName ?? dash}</Table.Td>
|
||||||
|
<Table.Td>{itemDescriptor(result)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm">{result.warehouse?.name ?? dash}</Text>
|
||||||
|
{result.warehouse?.code ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{result.warehouse.code}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{result.yard?.name ?? dash}</Table.Td>
|
||||||
|
<Table.Td>{result.zone?.name ?? dash}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm">{result.locationSummary ?? dash}</Text>
|
||||||
|
{result.trainNumber ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{result.trainNumber}
|
||||||
|
{result.route ? ` - ${result.route}` : ''}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{result.status ? (
|
||||||
|
<InventoryStatusBadge status={result.status} />
|
||||||
|
) : (
|
||||||
|
<Badge variant="light" color={result.trainStatus === 'ARRIVED' ? 'orange' : 'blue'} size="sm">
|
||||||
|
{result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{formatNumber(result.quantity)}</Table.Td>
|
||||||
|
<Table.Td>{formatNumber(result.weight)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(result.arrivedAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(result.readyForLoadingAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{onView ? (
|
||||||
|
<Table.Td>
|
||||||
|
<Tooltip label="View details" withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={() => onView(result)} ml="auto">
|
||||||
|
<Eye size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Table.Td>
|
||||||
|
) : null}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||||
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
|
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||||
import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
INVENTORY_NEXT_ACTION,
|
getNextInventoryAction,
|
||||||
type InventoryAction,
|
type InventoryAction,
|
||||||
type WarehouseInventoryItem,
|
type WarehouseInventoryItem,
|
||||||
} from "@/types/warehouse";
|
} from '@/types/warehouse';
|
||||||
import { InventoryStatusBadge } from "./badges";
|
import { InventoryStatusBadge } from './badges';
|
||||||
import { formatDate, formatNumber, humanizeEnum } from "./options";
|
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||||
|
|
||||||
interface WarehouseInventoryTableProps {
|
interface WarehouseInventoryTableProps {
|
||||||
items: WarehouseInventoryItem[];
|
items: WarehouseInventoryItem[];
|
||||||
@@ -17,11 +15,11 @@ interface WarehouseInventoryTableProps {
|
|||||||
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
|
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
|
||||||
onMove: (item: WarehouseInventoryItem) => void;
|
onMove: (item: WarehouseInventoryItem) => void;
|
||||||
onHistory: (item: WarehouseInventoryItem) => void;
|
onHistory: (item: WarehouseInventoryItem) => void;
|
||||||
|
onView?: (item: WarehouseInventoryItem) => void;
|
||||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||||
// Optional Last Mile action — only rendered for items whose booking requested door delivery.
|
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
||||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||||
// Optional row selection (used for bulk Mark-as-Inspected).
|
|
||||||
selectedIds?: Set<string>;
|
selectedIds?: Set<string>;
|
||||||
onToggleSelect?: (id: string) => void;
|
onToggleSelect?: (id: string) => void;
|
||||||
onToggleSelectAll?: () => void;
|
onToggleSelectAll?: () => void;
|
||||||
@@ -30,21 +28,21 @@ interface WarehouseInventoryTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const itemKind = (item: WarehouseInventoryItem) => {
|
const itemKind = (item: WarehouseInventoryItem) => {
|
||||||
if (item.containerId) return { label: "Container", color: "blue" };
|
if (item.containerId) return { label: 'Container', color: 'blue' };
|
||||||
if (item.cargoId) return { label: "Cargo", color: "grape" };
|
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
|
||||||
if (item.goodsId) return { label: "Goods", color: "orange" };
|
if (item.goodsId) return { label: 'Goods', color: 'orange' };
|
||||||
return { label: "—", color: "gray" };
|
return { label: '-', color: 'gray' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionColor: Record<InventoryAction, string> = {
|
const actionColor: Record<InventoryAction, string> = {
|
||||||
store: "blue",
|
store: 'blue',
|
||||||
reserve: "grape",
|
reserve: 'grape',
|
||||||
"ready-for-loading": "cyan",
|
'ready-for-loading': 'cyan',
|
||||||
load: "teal",
|
load: 'teal',
|
||||||
dispatch: "edr-green",
|
dispatch: 'edr-green',
|
||||||
"ready-for-pickup": "orange",
|
'ready-for-pickup': 'orange',
|
||||||
release: "yellow",
|
release: 'yellow',
|
||||||
deliver: "green",
|
deliver: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WarehouseInventoryTable({
|
export function WarehouseInventoryTable({
|
||||||
@@ -53,165 +51,195 @@ export function WarehouseInventoryTable({
|
|||||||
onAdvance,
|
onAdvance,
|
||||||
onMove,
|
onMove,
|
||||||
onHistory,
|
onHistory,
|
||||||
|
onView,
|
||||||
onInspect,
|
onInspect,
|
||||||
onFeePreview,
|
onFeePreview,
|
||||||
|
onReleaseDocument,
|
||||||
|
onLastMile,
|
||||||
|
selectedIds,
|
||||||
|
onToggleSelect,
|
||||||
|
onToggleSelectAll,
|
||||||
|
allSelected,
|
||||||
|
someSelected,
|
||||||
}: WarehouseInventoryTableProps) {
|
}: WarehouseInventoryTableProps) {
|
||||||
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
|
const selectable = Boolean(onToggleSelect);
|
||||||
() => [
|
|
||||||
{
|
if (items.length === 0) {
|
||||||
id: "booking",
|
return (
|
||||||
header: "Booking",
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
cell: ({ row }) =>
|
No inventory items found.
|
||||||
row.original.bookingId ? (
|
</Text>
|
||||||
<Tooltip label={row.original.bookingId} withArrow>
|
);
|
||||||
<Text size="sm" fw={600}>
|
}
|
||||||
{row.original.bookingId.slice(0, 8)}…
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
—
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "facility",
|
|
||||||
header: "Facility",
|
|
||||||
cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "warehouse",
|
|
||||||
header: "Warehouse",
|
|
||||||
cell: ({ row }) => row.original.warehouse?.code ?? "—",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "yard",
|
|
||||||
header: "Yard",
|
|
||||||
cell: ({ row }) => row.original.yard?.code ?? "—",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "zone",
|
|
||||||
header: "Zone",
|
|
||||||
cell: ({ row }) => row.original.zone?.code ?? "—",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "item",
|
|
||||||
header: "Item",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const kind = itemKind(row.original);
|
|
||||||
return (
|
|
||||||
<Badge color={kind.color} variant="light" size="sm" radius="md">
|
|
||||||
{kind.label}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "qty",
|
|
||||||
header: "Qty",
|
|
||||||
cell: ({ row }) => formatNumber(row.original.quantity),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "weight",
|
|
||||||
header: "Weight",
|
|
||||||
cell: ({ row }) => formatNumber(row.original.weight),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<InventoryStatusBadge status={row.original.status} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "arrived",
|
|
||||||
header: "Arrived",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="xs">{formatDate(row.original.arrivedAt)}</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: "",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const item = row.original;
|
|
||||||
const busy = busyId === item.id;
|
|
||||||
const nextAction = INVENTORY_NEXT_ACTION[item.status];
|
|
||||||
return (
|
|
||||||
<Group
|
|
||||||
gap="xs"
|
|
||||||
justify="flex-end"
|
|
||||||
wrap="nowrap"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{nextAction && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color={actionColor[nextAction]}
|
|
||||||
loading={busy}
|
|
||||||
onClick={() => onAdvance(item, nextAction)}
|
|
||||||
>
|
|
||||||
{humanizeEnum(nextAction.replace(/-/g, "_"))}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{item.status !== "DISPATCHED" && (
|
|
||||||
<Tooltip label="Move" withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={() => onMove(item)}
|
|
||||||
>
|
|
||||||
<ArrowRightLeft size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{onInspect && (
|
|
||||||
<Tooltip label="Inspection / Report" withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="orange"
|
|
||||||
onClick={() => onInspect(item)}
|
|
||||||
>
|
|
||||||
<ClipboardList size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{onFeePreview && (
|
|
||||||
<Tooltip label="Storage / Demurrage preview" withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="teal"
|
|
||||||
onClick={() => onFeePreview(item)}
|
|
||||||
>
|
|
||||||
<Coins size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip label="History" withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={() => onHistory(item)}
|
|
||||||
>
|
|
||||||
<History size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[busyId, onAdvance, onMove, onHistory, onInspect, onFeePreview],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<Table.ScrollContainer minWidth={1150}>
|
||||||
columns={columns}
|
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||||
data={items}
|
<Table.Thead>
|
||||||
status="success"
|
<Table.Tr>
|
||||||
emptyMessage="No inventory items found."
|
{selectable && (
|
||||||
containerClassName="border-0 shadow-none"
|
<Table.Th w={40}>
|
||||||
/>
|
<Checkbox
|
||||||
|
aria-label="Select all"
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={someSelected}
|
||||||
|
onChange={onToggleSelectAll}
|
||||||
|
/>
|
||||||
|
</Table.Th>
|
||||||
|
)}
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Facility</Table.Th>
|
||||||
|
<Table.Th>Warehouse</Table.Th>
|
||||||
|
<Table.Th>Yard</Table.Th>
|
||||||
|
<Table.Th>Zone</Table.Th>
|
||||||
|
<Table.Th>Item</Table.Th>
|
||||||
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>Weight</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Arrived</Table.Th>
|
||||||
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{items.map((item) => {
|
||||||
|
const kind = itemKind(item);
|
||||||
|
const busy = busyId === item.id;
|
||||||
|
const nextAction = getNextInventoryAction(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Tr key={item.id}>
|
||||||
|
{selectable && (
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox
|
||||||
|
aria-label={`Select ${item.bookingId ?? item.id}`}
|
||||||
|
checked={selectedIds?.has(item.id) ?? false}
|
||||||
|
onChange={() => onToggleSelect?.(item.id)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
|
<Table.Td>
|
||||||
|
{item.bookingId ? (
|
||||||
|
<Tooltip label={item.bookingId} withArrow>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{item.bookingId.slice(0, 8)}...
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
-
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
|
||||||
|
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
|
||||||
|
<Table.Td>{item.yard?.code ?? '-'}</Table.Td>
|
||||||
|
<Table.Td>{item.zone?.code ?? '-'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={kind.color} variant="light" size="sm" radius="md">
|
||||||
|
{kind.label}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
|
||||||
|
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<InventoryStatusBadge status={item.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||||
|
{onView && (
|
||||||
|
<Tooltip label="View details" withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={() => onView(item)}>
|
||||||
|
<Eye size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{nextAction && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color={actionColor[nextAction]}
|
||||||
|
loading={busy}
|
||||||
|
onClick={() => onAdvance(item, nextAction)}
|
||||||
|
>
|
||||||
|
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{item.status === 'READY_FOR_PICKUP' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
loading={busy}
|
||||||
|
onClick={() => onAdvance(item, 'store')}
|
||||||
|
>
|
||||||
|
Store
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
loading={busy}
|
||||||
|
onClick={() => onAdvance(item, 'dispatch')}
|
||||||
|
>
|
||||||
|
Dispatch
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.status !== 'DISPATCHED' && (
|
||||||
|
<Tooltip label="Move" withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
|
||||||
|
<ArrowRightLeft size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{onInspect && (
|
||||||
|
<Tooltip label="Inspection / Report" withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
|
||||||
|
<ClipboardList size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{onFeePreview && (
|
||||||
|
<Tooltip label="Storage / Demurrage preview" withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
|
||||||
|
<Coins size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{onReleaseDocument && item.releaseDate && (
|
||||||
|
<Tooltip label="View release exit paper" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="orange"
|
||||||
|
onClick={() => onReleaseDocument(item)}
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
||||||
|
<Tooltip label="Last mile delivery" withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
||||||
|
<MapPin size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="History" withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||||
|
<History size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { ActionIcon, Group, Text } from '@mantine/core';
|
import { ActionIcon, Group, Text } from '@mantine/core';
|
||||||
import { Eye, Pencil } from 'lucide-react';
|
import {
|
||||||
|
Building2,
|
||||||
|
Eye,
|
||||||
|
MapPin,
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Scale,
|
||||||
|
Warehouse as WarehouseIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { bookingTable } from '@/components/bookings/booking-ui.styles';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import type { Warehouse } from '@/types/warehouse';
|
import type { Warehouse } from '@/types/warehouse';
|
||||||
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||||
@@ -16,6 +26,43 @@ interface WarehouseTableProps {
|
|||||||
onEdit: (warehouse: Warehouse) => void;
|
onEdit: (warehouse: Warehouse) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HEADER = bookingTable.headerCell;
|
||||||
|
|
||||||
|
function CapacityCell({
|
||||||
|
current,
|
||||||
|
capacity,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
current?: number | null;
|
||||||
|
capacity?: number | null;
|
||||||
|
icon: ReactNode;
|
||||||
|
}) {
|
||||||
|
const numericCurrent = Number(current) || 0;
|
||||||
|
const numericCapacity = Number(capacity) || 0;
|
||||||
|
const hasCapacity = numericCapacity > 0;
|
||||||
|
const ratio = hasCapacity ? Math.min(100, Math.max(0, (numericCurrent / numericCapacity) * 100)) : 0;
|
||||||
|
const isOverCapacity = hasCapacity && numericCurrent > numericCapacity;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-[8rem] space-y-2 py-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg border border-border/50 bg-background/70 text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap">{formatCapacity(numericCurrent, capacity)}</span>
|
||||||
|
</div>
|
||||||
|
{hasCapacity ? (
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted/50">
|
||||||
|
<div
|
||||||
|
className={isOverCapacity ? 'h-full rounded-full bg-red-500' : 'h-full rounded-full bg-edr-green'}
|
||||||
|
style={{ width: `${ratio}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
|
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
|
||||||
const { data: stations } = useQuery(
|
const { data: stations } = useQuery(
|
||||||
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
||||||
@@ -28,63 +75,98 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
|||||||
const columns: ColumnDef<Warehouse>[] = [
|
const columns: ColumnDef<Warehouse>[] = [
|
||||||
{
|
{
|
||||||
id: 'code',
|
id: 'code',
|
||||||
header: 'Code',
|
header: () => <span className={HEADER}>Warehouse</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text
|
<div className="flex min-w-[11rem] items-center gap-3 py-1.5">
|
||||||
fw={600}
|
<div className={bookingTable.rowIcon}>
|
||||||
size="sm"
|
<WarehouseIcon className="size-4" strokeWidth={1.75} />
|
||||||
c="edr-green.7"
|
</div>
|
||||||
style={{ cursor: 'pointer' }}
|
<div className="min-w-0">
|
||||||
onClick={() => onView(row.original)}
|
<button
|
||||||
>
|
type="button"
|
||||||
{row.original.code}
|
className="block max-w-full truncate text-left text-sm font-semibold text-edr-green transition-colors hover:text-edr-green/80"
|
||||||
</Text>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onView(row.original);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.original.code}
|
||||||
|
</button>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
{row.original.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
|
||||||
{
|
{
|
||||||
id: 'facility',
|
id: 'facility',
|
||||||
header: 'Facility',
|
header: () => <span className={HEADER}>Facility</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const name = row.original.stationId
|
const name = row.original.stationId
|
||||||
? stationNameById.get(row.original.stationId)
|
? stationNameById.get(row.original.stationId)
|
||||||
: undefined;
|
: undefined;
|
||||||
return name ? (
|
return name ? (
|
||||||
<Text size="sm" fw={500}>
|
<div className="flex min-w-[9rem] items-center gap-2 py-1 text-sm font-medium text-foreground">
|
||||||
{name}
|
<Building2 className="size-4 shrink-0 text-muted-foreground" />
|
||||||
</Text>
|
<span className="truncate">{name}</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
—
|
-
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'type',
|
id: 'type',
|
||||||
header: 'Type',
|
header: () => <span className={HEADER}>Type</span>,
|
||||||
cell: ({ row }) => <WarehouseTypeBadge type={row.original.type} />,
|
cell: ({ row }) => (
|
||||||
|
<div className="py-1">
|
||||||
|
<WarehouseTypeBadge type={row.original.type} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'location',
|
id: 'location',
|
||||||
header: 'Location',
|
header: () => <span className={HEADER}>Location</span>,
|
||||||
cell: ({ row }) => row.original.locationName ?? '—',
|
cell: ({ row }) => (
|
||||||
|
<div className="flex min-w-[10rem] items-center gap-2 py-1 text-sm text-foreground">
|
||||||
|
<MapPin className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{row.original.locationName ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'weight',
|
id: 'weight',
|
||||||
header: 'Weight (cur / cap)',
|
header: () => <span className={HEADER}>Weight</span>,
|
||||||
cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight),
|
cell: ({ row }) => (
|
||||||
|
<CapacityCell
|
||||||
|
current={row.original.currentWeight}
|
||||||
|
capacity={row.original.capacityWeight}
|
||||||
|
icon={<Scale className="size-3.5" />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'containers',
|
id: 'containers',
|
||||||
header: 'Containers (cur / cap)',
|
header: () => <span className={HEADER}>Containers</span>,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => (
|
||||||
formatCapacity(row.original.currentContainers, row.original.capacityContainers),
|
<CapacityCell
|
||||||
|
current={row.original.currentContainers}
|
||||||
|
capacity={row.original.capacityContainers}
|
||||||
|
icon={<Package className="size-3.5" />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
header: 'Status',
|
header: () => <span className={HEADER}>Status</span>,
|
||||||
cell: ({ row }) => <WarehouseStatusBadge status={row.original.status} />,
|
cell: ({ row }) => (
|
||||||
|
<div className="py-1">
|
||||||
|
<WarehouseStatusBadge status={row.original.status} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -109,7 +191,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
|||||||
status="success"
|
status="success"
|
||||||
onRowClick={(warehouse) => onView(warehouse)}
|
onRowClick={(warehouse) => onView(warehouse)}
|
||||||
emptyMessage="No warehouses found."
|
emptyMessage="No warehouses found."
|
||||||
containerClassName="border-0 shadow-none"
|
containerClassName="border-0 bg-transparent shadow-none"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ const inventoryStatusColor: Record<InventoryStatus, string> = {
|
|||||||
RECEIVED: "yellow",
|
RECEIVED: "yellow",
|
||||||
STORED: "blue",
|
STORED: "blue",
|
||||||
RESERVED: "grape",
|
RESERVED: "grape",
|
||||||
|
ARRIVED_AT_WAREHOUSE: "orange",
|
||||||
|
UNDER_INSPECTION: "yellow",
|
||||||
READY_FOR_LOADING: "cyan",
|
READY_FOR_LOADING: "cyan",
|
||||||
LOADED: "teal",
|
LOADED: "teal",
|
||||||
READY_FOR_PICKUP: "teal",
|
READY_FOR_PICKUP: "teal",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export { ReserveInventoryModal } from './ReserveInventoryModal';
|
|||||||
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
|
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
|
||||||
export { ActivityTimeline } from './ActivityTimeline';
|
export { ActivityTimeline } from './ActivityTimeline';
|
||||||
export { InventoryHistoryModal } from './InventoryHistoryModal';
|
export { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||||
|
export { InventoryDetailModal } from './InventoryDetailModal';
|
||||||
|
export { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||||
export { InventoryWorkbench } from './InventoryWorkbench';
|
export { InventoryWorkbench } from './InventoryWorkbench';
|
||||||
export { BookingSelect } from './BookingSelect';
|
export { BookingSelect } from './BookingSelect';
|
||||||
export { WagonSelect } from './WagonSelect';
|
export { WagonSelect } from './WagonSelect';
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window | null) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
if (targetWindow && !targetWindow.closed) {
|
||||||
|
targetWindow.location.href = url;
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opened = window.open(url, '_blank');
|
||||||
|
if (opened) {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -276,38 +276,43 @@ export const URL_CONSTANTS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_YARDS: {
|
WAREHOUSE_YARDS: {
|
||||||
|
BASE: '/warehouse-yards',
|
||||||
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
||||||
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_ZONES: {
|
WAREHOUSE_ZONES: {
|
||||||
|
BASE: '/warehouse-zones',
|
||||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INVENTORY: {
|
WAREHOUSE_INVENTORY: {
|
||||||
BASE: '/warehouse-inventory',
|
BASE: '/warehouse-inventory',
|
||||||
RECEIVE: '/warehouse-inventory/receive',
|
RECEIVE: '/warehouse-inventory/receive',
|
||||||
|
DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary',
|
||||||
|
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
||||||
|
INQUIRY: '/warehouse-inventory/inquiry',
|
||||||
|
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
||||||
|
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
|
||||||
|
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||||
|
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||||
|
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||||
|
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||||
RESERVE: '/warehouse-inventory/reserve',
|
RESERVE: '/warehouse-inventory/reserve',
|
||||||
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
|
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
|
||||||
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
|
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
|
||||||
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
|
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
|
||||||
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
|
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
|
||||||
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
|
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
|
||||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
|
||||||
INQUIRY: '/warehouse-inventory/inquiry',
|
|
||||||
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
||||||
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
|
||||||
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
||||||
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
||||||
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
||||||
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
|
||||||
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
|
||||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
|
||||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
|
||||||
// Import branch
|
// Import branch
|
||||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||||
|
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
||||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||||
// Receive (Import/Export bulk)
|
// Receive (Import/Export bulk)
|
||||||
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const HealthCheck = () => {
|
||||||
|
const url1 = import.meta.env.VITE_API_URL?? "undefined";
|
||||||
|
const url2 = import.meta.env.VITE_BASE_API_URL?? "undefined";
|
||||||
|
const url3 = import.meta.env.VITE_USER_MANAGEMENT_BASE?? "undefined";
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
<h2>-----------{url1}</h2>
|
||||||
|
<h2>-----------{url2}</h2>
|
||||||
|
<h2>-----------{url3}</h2>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
538
apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
Normal file
538
apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
|
import type {
|
||||||
|
InspectionReportPayload,
|
||||||
|
SaveAllocationRulePayload,
|
||||||
|
SaveFeeRulePayload,
|
||||||
|
WarehouseInvoiceFilter,
|
||||||
|
PayInvoicePayload,
|
||||||
|
InventoryFilter,
|
||||||
|
InventoryInquiryFilter,
|
||||||
|
LoadInventoryPayload,
|
||||||
|
MoveInventoryPayload,
|
||||||
|
ReceiveInventoryPayload,
|
||||||
|
ReleaseOrderPayload,
|
||||||
|
DeliverInventoryPayload,
|
||||||
|
BulkReceivePayload,
|
||||||
|
BulkInspectPayload,
|
||||||
|
ReserveInventoryPayload,
|
||||||
|
SaveWarehousePayload,
|
||||||
|
SaveYardPayload,
|
||||||
|
SaveZonePayload,
|
||||||
|
WarehouseFilter,
|
||||||
|
} from '@/types/warehouse';
|
||||||
|
|
||||||
|
export const warehouseKeys = {
|
||||||
|
all: ['warehouses'] as const,
|
||||||
|
list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const,
|
||||||
|
facilities: () => ['warehouses', 'facilities'] as const,
|
||||||
|
detail: (id: string) => ['warehouses', 'detail', id] as const,
|
||||||
|
yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const,
|
||||||
|
allYards: () => ['warehouse-yards', 'all'] as const,
|
||||||
|
zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const,
|
||||||
|
allZones: () => ['warehouse-zones', 'all'] as const,
|
||||||
|
inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const,
|
||||||
|
dashboardSummary: (filter?: InventoryFilter) => ['warehouse-dashboard', 'summary', filter ?? {}] as const,
|
||||||
|
inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Warehouses ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWarehouses(filter?: WarehouseFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.list(filter),
|
||||||
|
queryFn: () => warehouseService.list(filter).then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWarehouse(id?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.detail(id ?? ''),
|
||||||
|
queryFn: () => warehouseService.getById(id as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWarehouseFacilities() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.facilities(),
|
||||||
|
queryFn: () => warehouseService.listFacilities().then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateWarehouse() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SaveWarehousePayload) => warehouseService.create(payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateWarehouse() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveWarehousePayload> }) =>
|
||||||
|
warehouseService.update(id, payload),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.detail(id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Yards ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWarehouseYards(warehouseId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.yards(warehouseId ?? ''),
|
||||||
|
queryFn: () => warehouseService.listYards(warehouseId as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(warehouseId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAllWarehouseYards() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.allYards(),
|
||||||
|
queryFn: () => warehouseService.listAllYards().then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateYard() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ warehouseId, payload }: { warehouseId: string; payload: SaveYardPayload }) =>
|
||||||
|
warehouseService.createYard(warehouseId, payload),
|
||||||
|
onSuccess: (_, { warehouseId }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.yards(warehouseId) });
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.detail(warehouseId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateYard() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveYardPayload> }) =>
|
||||||
|
warehouseService.updateYard(id, payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zones ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWarehouseZones(yardId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.zones(yardId ?? ''),
|
||||||
|
queryFn: () => warehouseService.listZones(yardId as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(yardId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAllWarehouseZones() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.allZones(),
|
||||||
|
queryFn: () => warehouseService.listAllZones().then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateZone() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ yardId, payload }: { yardId: string; payload: SaveZonePayload }) =>
|
||||||
|
warehouseService.createZone(yardId, payload),
|
||||||
|
onSuccess: (_, { yardId }) => qc.invalidateQueries({ queryKey: warehouseKeys.zones(yardId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateZone() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveZonePayload> }) =>
|
||||||
|
warehouseService.updateZone(id, payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-yards'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inventory ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWarehouseInventory(filter?: InventoryFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.inventory(filter),
|
||||||
|
queryFn: () => warehouseService.listInventory(filter).then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWarehouseDashboardSummary(filter?: InventoryFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.dashboardSummary(filter),
|
||||||
|
queryFn: () => warehouseService.getDashboardSummary(filter).then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReceiveInventory() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: ReceiveInventoryPayload) => warehouseService.receiveInventory(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInventoryMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: fn,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStoreInventory = () => useInventoryMutation((id: string) => warehouseService.store(id));
|
||||||
|
export const useReserveInventory = () =>
|
||||||
|
useInventoryMutation((payload: ReserveInventoryPayload) => warehouseService.reserve(payload));
|
||||||
|
export const useMarkReadyForLoading = () =>
|
||||||
|
useInventoryMutation((id: string) => warehouseService.markReadyForLoading(id));
|
||||||
|
export const useLoadInventory = () =>
|
||||||
|
useInventoryMutation((args: { id: string; payload: LoadInventoryPayload }) =>
|
||||||
|
warehouseService.load(args.id, args.payload),
|
||||||
|
);
|
||||||
|
export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id));
|
||||||
|
export const useMoveInventory = () =>
|
||||||
|
useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) =>
|
||||||
|
warehouseService.move(args.id, args.payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Import branch (READY_FOR_PICKUP → DELIVERED) ───────────────────────────
|
||||||
|
export const useMarkReadyForPickup = () =>
|
||||||
|
useInventoryMutation((id: string) => warehouseService.markReadyForPickup(id));
|
||||||
|
export const useReleaseInventory = () =>
|
||||||
|
useInventoryMutation((args: { id: string; payload: ReleaseOrderPayload }) =>
|
||||||
|
warehouseService.release(args.id, args.payload),
|
||||||
|
);
|
||||||
|
export const useDeliverInventory = () =>
|
||||||
|
useInventoryMutation((args: { id: string; payload: DeliverInventoryPayload }) =>
|
||||||
|
warehouseService.deliver(args.id, args.payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Receive (Import/Export bulk) ───────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* All not-yet-received PAID bookings, classified IMPORT/EXPORT by route, in one call.
|
||||||
|
* Both Receive tabs share this single query (same key) — only one HTTP request fires —
|
||||||
|
* then filter client-side by direction.
|
||||||
|
*/
|
||||||
|
export function useEligibleBookings(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'eligible-bookings'],
|
||||||
|
queryFn: () => warehouseService.eligibleBookings().then((r) => r.data),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export const useBulkReceive = () =>
|
||||||
|
useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload));
|
||||||
|
export const useLoadPassedExport = () =>
|
||||||
|
useInventoryMutation(() => warehouseService.loadPassedExport());
|
||||||
|
export const useBulkMarkInspected = () =>
|
||||||
|
useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload));
|
||||||
|
|
||||||
|
export function useReadyToLoadExport(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'ready-to-load-export'],
|
||||||
|
queryFn: () => warehouseService.readyToLoadExport().then((r) => r.data),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoadedExport(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'loaded-export'],
|
||||||
|
queryFn: () => warehouseService.loadedExport().then((r) => r.data),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBulkDispatchExport = () =>
|
||||||
|
useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds));
|
||||||
|
|
||||||
|
/** Arrived IMPORT trains (route-derived). Read-only. */
|
||||||
|
export function useImportArriveQueue(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'import-arrive-queue'],
|
||||||
|
queryFn: () => warehouseService.importArriveQueue().then((r) => r.data),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assigned bookings/items for an arrived import train. Read-only. */
|
||||||
|
export function useImportTrainItems(scheduleId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'import-train-items', scheduleId],
|
||||||
|
queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(scheduleId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */
|
||||||
|
export const useAutoUnloadArrivedBookings = () =>
|
||||||
|
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
|
||||||
|
|
||||||
|
/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */
|
||||||
|
export function useImportUnloadedQueue(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'import-unloaded-queue'],
|
||||||
|
queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */
|
||||||
|
export function useImportPickupReadyQueue(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'],
|
||||||
|
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useLoadableWagons(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse', 'loadable-wagons'],
|
||||||
|
queryFn: () => warehouseService.loadableWagons().then((r) => r.data),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWarehouseLoadings(params?: { bookingId?: string; wagonId?: string }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-loadings', params ?? {}],
|
||||||
|
queryFn: () => warehouseService.loadings(params).then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBookingSchedule(bookingId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse', 'booking-schedule', bookingId ?? ''],
|
||||||
|
queryFn: () => warehouseService.bookingSchedule(bookingId as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(bookingId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInventoryMovements(id?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', id, 'movements'],
|
||||||
|
queryFn: () => warehouseService.movements(id as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInventoryActivity(id?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', id, 'activity'],
|
||||||
|
queryFn: () => warehouseService.activity(id as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWarehouseDashboard() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouses', 'dashboard'],
|
||||||
|
queryFn: () => warehouseService.dashboard().then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.inquiry(filter),
|
||||||
|
queryFn: () => warehouseService.inquiry(filter).then((r) => r.data),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
|
||||||
|
|
||||||
|
export function useArrivalQueue() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'arrival-queue'],
|
||||||
|
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useArrivalInvalidation() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoUnloadArrived() {
|
||||||
|
const onSuccess = useArrivalInvalidation();
|
||||||
|
return useMutation({ mutationFn: () => warehouseService.autoUnloadArrived(), onSuccess });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoLoadReady() {
|
||||||
|
const onSuccess = useArrivalInvalidation();
|
||||||
|
return useMutation({ mutationFn: () => warehouseService.autoLoadReady(), onSuccess });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnloadBooking() {
|
||||||
|
const onSuccess = useArrivalInvalidation();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (args: { bookingId: string; payload?: Record<string, unknown> }) =>
|
||||||
|
warehouseService.unloadBooking(args.bookingId, args.payload),
|
||||||
|
onSuccess,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInspectionReports(inventoryId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'],
|
||||||
|
queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(inventoryId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateInspectionReport() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) =>
|
||||||
|
warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data),
|
||||||
|
onSuccess: (_, { inventoryId }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadInspectionAttachments() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) =>
|
||||||
|
warehouseService.uploadInspectionAttachments(reportId, files),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
|
||||||
|
|
||||||
|
export function useAllocationRules() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-allocation-rules'],
|
||||||
|
queryFn: () => warehouseService.listAllocationRules().then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFeeRules() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-fee-rules'],
|
||||||
|
queryFn: () => warehouseService.listFeeRules().then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRuleMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>, keys: string[]) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: fn,
|
||||||
|
onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreateAllocationRule = () =>
|
||||||
|
useRuleMutation(
|
||||||
|
(payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload),
|
||||||
|
['warehouse-allocation-rules'],
|
||||||
|
);
|
||||||
|
export const useUpdateAllocationRule = () =>
|
||||||
|
useRuleMutation(
|
||||||
|
(args: { id: string; payload: Partial<SaveAllocationRulePayload> }) =>
|
||||||
|
warehouseService.updateAllocationRule(args.id, args.payload),
|
||||||
|
['warehouse-allocation-rules'],
|
||||||
|
);
|
||||||
|
export const useDeleteAllocationRule = () =>
|
||||||
|
useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']);
|
||||||
|
|
||||||
|
export const useCreateFeeRule = () =>
|
||||||
|
useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']);
|
||||||
|
export const useUpdateFeeRule = () =>
|
||||||
|
useRuleMutation(
|
||||||
|
(args: { id: string; payload: Partial<SaveFeeRulePayload> }) =>
|
||||||
|
warehouseService.updateFeeRule(args.id, args.payload),
|
||||||
|
['warehouse-fee-rules'],
|
||||||
|
);
|
||||||
|
export const useDeleteFeeRule = () =>
|
||||||
|
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']);
|
||||||
|
|
||||||
|
export function useFeePreview(inventoryId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'],
|
||||||
|
queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(inventoryId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch 6: Warehouse fee invoices ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWarehouseInvoices(filter?: WarehouseInvoiceFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-fee-invoices', filter ?? {}],
|
||||||
|
queryFn: () => warehouseService.listInvoices(filter).then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWarehouseInvoice(id?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-fee-invoices', 'detail', id],
|
||||||
|
queryFn: () => warehouseService.getInvoice(id as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInvoicesForInventory(inventoryId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', inventoryId, 'fee-invoices'],
|
||||||
|
queryFn: () => warehouseService.invoicesForInventory(inventoryId as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(inventoryId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvoiceInvalidation() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['warehouse-fee-invoices'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateInvoice() {
|
||||||
|
const onSuccess = useInvoiceInvalidation();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) =>
|
||||||
|
warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data),
|
||||||
|
onSuccess,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCancelInvoice() {
|
||||||
|
const onSuccess = useInvoiceInvalidation();
|
||||||
|
return useMutation({ mutationFn: (id: string) => warehouseService.cancelInvoice(id), onSuccess });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePayInvoice() {
|
||||||
|
const onSuccess = useInvoiceInvalidation();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: string; payload: PayInvoicePayload }) =>
|
||||||
|
warehouseService.payInvoice(id, payload),
|
||||||
|
onSuccess,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGateClearance() {
|
||||||
|
const onSuccess = useInvoiceInvalidation();
|
||||||
|
return useMutation({ mutationFn: (inventoryId: string) => warehouseService.gateClearance(inventoryId), onSuccess });
|
||||||
|
}
|
||||||
@@ -103,9 +103,8 @@ export default function BookingRequestsPage() {
|
|||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 100,
|
pageSize: 100,
|
||||||
statuses: "PAID",
|
statuses: "PAID",
|
||||||
schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE",
|
|
||||||
assignedToSchedule: "false",
|
assignedToSchedule: "false",
|
||||||
sortBy: "isGovernment",
|
sortBy: "createdAt",
|
||||||
sortOrder: "DESC",
|
sortOrder: "DESC",
|
||||||
tab: activeTab,
|
tab: activeTab,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -32,7 +35,7 @@ import {
|
|||||||
} from "@/components/customers";
|
} from "@/components/customers";
|
||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Company } from "@/types/customer";
|
import type { Company, CompanyStatus } from "@/types/customer";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableFooter,
|
DataTableFooter,
|
||||||
@@ -45,14 +48,17 @@ export default function CustomersPage() {
|
|||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||||
|
// "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
|
||||||
|
|
||||||
const filter = useMemo(
|
const filter = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
search: debouncedQuery,
|
search: debouncedQuery,
|
||||||
|
status: statusFilter || undefined,
|
||||||
}),
|
}),
|
||||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery],
|
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
|
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
|
||||||
@@ -107,7 +113,25 @@ export default function CustomersPage() {
|
|||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
cell: ({ row }) => <CompanyStatusBadge status={row.original.status} />,
|
cell: ({ row }) => {
|
||||||
|
const pending = (row.original.companyProfiles ?? []).filter(
|
||||||
|
(p) => p.status === "pending",
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<CompanyStatusBadge status={row.original.status} />
|
||||||
|
{pending > 0 ? (
|
||||||
|
<Tooltip
|
||||||
|
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
|
||||||
|
>
|
||||||
|
<Badge color="yellow" variant="light" size="sm" radius="sm">
|
||||||
|
{pending} pending
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "contact",
|
id: "contact",
|
||||||
@@ -216,6 +240,20 @@ export default function CustomersPage() {
|
|||||||
style={{ flex: 1, minWidth: "240px" }}
|
style={{ flex: 1, minWidth: "240px" }}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
/>
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
value={statusFilter || "all"}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{ label: "All", value: "all" },
|
||||||
|
{ label: "Pending approval", value: "pending" },
|
||||||
|
{ label: "Active", value: "active" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ const FleetResourcePage = () => {
|
|||||||
{config.subtitle}
|
{config.subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button leftSection={<Plus size={16} />} onClick={() => {
|
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}}>
|
}}>
|
||||||
@@ -391,7 +391,7 @@ const FleetResourcePage = () => {
|
|||||||
size="xs"
|
size="xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
variant={filter.value === option.value ? "filled" : "outline"}
|
variant={filter.value === option.value ? "filled" : "outline"}
|
||||||
color="green"
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setListFilterValues((prev) => ({
|
setListFilterValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -417,7 +417,7 @@ const FleetResourcePage = () => {
|
|||||||
size="xs"
|
size="xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
variant={statusFilter === option.value ? "filled" : "outline"}
|
variant={statusFilter === option.value ? "filled" : "outline"}
|
||||||
color="green"
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
onClick={() => setStatusFilter(option.value)}
|
onClick={() => setStatusFilter(option.value)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
],
|
],
|
||||||
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
|
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
|
||||||
columns: [
|
columns: [
|
||||||
|
{ id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 },
|
||||||
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 },
|
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 },
|
||||||
|
{ id: "powerPlateNo", header: "Power Plate No", accessorKey: "powerPlateNo", format: "code", size: 140 },
|
||||||
|
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", format: "code", size: 140 },
|
||||||
{ id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", format: "code", size: 140 },
|
{ id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", format: "code", size: 140 },
|
||||||
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 },
|
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 },
|
||||||
{ id: "model", header: "Model", accessorKey: "model", format: "code", size: 120 },
|
{ id: "model", header: "Model", accessorKey: "model", format: "code", size: 120 },
|
||||||
@@ -59,7 +62,10 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ name: "plateNumber", label: "Plate Number", type: "text", required: true },
|
{ name: "code", label: "Code", type: "text" },
|
||||||
|
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true },
|
||||||
|
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
|
||||||
|
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text" },
|
||||||
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
|
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
|
||||||
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
|
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
|
||||||
{ name: "model", label: "Model", type: "text", required: true },
|
{ name: "model", label: "Model", type: "text", required: true },
|
||||||
@@ -70,7 +76,10 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
{ name: "description", label: "Description", type: "textarea" },
|
{ name: "description", label: "Description", type: "textarea" },
|
||||||
],
|
],
|
||||||
emptyValues: {
|
emptyValues: {
|
||||||
|
code: "",
|
||||||
plateNumber: "",
|
plateNumber: "",
|
||||||
|
powerPlateNo: "",
|
||||||
|
trailerPlateNo: "",
|
||||||
vehicleType: "TRUCK",
|
vehicleType: "TRUCK",
|
||||||
manufacturer: "",
|
manufacturer: "",
|
||||||
model: "",
|
model: "",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type ReactNode, useMemo, useState } from "react";
|
import { type ReactNode, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
ChevronRight,
|
||||||
Eye,
|
Eye,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Printer,
|
Printer,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
@@ -73,7 +75,11 @@ const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
|||||||
const vehicleLabel = (record: FirstMileRecord) => {
|
const vehicleLabel = (record: FirstMileRecord) => {
|
||||||
if (!record.vehicle) return null;
|
if (!record.vehicle) return null;
|
||||||
const v = record.vehicle;
|
const v = record.vehicle;
|
||||||
return `${v.manufacturer} ${v.model} (${v.plateNumber})`;
|
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
|
||||||
|
if (v.code) parts.unshift(v.code);
|
||||||
|
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
|
||||||
|
if (plates) parts.push(plates);
|
||||||
|
return parts.join(" · ");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId);
|
const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId);
|
||||||
@@ -89,8 +95,9 @@ const cargoDesc = (r: FirstMileRecord) => {
|
|||||||
};
|
};
|
||||||
const priceAmount = (r: FirstMileRecord) =>
|
const priceAmount = (r: FirstMileRecord) =>
|
||||||
r.booking?.totalAmount ?? r.advancedPayment;
|
r.booking?.totalAmount ?? r.advancedPayment;
|
||||||
|
// First-mile destination is the origin yard (pickup → origin yard)
|
||||||
const destinationYardName = (r: FirstMileRecord) =>
|
const destinationYardName = (r: FirstMileRecord) =>
|
||||||
r.booking?.destinationYard?.name ?? "—";
|
r.booking?.originYard?.label ?? "—";
|
||||||
const contactPersonName = (r: FirstMileRecord) =>
|
const contactPersonName = (r: FirstMileRecord) =>
|
||||||
r.booking?.company?.contactPersonName ?? "—";
|
r.booking?.company?.contactPersonName ?? "—";
|
||||||
const contactPhone = (r: FirstMileRecord) =>
|
const contactPhone = (r: FirstMileRecord) =>
|
||||||
@@ -100,7 +107,7 @@ const requestedDate = (r: FirstMileRecord) => {
|
|||||||
return d ? new Date(d).toISOString().slice(0, 10) : "—";
|
return d ? new Date(d).toISOString().slice(0, 10) : "—";
|
||||||
};
|
};
|
||||||
const serviceTypeName = (r: FirstMileRecord) =>
|
const serviceTypeName = (r: FirstMileRecord) =>
|
||||||
r.booking?.serviceType?.name ?? "—";
|
r.booking?.serviceType?.label ?? "—";
|
||||||
|
|
||||||
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
@@ -133,7 +140,7 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => (
|
|||||||
<InfoRow label="Customer" value={customerName(record)} />
|
<InfoRow label="Customer" value={customerName(record)} />
|
||||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||||
<InfoRow label="Pickup location" value={pickupLocation(record)} />
|
<InfoRow label="Pickup location" value={pickupLocation(record)} />
|
||||||
<InfoRow label="Destination yard" value={destinationYardName(record)} />
|
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||||
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
||||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||||
@@ -343,10 +350,13 @@ const FirstMilePage = () => {
|
|||||||
|
|
||||||
const vehicleOptions = useMemo(
|
const vehicleOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({
|
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
|
||||||
value: v.id,
|
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
|
||||||
label: `${v.manufacturer} ${v.model} (${v.plateNumber})`,
|
if (v.code) parts.unshift(v.code);
|
||||||
})),
|
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
|
||||||
|
if (plates) parts.push(plates);
|
||||||
|
return { value: v.id, label: parts.join(" · ") };
|
||||||
|
}),
|
||||||
[vehiclesData],
|
[vehiclesData],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -585,6 +595,12 @@ const FirstMilePage = () => {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => pickupLocation(row.original),
|
cell: ({ row }) => pickupLocation(row.original),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "destination",
|
||||||
|
header: "Destination",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => destinationYardName(row.original),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "cargo",
|
id: "cargo",
|
||||||
header: "Cargo",
|
header: "Cargo",
|
||||||
@@ -701,11 +717,11 @@ const FirstMilePage = () => {
|
|||||||
/>
|
/>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign}>
|
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign} styles={{ label: { fontWeight: 500 } }}>
|
||||||
Assign vehicle ({selectedIds.length})
|
Assign vehicle ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button leftSection={<Truck size={16} />} onClick={openAccept}>
|
<Button leftSection={<Truck size={16} />} onClick={openAccept} styles={{ label: { fontWeight: 500 } }}>
|
||||||
Assign Mile
|
Assign Mile
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -718,6 +734,7 @@ const FirstMilePage = () => {
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
size="xs"
|
size="xs"
|
||||||
variant={active ? "filled" : "default"}
|
variant={active ? "filled" : "default"}
|
||||||
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatusFilter(option.value);
|
setStatusFilter(option.value);
|
||||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
@@ -865,33 +882,54 @@ const FirstMilePage = () => {
|
|||||||
<Text c="dimmed" size="sm" ta="center" py="md">No paid bookings found.</Text>
|
<Text c="dimmed" size="sm" ta="center" py="md">No paid bookings found.</Text>
|
||||||
) : (
|
) : (
|
||||||
filteredPaidBookings.map((b) => (
|
filteredPaidBookings.map((b) => (
|
||||||
<Card
|
<UnstyledButton
|
||||||
key={b.id}
|
key={b.id}
|
||||||
withBorder
|
w="100%"
|
||||||
padding="sm"
|
|
||||||
radius="md"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedBooking(b);
|
setSelectedBooking(b);
|
||||||
setAcceptStep(2);
|
setAcceptStep(2);
|
||||||
}}
|
}}
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
padding: "10px 12px",
|
||||||
|
backgroundColor: "var(--mantine-color-white)",
|
||||||
|
transition: "background-color 120ms ease, border-color 120ms ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
|
||||||
|
"var(--mantine-color-blue-0)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||||
|
"var(--mantine-color-blue-4)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
|
||||||
|
"var(--mantine-color-white)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||||
|
"var(--mantine-color-gray-3)";
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
<Stack gap={2}>
|
<Stack gap={3} style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text fw={600} size="sm">{b.reference}</Text>
|
<Text fw={700} size="sm" c="dark">{b.reference}</Text>
|
||||||
<Text size="xs" c="dimmed">{b.company?.name ?? b.company?.companyName ?? "—"}</Text>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
</Stack>
|
{b.company?.name ?? b.company?.companyName ?? "—"}
|
||||||
<Stack gap={2} align="flex-end">
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
</Stack>
|
||||||
{b.originYard?.name ?? "—"} → {b.destinationYard?.name ?? "—"}
|
<Stack gap={3} align="flex-end" style={{ flexShrink: 0 }}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{b.originYard?.label ?? "—"} → {b.destinationYard?.label ?? "—"}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} c="blue">
|
||||||
|
{formatPrice(b.totalAmount)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500}>{formatPrice(b.totalAmount)}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{b.scheduledDate ? b.scheduledDate.slice(0, 10) : "—"}
|
{b.scheduledDate ? b.scheduledDate.slice(0, 10) : "—"}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<ChevronRight size={16} color="var(--mantine-color-gray-5)" />
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</UnstyledButton>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -912,8 +950,8 @@ const FirstMilePage = () => {
|
|||||||
<InfoRow label="Customer" value={selectedBooking.company?.name ?? selectedBooking.company?.companyName ?? "—"} />
|
<InfoRow label="Customer" value={selectedBooking.company?.name ?? selectedBooking.company?.companyName ?? "—"} />
|
||||||
<InfoRow label="Service type" value={selectedBooking.serviceType?.name ?? "—"} />
|
<InfoRow label="Service type" value={selectedBooking.serviceType?.name ?? "—"} />
|
||||||
<InfoRow label="Pickup address" value={selectedBooking.firstMilePickupAddress ?? "—"} />
|
<InfoRow label="Pickup address" value={selectedBooking.firstMilePickupAddress ?? "—"} />
|
||||||
<InfoRow label="Origin yard" value={selectedBooking.originYard?.name ?? "—"} />
|
<InfoRow label="Destination (origin yard)" value={selectedBooking.originYard?.label ?? "—"} />
|
||||||
<InfoRow label="Destination yard" value={selectedBooking.destinationYard?.name ?? "—"} />
|
<InfoRow label="Train destination yard" value={selectedBooking.destinationYard?.label ?? "—"} />
|
||||||
<InfoRow label="Cargo type" value={selectedBooking.cargoType?.name ?? "—"} />
|
<InfoRow label="Cargo type" value={selectedBooking.cargoType?.name ?? "—"} />
|
||||||
<InfoRow label="Weight (VGM)" value={`${selectedBooking.cargoTotalWeightVgm} t`} />
|
<InfoRow label="Weight (VGM)" value={`${selectedBooking.cargoTotalWeightVgm} t`} />
|
||||||
<InfoRow label="Total amount" value={formatPrice(selectedBooking.totalAmount)} />
|
<InfoRow label="Total amount" value={formatPrice(selectedBooking.totalAmount)} />
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
|||||||
const vehicleLabel = (record: LastMileRecord) => {
|
const vehicleLabel = (record: LastMileRecord) => {
|
||||||
if (!record.vehicle) return null;
|
if (!record.vehicle) return null;
|
||||||
const v = record.vehicle;
|
const v = record.vehicle;
|
||||||
return `${v.manufacturer} ${v.model} (${v.plateNumber})`;
|
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
|
||||||
|
if (v.code) parts.unshift(v.code);
|
||||||
|
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
|
||||||
|
if (plates) parts.push(plates);
|
||||||
|
return parts.join(" · ");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
|
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
|
||||||
@@ -313,10 +317,13 @@ const LastMilePage = () => {
|
|||||||
|
|
||||||
const vehicleOptions = useMemo(
|
const vehicleOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({
|
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
|
||||||
value: v.id,
|
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
|
||||||
label: `${v.manufacturer} ${v.model} (${v.plateNumber})`,
|
if (v.code) parts.unshift(v.code);
|
||||||
})),
|
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
|
||||||
|
if (plates) parts.push(plates);
|
||||||
|
return { value: v.id, label: parts.join(" · ") };
|
||||||
|
}),
|
||||||
[vehiclesData],
|
[vehiclesData],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -622,11 +629,11 @@ const LastMilePage = () => {
|
|||||||
/>
|
/>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign}>
|
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign} styles={{ label: { fontWeight: 500 } }}>
|
||||||
Assign vehicle ({selectedIds.length})
|
Assign vehicle ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)}>
|
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)} styles={{ label: { fontWeight: 500 } }}>
|
||||||
Assign Mile
|
Assign Mile
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -639,6 +646,7 @@ const LastMilePage = () => {
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
size="xs"
|
size="xs"
|
||||||
variant={active ? "filled" : "default"}
|
variant={active ? "filled" : "default"}
|
||||||
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatusFilter(option.value);
|
setStatusFilter(option.value);
|
||||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
|||||||
@@ -1,197 +1,271 @@
|
|||||||
import { useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Container,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
Text,
|
Text,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
|
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageHeader } from '@/components/page';
|
||||||
|
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||||
import {
|
import {
|
||||||
InspectionReportModal,
|
|
||||||
VisualEmptyState,
|
VisualEmptyState,
|
||||||
|
WarehouseHero,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
formatNumber,
|
||||||
} from '@/components/warehouses';
|
} from '@/components/warehouses';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import {
|
||||||
|
useAutoUnloadArrivedBookings,
|
||||||
import { api } from '@/services/api';
|
useImportArriveQueue,
|
||||||
|
useImportTrainItems,
|
||||||
|
} from '@/hooks/useWarehouses';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import type { ArrivalQueueItem } from '@/types/warehouse';
|
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse';
|
||||||
|
|
||||||
function inspectionBadge(status: string | null) {
|
const getErrorMessage = (error: unknown) => {
|
||||||
if (!status) return <Badge variant="light" color="gray" size="sm">Not inspected</Badge>;
|
if (error && typeof error === 'object' && 'response' in error) {
|
||||||
const color = status === 'PASSED' ? 'edr-green' : status === 'FAILED' ? 'red' : 'orange';
|
const response = (error as { response?: { data?: { message?: unknown } } }).response;
|
||||||
return <Badge variant="light" color={color} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
const message = response?.data?.message;
|
||||||
}
|
if (Array.isArray(message)) return message.join(', ');
|
||||||
|
if (typeof message === 'string') return message;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
/** Batch 4.5 — arrived bookings awaiting unload / inspection. */
|
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||||
export default function ArrivalQueuePage() {
|
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||||
const navigate = useNavigate();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { data, isLoading } = useQuery(api.warehouses.arrivalQueue.queryOptions());
|
|
||||||
const autoUnload = useMutation(api.warehouses.autoUnloadArrived.mutationOptions());
|
|
||||||
const unloadOne = useMutation(api.warehouses.unloadBooking.mutationOptions());
|
|
||||||
const [inspectInventoryId, setInspectInventoryId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const items = data ?? [];
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleAutoUnload = async () => {
|
if (items.length === 0) {
|
||||||
try {
|
return (
|
||||||
const r = await autoUnload.mutateAsync();
|
<Text c="dimmed" ta="center" py="md" size="sm">
|
||||||
toast({
|
No assigned bookings found for this train.
|
||||||
title: 'Auto-unload complete',
|
</Text>
|
||||||
description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`,
|
);
|
||||||
});
|
}
|
||||||
} catch {
|
|
||||||
toast({ variant: 'destructive', title: 'Auto-unload failed' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnloadOne = async (item: ArrivalQueueItem) => {
|
|
||||||
try {
|
|
||||||
await unloadOne.mutateAsync({ bookingId: item.bookingId });
|
|
||||||
toast({ title: 'Booking unloaded', description: `${item.bookingReference} stored as RECEIVED.` });
|
|
||||||
} catch {
|
|
||||||
toast({ variant: 'destructive', title: 'Unload failed' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnDef<ArrivalQueueItem>[] = [
|
|
||||||
{
|
|
||||||
id: 'booking',
|
|
||||||
header: 'Booking',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{row.original.bookingReference}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customer ?? '—' },
|
|
||||||
{
|
|
||||||
id: 'cargo',
|
|
||||||
header: 'Cargo / Container',
|
|
||||||
cell: ({ row }) => row.original.container ?? row.original.cargo ?? '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'arrival',
|
|
||||||
header: 'Arrival',
|
|
||||||
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivalDate)}</Text>,
|
|
||||||
},
|
|
||||||
{ id: 'facility', header: 'Facility', cell: ({ row }) => row.original.facility ?? '—' },
|
|
||||||
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse ?? '—' },
|
|
||||||
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard ?? '—' },
|
|
||||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone ?? '—' },
|
|
||||||
{
|
|
||||||
id: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.unloaded ? (
|
|
||||||
<Badge variant="light" color="edr-green" size="sm">
|
|
||||||
{row.original.currentStatus ?? 'RECEIVED'}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="light" color="orange" size="sm">
|
|
||||||
Not unloaded
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'inspection',
|
|
||||||
header: 'Inspection',
|
|
||||||
cell: ({ row }) => inspectionBadge(row.original.inspectionStatus),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: '',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const item = row.original;
|
|
||||||
return (
|
|
||||||
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
|
||||||
{!item.unloaded && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<Truck size={14} />}
|
|
||||||
loading={unloadOne.isPending}
|
|
||||||
onClick={() => handleUnloadOne(item)}
|
|
||||||
>
|
|
||||||
Unload
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{item.inventoryId && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<ClipboardList size={14} />}
|
|
||||||
onClick={() => setInspectInventoryId(item.inventoryId)}
|
|
||||||
>
|
|
||||||
Inspect
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{item.inventoryId && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
leftSection={<Eye size={14} />}
|
|
||||||
onClick={() => navigate('/dashboard/warehouse-inventory')}
|
|
||||||
>
|
|
||||||
Inventory
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<Table highlightOnHover verticalSpacing="xs">
|
||||||
<PageHeader
|
<Table.Thead>
|
||||||
title="Arrival / Unloading Queue"
|
<Table.Tr>
|
||||||
subtitle="Arrived bookings ready to unload, store and inspect."
|
<Table.Th>Booking</Table.Th>
|
||||||
action={
|
<Table.Th>Customer</Table.Th>
|
||||||
<Button
|
<Table.Th>Container</Table.Th>
|
||||||
leftSection={<PackageOpen size={16} />}
|
<Table.Th>Cargo</Table.Th>
|
||||||
loading={autoUnload.isPending}
|
<Table.Th>Weight</Table.Th>
|
||||||
onClick={handleAutoUnload}
|
<Table.Th>Arrival</Table.Th>
|
||||||
>
|
<Table.Th>Status</Table.Th>
|
||||||
Auto Unload Arrived Bookings
|
<Table.Th>Pickup</Table.Th>
|
||||||
</Button>
|
</Table.Tr>
|
||||||
}
|
</Table.Thead>
|
||||||
/>
|
<Table.Tbody>
|
||||||
|
{items.map((item: ImportTrainItem) => (
|
||||||
<Card>
|
<Table.Tr key={item.bookingId}>
|
||||||
<Text fw={600} mb="md">
|
<Table.Td>
|
||||||
{items.length} arrived booking(s)
|
<Text size="sm" fw={600}>
|
||||||
</Text>
|
{item.bookingReference ?? item.bookingId.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
{!isLoading && items.length === 0 ? (
|
</Table.Td>
|
||||||
<VisualEmptyState
|
<Table.Td>{item.customerName ?? '-'}</Table.Td>
|
||||||
variant="container"
|
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
|
||||||
title="No arrived bookings"
|
<Table.Td>{item.cargoType ?? '-'}</Table.Td>
|
||||||
description="Bookings in transit that arrive appear here for unloading and inspection."
|
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||||
/>
|
<Table.Td>{formatDate(item.arrivalTime)}</Table.Td>
|
||||||
) : (
|
<Table.Td>
|
||||||
<DataTable
|
<Badge
|
||||||
columns={columns}
|
variant="light"
|
||||||
data={items}
|
color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'}
|
||||||
status={isLoading ? 'loading' : 'success'}
|
size="sm"
|
||||||
containerClassName="border-0 shadow-none"
|
>
|
||||||
/>
|
{item.currentStatus ?? 'PENDING'}
|
||||||
)}
|
</Badge>
|
||||||
</Card>
|
</Table.Td>
|
||||||
|
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||||
<InspectionReportModal
|
</Table.Tr>
|
||||||
opened={Boolean(inspectInventoryId)}
|
))}
|
||||||
onClose={() => setInspectInventoryId(null)}
|
</Table.Tbody>
|
||||||
inventoryId={inspectInventoryId}
|
</Table>
|
||||||
/>
|
);
|
||||||
</PageContainer>
|
}
|
||||||
|
|
||||||
|
/** Arrived import trains awaiting unload into warehouse inventory. */
|
||||||
|
export default function ArrivalQueuePage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { data: trains = [], isLoading } = useImportArriveQueue();
|
||||||
|
const autoUnload = useAutoUnloadArrivedBookings();
|
||||||
|
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||||
|
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const unloadTrain = async (train: ImportTrain) => {
|
||||||
|
setBusyScheduleId(train.scheduleId);
|
||||||
|
try {
|
||||||
|
const res = (await autoUnload.mutateAsync(train.scheduleId)) as {
|
||||||
|
data: AutoUnloadArrivedResult;
|
||||||
|
};
|
||||||
|
const result = res.data;
|
||||||
|
const details = [
|
||||||
|
result.skippedCount ? `${result.skippedCount} skipped` : '',
|
||||||
|
result.failedCount ? `${result.failedCount} failed` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: `${result.unloadedCount} booking(s) unloaded`,
|
||||||
|
description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Auto unload failed',
|
||||||
|
description: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyScheduleId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xxl" py="lg">
|
||||||
|
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
|
||||||
|
|
||||||
|
<Stack gap="lg" mt="sm">
|
||||||
|
<PageHeader
|
||||||
|
title="Arrival / Unloading Queue"
|
||||||
|
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WarehouseHero
|
||||||
|
variant="container"
|
||||||
|
secondaryVariant="warehouse"
|
||||||
|
title="Arrival / Unloading Queue"
|
||||||
|
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Open a train to review assigned bookings, then auto unload it.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : trains.length === 0 ? (
|
||||||
|
<VisualEmptyState
|
||||||
|
variant="container"
|
||||||
|
title="No arrived import trains"
|
||||||
|
description="Import trains appear here once their train schedule status is ARRIVED."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table.ScrollContainer minWidth={1150}>
|
||||||
|
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Train</Table.Th>
|
||||||
|
<Table.Th>Route</Table.Th>
|
||||||
|
<Table.Th>Origin</Table.Th>
|
||||||
|
<Table.Th>Destination</Table.Th>
|
||||||
|
<Table.Th>Arrival</Table.Th>
|
||||||
|
<Table.Th ta="center">Bookings</Table.Th>
|
||||||
|
<Table.Th ta="center">Containers</Table.Th>
|
||||||
|
<Table.Th ta="center">Cargoes</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{trains.map((train: ImportTrain) => {
|
||||||
|
const isOpen = openScheduleId === train.scheduleId;
|
||||||
|
return (
|
||||||
|
<Fragment key={train.scheduleId}>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{train.trainNumber ?? '-'}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{train.scheduleId.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{train.route ?? '-'}</Table.Td>
|
||||||
|
<Table.Td>{train.origin ?? '-'}</Table.Td>
|
||||||
|
<Table.Td>{train.destination ?? '-'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(train.arrivalTime)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">{train.totalBookings}</Table.Td>
|
||||||
|
<Table.Td ta="center">{train.totalContainers}</Table.Td>
|
||||||
|
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="light" color="teal" size="sm">
|
||||||
|
{train.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={
|
||||||
|
isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||||
|
}
|
||||||
|
onClick={() => setOpenScheduleId(isOpen ? null : train.scheduleId)}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
color="orange"
|
||||||
|
leftSection={
|
||||||
|
busyScheduleId === train.scheduleId ? (
|
||||||
|
<PackageOpen size={14} />
|
||||||
|
) : (
|
||||||
|
<Truck size={14} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loading={busyScheduleId === train.scheduleId}
|
||||||
|
onClick={() => unloadTrain(train)}
|
||||||
|
>
|
||||||
|
Auto Unload
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
{isOpen && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||||
|
<ImportTrainDetailRows scheduleId={train.scheduleId} />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,35 +3,30 @@ import { Button, Card, Center, Group, Loader, Select, Stack, TextInput } from '@
|
|||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import {
|
||||||
|
InventoryInquiryDetailModal,
|
||||||
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
VisualEmptyState,
|
||||||
import { api } from '@/services/api';
|
WarehouseInquiryTable,
|
||||||
import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse';
|
inventoryStatusOptions,
|
||||||
|
} from '@/components/warehouses';
|
||||||
|
import {
|
||||||
|
useAllWarehouseYards,
|
||||||
|
useAllWarehouseZones,
|
||||||
|
useInventoryInquiry,
|
||||||
|
useWarehouses,
|
||||||
|
} from '@/hooks/useWarehouses';
|
||||||
|
import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse';
|
||||||
|
|
||||||
export default function InventoryInquiryPage() {
|
export default function InventoryInquiryPage() {
|
||||||
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
||||||
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
||||||
|
const [viewResult, setViewResult] = useState<InventoryInquiryResult | null>(null);
|
||||||
|
|
||||||
const warehousesQuery = useQuery(
|
const warehousesQuery = useWarehouses();
|
||||||
api.warehouses.list.queryOptions({ input: {} }),
|
const yardsQuery = useAllWarehouseYards();
|
||||||
);
|
const zonesQuery = useAllWarehouseZones();
|
||||||
const yardsQuery = useQuery(
|
|
||||||
api.warehouses.listYards.queryOptions({
|
|
||||||
input: { warehouseId: draft.warehouseId ?? '' },
|
|
||||||
enabled: Boolean(draft.warehouseId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const zonesQuery = useQuery(
|
|
||||||
api.warehouses.listZones.queryOptions({
|
|
||||||
input: { yardId: draft.yardId ?? '' },
|
|
||||||
enabled: Boolean(draft.yardId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isFetching } = useQuery(
|
const { data, isFetching } = useInventoryInquiry(applied);
|
||||||
api.warehouses.inquiry.queryOptions({ input: { filter: applied } }),
|
|
||||||
);
|
|
||||||
const results = data ?? [];
|
const results = data ?? [];
|
||||||
|
|
||||||
const warehouseOptions = useMemo(
|
const warehouseOptions = useMemo(
|
||||||
@@ -39,15 +34,39 @@ export default function InventoryInquiryPage() {
|
|||||||
[warehousesQuery.data],
|
[warehousesQuery.data],
|
||||||
);
|
);
|
||||||
const yardOptions = useMemo(
|
const yardOptions = useMemo(
|
||||||
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
() =>
|
||||||
[yardsQuery.data],
|
(yardsQuery.data ?? [])
|
||||||
|
.filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId)
|
||||||
|
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||||
|
[draft.warehouseId, yardsQuery.data],
|
||||||
);
|
);
|
||||||
const zoneOptions = useMemo(
|
const zoneOptions = useMemo(
|
||||||
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
() => {
|
||||||
[zonesQuery.data],
|
const visibleYardIds = new Set(
|
||||||
|
(yardsQuery.data ?? [])
|
||||||
|
.filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId)
|
||||||
|
.map((y) => y.id),
|
||||||
|
);
|
||||||
|
return (zonesQuery.data ?? [])
|
||||||
|
.filter((z) => {
|
||||||
|
if (draft.yardId) return z.yardId === draft.yardId;
|
||||||
|
if (draft.warehouseId) return visibleYardIds.has(z.yardId);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||||
|
},
|
||||||
|
[draft.warehouseId, draft.yardId, yardsQuery.data, zonesQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runSearch = () => setApplied(draft);
|
const normalizeDraft = (filter: InventoryInquiryFilter): InventoryInquiryFilter => ({
|
||||||
|
...filter,
|
||||||
|
bookingReference: filter.bookingReference?.trim() || undefined,
|
||||||
|
containerNumber: filter.containerNumber?.trim() || undefined,
|
||||||
|
cargoType: filter.cargoType?.trim() || undefined,
|
||||||
|
goodsName: filter.goodsName?.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runSearch = () => setApplied(normalizeDraft(draft));
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setDraft({});
|
setDraft({});
|
||||||
setApplied({});
|
setApplied({});
|
||||||
@@ -60,101 +79,109 @@ export default function InventoryInquiryPage() {
|
|||||||
subtitle="Locate any cargo, container or goods inside the warehouse network."
|
subtitle="Locate any cargo, container or goods inside the warehouse network."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Stack gap="lg" mt="sm">
|
||||||
<Stack gap="md">
|
<Card withBorder radius="md" padding="lg">
|
||||||
<Group gap="sm" wrap="wrap">
|
<Stack gap="md">
|
||||||
<TextInput
|
<Group gap="sm" wrap="wrap">
|
||||||
label="Booking number"
|
<TextInput
|
||||||
placeholder="e.g. BKG-00123"
|
label="Booking reference"
|
||||||
value={draft.bookingNumber ?? ''}
|
placeholder="e.g. BKG-00123"
|
||||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }}
|
value={draft.bookingReference ?? ''}
|
||||||
w={200}
|
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingReference: v || undefined })); }}
|
||||||
/>
|
onKeyDown={(e) => {
|
||||||
<TextInput
|
if (e.key === 'Enter') runSearch();
|
||||||
label="Container number"
|
}}
|
||||||
placeholder="e.g. MSKU1234567"
|
w={200}
|
||||||
value={draft.containerNumber ?? ''}
|
/>
|
||||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
|
<TextInput
|
||||||
w={200}
|
label="Container number"
|
||||||
/>
|
placeholder="e.g. MSKU1234567"
|
||||||
<TextInput
|
value={draft.containerNumber ?? ''}
|
||||||
label="Goods name"
|
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
|
||||||
placeholder="e.g. Coffee"
|
w={200}
|
||||||
value={draft.goodsName ?? ''}
|
/>
|
||||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
|
<TextInput
|
||||||
w={180}
|
label="Goods name"
|
||||||
/>
|
placeholder="e.g. Coffee"
|
||||||
<Select
|
value={draft.goodsName ?? ''}
|
||||||
label="Warehouse"
|
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
|
||||||
placeholder="Any"
|
w={180}
|
||||||
clearable
|
/>
|
||||||
searchable
|
<Select
|
||||||
data={warehouseOptions}
|
label="Warehouse"
|
||||||
value={draft.warehouseId ?? null}
|
placeholder="Any"
|
||||||
onChange={(value) =>
|
clearable
|
||||||
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
searchable
|
||||||
}
|
data={warehouseOptions}
|
||||||
w={200}
|
value={draft.warehouseId ?? null}
|
||||||
/>
|
onChange={(value) =>
|
||||||
<Select
|
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||||
label="Yard"
|
}
|
||||||
placeholder="Any"
|
w={200}
|
||||||
clearable
|
/>
|
||||||
searchable
|
<Select
|
||||||
disabled={!draft.warehouseId}
|
label="Yard"
|
||||||
data={yardOptions}
|
placeholder="Any"
|
||||||
value={draft.yardId ?? null}
|
clearable
|
||||||
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
searchable
|
||||||
w={180}
|
data={yardOptions}
|
||||||
/>
|
value={draft.yardId ?? null}
|
||||||
<Select
|
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||||
label="Zone"
|
w={180}
|
||||||
placeholder="Any"
|
/>
|
||||||
clearable
|
<Select
|
||||||
searchable
|
label="Zone"
|
||||||
disabled={!draft.yardId}
|
placeholder="Any"
|
||||||
data={zoneOptions}
|
clearable
|
||||||
value={draft.zoneId ?? null}
|
searchable
|
||||||
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
data={zoneOptions}
|
||||||
w={180}
|
value={draft.zoneId ?? null}
|
||||||
/>
|
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||||
<Select
|
w={180}
|
||||||
label="Status"
|
/>
|
||||||
placeholder="Any"
|
<Select
|
||||||
clearable
|
label="Status"
|
||||||
data={inventoryStatusOptions}
|
placeholder="Any"
|
||||||
value={draft.status ?? null}
|
clearable
|
||||||
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
data={inventoryStatusOptions}
|
||||||
w={180}
|
value={draft.status ?? null}
|
||||||
/>
|
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||||
</Group>
|
w={180}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" onClick={reset}>
|
<Button variant="default" onClick={reset}>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card withBorder radius="md" padding="lg">
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Loader />
|
<Loader />
|
||||||
</Center>
|
</Center>
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<VisualEmptyState
|
<VisualEmptyState
|
||||||
variant="container"
|
variant="container"
|
||||||
title="No items found"
|
title="No items found"
|
||||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<WarehouseInquiryTable results={results} />
|
<WarehouseInquiryTable results={results} onView={setViewResult} />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
<InventoryInquiryDetailModal
|
||||||
|
opened={Boolean(viewResult)}
|
||||||
|
onClose={() => setViewResult(null)}
|
||||||
|
result={viewResult}
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
import { Card, Center, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -16,10 +16,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
|
||||||
|
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||||
import { WarehouseDashboardCharts } from '@/components/warehouses';
|
|
||||||
import { api } from '@/services/api';
|
|
||||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||||
|
|
||||||
interface Metric {
|
interface Metric {
|
||||||
@@ -31,8 +29,8 @@ interface Metric {
|
|||||||
theme: string;
|
theme: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORANGE = '#f08c00';
|
const ORANGE = 'rgb(241, 147, 23)';
|
||||||
const GREEN = '#22c55e';
|
const GREEN = '#084b21';
|
||||||
|
|
||||||
const METRICS: Metric[] = [
|
const METRICS: Metric[] = [
|
||||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
|
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
|
||||||
@@ -51,7 +49,7 @@ const METRICS: Metric[] = [
|
|||||||
|
|
||||||
export default function WarehouseDashboardPage() {
|
export default function WarehouseDashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data, isLoading } = useQuery(api.warehouses.dashboard.queryOptions());
|
const { data, isError, isLoading } = useWarehouseDashboard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -60,45 +58,58 @@ export default function WarehouseDashboardPage() {
|
|||||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
<Stack gap="lg" mt="sm">
|
||||||
<Center py="xl">
|
<WarehouseHero
|
||||||
<Loader />
|
variant="train"
|
||||||
</Center>
|
secondaryVariant="warehouse"
|
||||||
) : (
|
title="Warehouse Dashboard"
|
||||||
<>
|
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
/>
|
||||||
{METRICS.map((metric) => (
|
|
||||||
<Card
|
|
||||||
key={metric.key}
|
|
||||||
padding="lg"
|
|
||||||
onClick={() => navigate(metric.to)}
|
|
||||||
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
|
||||||
>
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
|
||||||
<div>
|
|
||||||
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
|
||||||
{metric.label}
|
|
||||||
</Text>
|
|
||||||
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
|
|
||||||
{data ? data[metric.key] : 0}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<ThemeIcon
|
|
||||||
variant="light"
|
|
||||||
size={46}
|
|
||||||
radius="md"
|
|
||||||
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
|
|
||||||
>
|
|
||||||
{metric.icon}
|
|
||||||
</ThemeIcon>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<WarehouseDashboardCharts data={data} />
|
{isLoading ? (
|
||||||
</>
|
<Center py="xl">
|
||||||
)}
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : isError ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="red">Failed to load warehouse dashboard.</Text>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<Card
|
||||||
|
key={metric.key}
|
||||||
|
padding="lg"
|
||||||
|
onClick={() => navigate(metric.to)}
|
||||||
|
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
||||||
|
{metric.label}
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
|
||||||
|
{data ? data[metric.key] : 0}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
size={46}
|
||||||
|
radius="md"
|
||||||
|
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
|
||||||
|
>
|
||||||
|
{metric.icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<WarehouseDashboardCharts data={data} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,15 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Stack,
|
|
||||||
Select,
|
Select,
|
||||||
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
|
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
|
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
|
||||||
@@ -27,8 +26,6 @@ import {
|
|||||||
formatCapacity,
|
formatCapacity,
|
||||||
humanizeEnum,
|
humanizeEnum,
|
||||||
} from '@/components/warehouses';
|
} from '@/components/warehouses';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||||
|
|
||||||
@@ -51,7 +48,6 @@ export default function WarehouseDetailPage() {
|
|||||||
|
|
||||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||||
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
|
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
|
||||||
|
|
||||||
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
||||||
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||||
@@ -169,14 +165,18 @@ export default function WarehouseDetailPage() {
|
|||||||
|
|
||||||
if (!warehouse) {
|
if (!warehouse) {
|
||||||
return (
|
return (
|
||||||
<Container size="sm" py="xl">
|
<PageContainer>
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md" py="xl">
|
||||||
<Text fw={700}>Warehouse not found</Text>
|
<Text fw={700}>Warehouse not found</Text>
|
||||||
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses')}>
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
onClick={() => navigate('/dashboard/warehouses')}
|
||||||
|
>
|
||||||
Back to warehouses
|
Back to warehouses
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,9 +189,7 @@ export default function WarehouseDetailPage() {
|
|||||||
]}
|
]}
|
||||||
backTo="/dashboard/warehouses"
|
backTo="/dashboard/warehouses"
|
||||||
title={warehouse.name}
|
title={warehouse.name}
|
||||||
subtitle={`${warehouse.code}${
|
subtitle={`${warehouse.code}${warehouse.locationName ? ` - ${warehouse.locationName}` : ''}`}
|
||||||
warehouse.locationName ? ` · ${warehouse.locationName}` : ''
|
|
||||||
}`}
|
|
||||||
meta={
|
meta={
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<WarehouseTypeBadge type={warehouse.type} />
|
<WarehouseTypeBadge type={warehouse.type} />
|
||||||
@@ -216,7 +214,6 @@ export default function WarehouseDetailPage() {
|
|||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{/* OVERVIEW */}
|
|
||||||
<Tabs.Panel value="overview" pt="lg">
|
<Tabs.Panel value="overview" pt="lg">
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
items={[
|
items={[
|
||||||
@@ -237,113 +234,105 @@ export default function WarehouseDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* YARDS */}
|
<Tabs.Panel value="yards" pt="lg">
|
||||||
<Tabs.Panel value="yards" pt="lg">
|
<Card withBorder radius="md" padding="lg">
|
||||||
<Card withBorder radius="md" padding="lg">
|
<Stack gap="md">
|
||||||
<Stack gap="md">
|
<Group justify="space-between">
|
||||||
<Group justify="space-between">
|
<Text fw={600}>Yards</Text>
|
||||||
<Text fw={600}>Yards</Text>
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
leftSection={<Plus size={16} />}
|
||||||
leftSection={<Plus size={16} />}
|
onClick={() => {
|
||||||
onClick={() => {
|
setEditingYard(null);
|
||||||
setEditingYard(null);
|
setYardModalOpen(true);
|
||||||
setYardModalOpen(true);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Create Yard
|
||||||
Create Yard
|
</Button>
|
||||||
</Button>
|
</Group>
|
||||||
</Group>
|
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={yardColumns}
|
||||||
|
data={yards}
|
||||||
|
status={
|
||||||
|
yardsQuery.isLoading ? 'loading' : yardsQuery.isError ? 'error' : 'success'
|
||||||
|
}
|
||||||
|
emptyMessage="No yards yet."
|
||||||
|
containerClassName="border-0 shadow-none"
|
||||||
|
error={
|
||||||
|
yardsQuery.isError
|
||||||
|
? {
|
||||||
|
message: 'Failed to load yards.',
|
||||||
|
onRetry: () => void yardsQuery.refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="zones" pt="lg">
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<Select
|
||||||
|
label="Yard"
|
||||||
|
placeholder="Select a yard"
|
||||||
|
data={yardOptions}
|
||||||
|
value={selectedYardId}
|
||||||
|
onChange={setSelectedYardId}
|
||||||
|
w={280}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
disabled={!selectedYardId}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingZone(null);
|
||||||
|
setZoneModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Zone
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!selectedYardId ? (
|
||||||
|
<Text c="dimmed" ta="center" py="lg">
|
||||||
|
Select a yard to view its zones.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={yardColumns}
|
columns={zoneColumns}
|
||||||
data={yards}
|
data={zonesQuery.data ?? []}
|
||||||
status={
|
status={
|
||||||
yardsQuery.isLoading
|
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
|
||||||
? 'loading'
|
|
||||||
: yardsQuery.isError
|
|
||||||
? 'error'
|
|
||||||
: 'success'
|
|
||||||
}
|
}
|
||||||
emptyMessage="No yards yet."
|
emptyMessage="No zones in this yard yet."
|
||||||
containerClassName="border-0 shadow-none"
|
containerClassName="border-0 shadow-none"
|
||||||
error={
|
error={
|
||||||
yardsQuery.isError
|
zonesQuery.isError
|
||||||
? {
|
? {
|
||||||
message: 'Failed to load yards.',
|
message: 'Failed to load zones.',
|
||||||
onRetry: () => void yardsQuery.refetch(),
|
onRetry: () => void zonesQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
)}
|
||||||
</Card>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Card>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* ZONES */}
|
<Tabs.Panel value="inventory" pt="lg">
|
||||||
<Tabs.Panel value="zones" pt="lg">
|
<Card withBorder radius="md" padding="lg">
|
||||||
<Card withBorder radius="md" padding="lg">
|
<InventoryWorkbench
|
||||||
<Stack gap="md">
|
items={inventoryQuery.data ?? []}
|
||||||
<Group justify="space-between" align="flex-end">
|
isLoading={inventoryQuery.isLoading}
|
||||||
<Select
|
/>
|
||||||
label="Yard"
|
</Card>
|
||||||
placeholder="Select a yard"
|
</Tabs.Panel>
|
||||||
data={yardOptions}
|
|
||||||
value={selectedYardId}
|
|
||||||
onChange={setSelectedYardId}
|
|
||||||
w={280}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
leftSection={<Plus size={16} />}
|
|
||||||
disabled={!selectedYardId}
|
|
||||||
onClick={() => {
|
|
||||||
setEditingZone(null);
|
|
||||||
setZoneModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create Zone
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{!selectedYardId ? (
|
|
||||||
<Text c="dimmed" ta="center" py="lg">
|
|
||||||
Select a yard to view its zones.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
columns={zoneColumns}
|
|
||||||
data={zonesQuery.data ?? []}
|
|
||||||
status={
|
|
||||||
zonesQuery.isLoading
|
|
||||||
? 'loading'
|
|
||||||
: zonesQuery.isError
|
|
||||||
? 'error'
|
|
||||||
: 'success'
|
|
||||||
}
|
|
||||||
emptyMessage="No zones in this yard yet."
|
|
||||||
containerClassName="border-0 shadow-none"
|
|
||||||
error={
|
|
||||||
zonesQuery.isError
|
|
||||||
? {
|
|
||||||
message: 'Failed to load zones.',
|
|
||||||
onRetry: () => void zonesQuery.refetch(),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
{/* INVENTORY */}
|
|
||||||
<Tabs.Panel value="inventory" pt="lg">
|
|
||||||
<Card withBorder radius="md" padding="lg">
|
|
||||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
|
||||||
</Card>
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{id && (
|
{id && (
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import {
|
|||||||
ReceiveInventoryModal,
|
ReceiveInventoryModal,
|
||||||
inventoryStatusOptions,
|
inventoryStatusOptions,
|
||||||
} from '@/components/warehouses';
|
} from '@/components/warehouses';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import {
|
||||||
|
useWarehouseInventory,
|
||||||
import { api } from '@/services/api';
|
useWarehouseYards,
|
||||||
|
useWarehouseZones,
|
||||||
|
useWarehouses,
|
||||||
|
} from '@/hooks/useWarehouses';
|
||||||
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
||||||
|
|
||||||
export default function WarehouseInventoryPage() {
|
export default function WarehouseInventoryPage() {
|
||||||
@@ -30,24 +33,10 @@ export default function WarehouseInventoryPage() {
|
|||||||
[filter, debouncedSearch],
|
[filter, debouncedSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const warehousesQuery = useQuery(
|
const warehousesQuery = useWarehouses();
|
||||||
api.warehouses.list.queryOptions({ input: {} }),
|
const yardsQuery = useWarehouseYards(filter.warehouseId);
|
||||||
);
|
const zonesQuery = useWarehouseZones(filter.yardId);
|
||||||
const yardsQuery = useQuery(
|
const inventoryQuery = useWarehouseInventory(queryFilter);
|
||||||
api.warehouses.listYards.queryOptions({
|
|
||||||
input: { warehouseId: filter.warehouseId ?? '' },
|
|
||||||
enabled: Boolean(filter.warehouseId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const zonesQuery = useQuery(
|
|
||||||
api.warehouses.listZones.queryOptions({
|
|
||||||
input: { yardId: filter.yardId ?? '' },
|
|
||||||
enabled: Boolean(filter.yardId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const inventoryQuery = useQuery(
|
|
||||||
api.warehouses.listInventory.queryOptions({ input: { filter: queryFilter } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const warehouseOptions = useMemo(
|
const warehouseOptions = useMemo(
|
||||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||||
@@ -91,7 +80,12 @@ export default function WarehouseInventoryPage() {
|
|||||||
data={warehouseOptions}
|
data={warehouseOptions}
|
||||||
value={filter.warehouseId ?? null}
|
value={filter.warehouseId ?? null}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
setFilter((f) => ({
|
||||||
|
...f,
|
||||||
|
warehouseId: value ?? undefined,
|
||||||
|
yardId: undefined,
|
||||||
|
zoneId: undefined,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
w={220}
|
w={220}
|
||||||
/>
|
/>
|
||||||
@@ -102,7 +96,9 @@ export default function WarehouseInventoryPage() {
|
|||||||
disabled={!filter.warehouseId}
|
disabled={!filter.warehouseId}
|
||||||
data={yardOptions}
|
data={yardOptions}
|
||||||
value={filter.yardId ?? null}
|
value={filter.yardId ?? null}
|
||||||
onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
onChange={(value) =>
|
||||||
|
setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))
|
||||||
|
}
|
||||||
w={200}
|
w={200}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
@@ -120,7 +116,9 @@ export default function WarehouseInventoryPage() {
|
|||||||
clearable
|
clearable
|
||||||
data={inventoryStatusOptions}
|
data={inventoryStatusOptions}
|
||||||
value={filter.status ?? null}
|
value={filter.status ?? null}
|
||||||
onChange={(value) => setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
onChange={(value) =>
|
||||||
|
setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))
|
||||||
|
}
|
||||||
w={200}
|
w={200}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
|||||||
CANCELLED: 'gray',
|
CANCELLED: 'gray',
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c}`;
|
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c === 'ETB' ? 'Birr (ETB)' : c}`;
|
||||||
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
||||||
|
|
||||||
export default function WarehouseInvoicesPage() {
|
export default function WarehouseInvoicesPage() {
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import {
|
|||||||
WarehouseTable,
|
WarehouseTable,
|
||||||
type WarehouseView,
|
type WarehouseView,
|
||||||
} from '@/components/warehouses';
|
} from '@/components/warehouses';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useWarehouses } from '@/hooks/useWarehouses';
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
|
||||||
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||||
|
|
||||||
export default function WarehouseListPage() {
|
export default function WarehouseListPage() {
|
||||||
@@ -30,9 +28,7 @@ export default function WarehouseListPage() {
|
|||||||
[filter, debouncedSearch],
|
[filter, debouncedSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery(
|
const { data, isLoading, isError } = useWarehouses(queryFilter);
|
||||||
api.warehouses.list.queryOptions({ input: { filter: queryFilter } }),
|
|
||||||
);
|
|
||||||
const warehouses = data ?? [];
|
const warehouses = data ?? [];
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
Table,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import {
|
||||||
|
useAllWarehouseYards,
|
||||||
|
useAllocationRules,
|
||||||
|
useCreateAllocationRule,
|
||||||
|
useCreateFeeRule,
|
||||||
|
useDeleteAllocationRule,
|
||||||
|
useDeleteFeeRule,
|
||||||
|
useFeeRules,
|
||||||
|
} from '@/hooks/useWarehouses';
|
||||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||||
|
|
||||||
const FREIGHT = [
|
const FREIGHT = [
|
||||||
@@ -32,15 +40,26 @@ const TRADE = [
|
|||||||
{ value: 'EXPORT', label: 'Export' },
|
{ value: 'EXPORT', label: 'Export' },
|
||||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||||
];
|
];
|
||||||
|
const CURRENCIES = [
|
||||||
|
{ value: 'USD', label: 'USD - Dollar' },
|
||||||
|
{ value: 'ETB', label: 'ETB - Birr' },
|
||||||
|
];
|
||||||
|
|
||||||
const clean = (s: string) => s.trim() || undefined;
|
const clean = (s: string) => s.trim() || undefined;
|
||||||
|
const selectValue = (value: string | null, fallback = '') => value ?? fallback;
|
||||||
|
const numberValue = (value: string | number, fallback = 0) => {
|
||||||
|
const next = Number(value);
|
||||||
|
return Number.isFinite(next) ? next : fallback;
|
||||||
|
};
|
||||||
|
const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`;
|
||||||
|
const dash = '-';
|
||||||
|
|
||||||
export default function WarehouseRulesPage() {
|
export default function WarehouseRulesPage() {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Allocation & Fee Rules"
|
title="Allocation & Fee Rules"
|
||||||
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates."
|
subtitle="Configure yard allocation and storage or demurrage free time and rates."
|
||||||
/>
|
/>
|
||||||
<Card>
|
<Card>
|
||||||
<Tabs defaultValue="allocation">
|
<Tabs defaultValue="allocation">
|
||||||
@@ -62,11 +81,10 @@ export default function WarehouseRulesPage() {
|
|||||||
|
|
||||||
function AllocationRules() {
|
function AllocationRules() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data, isLoading } = useQuery(
|
const { data, isLoading } = useAllocationRules();
|
||||||
api.warehouses.allocationRules.queryOptions(),
|
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
|
||||||
);
|
const create = useCreateAllocationRule();
|
||||||
const create = useMutation(api.warehouses.createAllocationRule.mutationOptions());
|
const remove = useDeleteAllocationRule();
|
||||||
const remove = useMutation(api.warehouses.deleteAllocationRule.mutationOptions());
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -78,13 +96,33 @@ function AllocationRules() {
|
|||||||
targetYardCode: '',
|
targetYardCode: '',
|
||||||
storageType: '',
|
storageType: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules = data ?? [];
|
const rules = data ?? [];
|
||||||
|
const yardOptions = yards
|
||||||
|
.filter((yard) => yard.code)
|
||||||
|
.map((yard) => ({
|
||||||
|
value: yard.code,
|
||||||
|
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resetForm = () =>
|
||||||
|
setForm({
|
||||||
|
name: '',
|
||||||
|
priority: 100,
|
||||||
|
freightType: '',
|
||||||
|
tradeDirection: '',
|
||||||
|
cargoTypeCode: '',
|
||||||
|
containerStatus: '',
|
||||||
|
targetYardCode: '',
|
||||||
|
storageType: '',
|
||||||
|
});
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||||
toast({ variant: 'destructive', title: 'Name and target yard code are required' });
|
toast({ variant: 'destructive', title: 'Name and target yard are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await create.mutateAsync({
|
await create.mutateAsync({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
priority: form.priority,
|
priority: form.priority,
|
||||||
@@ -98,77 +136,179 @@ function AllocationRules() {
|
|||||||
} as never);
|
} as never);
|
||||||
toast({ title: 'Allocation rule created' });
|
toast({ title: 'Allocation rule created' });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
|
resetForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<(typeof rules)[number]>[] = [
|
|
||||||
{ id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority },
|
|
||||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
|
||||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' },
|
|
||||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' },
|
|
||||||
{ id: 'cargo', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? '—' },
|
|
||||||
{
|
|
||||||
id: 'targetYard',
|
|
||||||
header: 'Target yard',
|
|
||||||
cell: ({ row }) => <Badge variant="light">{row.original.targetYardCode}</Badge>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'active',
|
|
||||||
header: 'Active',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge color={row.original.isActive ? 'edr-green' : 'gray'} variant="light">
|
|
||||||
{row.original.isActive ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: '',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(row.original.id)} title="Delete">
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — matched by ascending priority</Text>
|
<Text c="dimmed" size="sm">
|
||||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
|
{rules.length} rule(s) matched by ascending priority
|
||||||
|
</Text>
|
||||||
|
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||||
|
New allocation rule
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<DataTable
|
<Alert icon={<Info size={16} />} color="orange" variant="light" mb="md">
|
||||||
columns={columns}
|
<Text size="sm">
|
||||||
data={rules}
|
Allocation rules tell the system where to place a booking when it enters the warehouse.
|
||||||
status={isLoading ? 'loading' : 'success'}
|
Lower priority numbers are checked first.
|
||||||
emptyMessage="No allocation rules yet."
|
</Text>
|
||||||
containerClassName="border-0 shadow-none"
|
</Alert>
|
||||||
/>
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Table.ScrollContainer minWidth={900}>
|
||||||
|
<Table striped highlightOnHover verticalSpacing="sm">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Priority</Table.Th>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Freight</Table.Th>
|
||||||
|
<Table.Th>Trade</Table.Th>
|
||||||
|
<Table.Th>Cargo code</Table.Th>
|
||||||
|
<Table.Th>Target yard</Table.Th>
|
||||||
|
<Table.Th>Active</Table.Th>
|
||||||
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<Table.Tr key={rule.id}>
|
||||||
|
<Table.Td>{rule.priority}</Table.Td>
|
||||||
|
<Table.Td>{rule.name}</Table.Td>
|
||||||
|
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||||
|
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||||
|
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="light">{rule.targetYardCode}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
|
||||||
|
{rule.isActive ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => remove.mutate(rule.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
||||||
<Stack gap="sm">
|
<Stack gap="md">
|
||||||
|
<Card withBorder radius="md" padding="sm" bg="gray.0">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700} tt="uppercase">
|
||||||
|
Rule preview
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
<b>When</b> {anyLabel(form.tradeDirection, 'trade direction').toLowerCase()} /{' '}
|
||||||
|
{anyLabel(form.freightType, 'freight type').toLowerCase()} booking
|
||||||
|
{form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''}
|
||||||
|
{form.containerStatus.trim() ? ` and container status ${form.containerStatus.trim()}` : ''}{' '}
|
||||||
|
is received, <b>send it to</b> {form.targetYardCode || 'a selected target yard'}.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
<TextInput
|
||||||
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} />
|
label="Rule name"
|
||||||
|
placeholder="e.g. Import containers to open yard"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
setForm((f) => ({ ...f, name: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Priority"
|
||||||
|
value={form.priority}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, priority: numberValue(v, 100) || 100 }))}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
<Select
|
||||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
label="Freight type"
|
||||||
|
placeholder="Any freight"
|
||||||
|
data={FREIGHT}
|
||||||
|
value={form.freightType || null}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, freightType: selectValue(v) }))}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Trade direction"
|
||||||
|
placeholder="Any direction"
|
||||||
|
data={TRADE}
|
||||||
|
value={form.tradeDirection || null}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, tradeDirection: selectValue(v) }))}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
<TextInput
|
||||||
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} />
|
label="Cargo type code"
|
||||||
|
placeholder="e.g. COFFEE"
|
||||||
|
value={form.cargoTypeCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
setForm((f) => ({ ...f, cargoTypeCode: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Container status"
|
||||||
|
placeholder="e.g. MAINTENANCE"
|
||||||
|
value={form.containerStatus}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
setForm((f) => ({ ...f, containerStatus: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} />
|
<Select
|
||||||
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} />
|
label="Target yard"
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
data={yardOptions}
|
||||||
|
value={form.targetYardCode || null}
|
||||||
|
placeholder={yardsLoading ? 'Loading yards...' : 'Select target yard'}
|
||||||
|
nothingFoundMessage="No yards found"
|
||||||
|
disabled={yardsLoading}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, targetYardCode: selectValue(value) }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Storage type"
|
||||||
|
placeholder="e.g. OPEN_STACK"
|
||||||
|
value={form.storageType}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
setForm((f) => ({ ...f, storageType: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="flex-end" mt="sm">
|
<Group justify="flex-end" mt="sm">
|
||||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
<Button variant="default" onClick={() => setOpen(false)}>
|
||||||
<Button loading={create.isPending} onClick={submit}>Create</Button>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button loading={create.isPending} onClick={submit}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -178,9 +318,9 @@ function AllocationRules() {
|
|||||||
|
|
||||||
function FeeRules() {
|
function FeeRules() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data, isLoading } = useQuery(api.warehouses.feeRules.queryOptions());
|
const { data, isLoading } = useFeeRules();
|
||||||
const create = useMutation(api.warehouses.createFeeRule.mutationOptions());
|
const create = useCreateFeeRule();
|
||||||
const remove = useMutation(api.warehouses.deleteFeeRule.mutationOptions());
|
const remove = useDeleteFeeRule();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -199,6 +339,7 @@ function FeeRules() {
|
|||||||
toast({ variant: 'destructive', title: 'Name is required' });
|
toast({ variant: 'destructive', title: 'Name is required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await create.mutateAsync({
|
await create.mutateAsync({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
ruleType: form.ruleType,
|
ruleType: form.ruleType,
|
||||||
@@ -208,86 +349,158 @@ function FeeRules() {
|
|||||||
freeDays: form.freeDays,
|
freeDays: form.freeDays,
|
||||||
ratePerDay: form.ratePerDay,
|
ratePerDay: form.ratePerDay,
|
||||||
currency: form.currency || 'USD',
|
currency: form.currency || 'USD',
|
||||||
isActive: true,
|
|
||||||
} as never);
|
} as never);
|
||||||
toast({ title: 'Fee rule created' });
|
toast({ title: 'Fee rule created' });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<(typeof rules)[number]>[] = [
|
|
||||||
{
|
|
||||||
id: 'type',
|
|
||||||
header: 'Type',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge color={row.original.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
|
|
||||||
{row.original.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
|
||||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' },
|
|
||||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' },
|
|
||||||
{ id: 'freeDays', header: 'Free days', cell: ({ row }) => row.original.freeDays },
|
|
||||||
{
|
|
||||||
id: 'rate',
|
|
||||||
header: 'Rate / day',
|
|
||||||
cell: ({ row }) => `${Number(row.original.ratePerDay).toLocaleString()} ${row.original.currency}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'active',
|
|
||||||
header: 'Active',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge color={row.original.isActive ? 'edr-green' : 'gray'} variant="light">
|
|
||||||
{row.original.isActive ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: '',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(row.original.id)} title="Delete">
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — most specific match applies</Text>
|
<Text c="dimmed" size="sm">
|
||||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
|
{rules.length} rule(s) - most specific match applies
|
||||||
|
</Text>
|
||||||
|
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||||
|
New fee rule
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
{isLoading ? (
|
||||||
data={rules}
|
<Group justify="center" py="xl">
|
||||||
status={isLoading ? 'loading' : 'success'}
|
<Loader />
|
||||||
emptyMessage="No fee rules yet."
|
</Group>
|
||||||
containerClassName="border-0 shadow-none"
|
) : (
|
||||||
/>
|
<Table.ScrollContainer minWidth={900}>
|
||||||
|
<Table striped highlightOnHover verticalSpacing="sm">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Freight</Table.Th>
|
||||||
|
<Table.Th>Trade</Table.Th>
|
||||||
|
<Table.Th>Free days</Table.Th>
|
||||||
|
<Table.Th>Rate / day</Table.Th>
|
||||||
|
<Table.Th>Active</Table.Th>
|
||||||
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<Table.Tr key={rule.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
|
||||||
|
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{rule.name}</Table.Td>
|
||||||
|
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||||
|
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||||
|
<Table.Td>{rule.freeDays}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{Number(rule.ratePerDay).toLocaleString()} {rule.currency}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
|
||||||
|
{rule.isActive ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => remove.mutate(rule.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
<TextInput
|
||||||
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} />
|
label="Name"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
setForm((f) => ({ ...f, name: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Rule type"
|
||||||
|
data={FEE_RULE_TYPES.map((type) => ({
|
||||||
|
value: type,
|
||||||
|
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage',
|
||||||
|
}))}
|
||||||
|
value={form.ruleType}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
ruleType: selectValue(value, 'DEMURRAGE_FEE') as FeeRuleType,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
<Select
|
||||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
label="Freight type"
|
||||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
data={FREIGHT}
|
||||||
|
value={form.freightType || null}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, freightType: selectValue(value) }))}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Trade direction"
|
||||||
|
data={TRADE}
|
||||||
|
value={form.tradeDirection || null}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Cargo type code"
|
||||||
|
value={form.cargoTypeCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
setForm((f) => ({ ...f, cargoTypeCode: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} />
|
<NumberInput
|
||||||
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} />
|
label="Free days"
|
||||||
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} />
|
min={0}
|
||||||
|
value={form.freeDays}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Rate / day"
|
||||||
|
min={0}
|
||||||
|
value={form.ratePerDay}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Currency"
|
||||||
|
data={CURRENCIES}
|
||||||
|
value={form.currency}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, currency: selectValue(value, 'USD') }))}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="flex-end" mt="sm">
|
<Group justify="flex-end" mt="sm">
|
||||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
<Button variant="default" onClick={() => setOpen(false)}>
|
||||||
<Button loading={create.isPending} onClick={submit}>Create</Button>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button loading={create.isPending} onClick={submit}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export interface FirstMileBooking {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
scheduledDate?: string | null;
|
scheduledDate?: string | null;
|
||||||
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
|
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
|
||||||
serviceType?: { id: string; name?: string } | null;
|
serviceType?: { id: string; label?: string } | null;
|
||||||
originYard?: { id: string; name?: string } | null;
|
originYard?: { id: string; label?: string } | null;
|
||||||
destinationYard?: { id: string; name?: string } | null;
|
destinationYard?: { id: string; label?: string } | null;
|
||||||
cargoType?: { id: string; name?: string } | null;
|
cargoType?: { id: string; label?: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirstMileVehicle {
|
export interface FirstMileVehicle {
|
||||||
@@ -29,6 +29,9 @@ export interface FirstMileVehicle {
|
|||||||
plateNumber: string;
|
plateNumber: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
code?: string | null;
|
||||||
|
powerPlateNo?: string | null;
|
||||||
|
trailerPlateNo?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirstMileRecord {
|
export interface FirstMileRecord {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface LastMileVehicle {
|
|||||||
plateNumber: string;
|
plateNumber: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
code?: string | null;
|
||||||
|
powerPlateNo?: string | null;
|
||||||
|
trailerPlateNo?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LastMileRecord {
|
export interface LastMileRecord {
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface Vehicle {
|
|||||||
capacity: number;
|
capacity: number;
|
||||||
status: VehicleStatus;
|
status: VehicleStatus;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
code?: string | null;
|
||||||
|
powerPlateNo?: string | null;
|
||||||
|
trailerPlateNo?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import type {
|
|||||||
Warehouse,
|
Warehouse,
|
||||||
WarehouseActivityLog,
|
WarehouseActivityLog,
|
||||||
WarehouseDashboard,
|
WarehouseDashboard,
|
||||||
|
WarehouseFacility,
|
||||||
WarehouseFilter,
|
WarehouseFilter,
|
||||||
WarehouseInventoryItem,
|
WarehouseInventoryItem,
|
||||||
WarehouseLoading,
|
WarehouseLoading,
|
||||||
@@ -67,15 +68,19 @@ export const warehouseService = {
|
|||||||
params: cleanParams(filter ?? {}),
|
params: cleanParams(filter ?? {}),
|
||||||
}),
|
}),
|
||||||
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
|
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
|
||||||
|
getDashboardSummary: (_filter?: InventoryFilter) =>
|
||||||
|
apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
|
||||||
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
|
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
|
||||||
create: (payload: SaveWarehousePayload) =>
|
create: (payload: SaveWarehousePayload) =>
|
||||||
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
|
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
|
||||||
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
|
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
|
||||||
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
|
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
|
||||||
|
listFacilities: () => apiClient.get<WarehouseFacility[]>(URL_CONSTANTS.RULE_ENGINE.YARDS),
|
||||||
|
|
||||||
// ── Yards ────────────────────────────────────────────────────────────────
|
// ── Yards ────────────────────────────────────────────────────────────────
|
||||||
listYards: (warehouseId: string) =>
|
listYards: (warehouseId: string) =>
|
||||||
apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId)),
|
apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId)),
|
||||||
|
listAllYards: () => apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSE_YARDS.BASE),
|
||||||
createYard: (warehouseId: string, payload: SaveYardPayload) =>
|
createYard: (warehouseId: string, payload: SaveYardPayload) =>
|
||||||
apiClient.post<WarehouseYard>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId), payload),
|
apiClient.post<WarehouseYard>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId), payload),
|
||||||
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
||||||
@@ -85,6 +90,7 @@ export const warehouseService = {
|
|||||||
// ── Zones ──────────────────────────────────────────────────────────────
|
// ── Zones ──────────────────────────────────────────────────────────────
|
||||||
listZones: (yardId: string) =>
|
listZones: (yardId: string) =>
|
||||||
apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId)),
|
apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId)),
|
||||||
|
listAllZones: () => apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_ZONES.BASE),
|
||||||
createZone: (yardId: string, payload: SaveZonePayload) =>
|
createZone: (yardId: string, payload: SaveZonePayload) =>
|
||||||
apiClient.post<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId), payload),
|
apiClient.post<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId), payload),
|
||||||
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
||||||
@@ -124,6 +130,10 @@ export const warehouseService = {
|
|||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY_PICKUP(id)),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY_PICKUP(id)),
|
||||||
release: (id: string, payload: ReleaseOrderPayload) =>
|
release: (id: string, payload: ReleaseOrderPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE(id), payload),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE(id), payload),
|
||||||
|
downloadReleaseDocument: (id: string) =>
|
||||||
|
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
|
||||||
|
responseType: 'blob',
|
||||||
|
}),
|
||||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const INVENTORY_STATUSES = [
|
|||||||
'RECEIVED',
|
'RECEIVED',
|
||||||
'STORED',
|
'STORED',
|
||||||
'RESERVED',
|
'RESERVED',
|
||||||
|
'ARRIVED_AT_WAREHOUSE',
|
||||||
|
'UNDER_INSPECTION',
|
||||||
'READY_FOR_LOADING',
|
'READY_FOR_LOADING',
|
||||||
'LOADED',
|
'LOADED',
|
||||||
'DISPATCHED',
|
'DISPATCHED',
|
||||||
@@ -57,6 +59,8 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
|
|||||||
RECEIVED: 'store',
|
RECEIVED: 'store',
|
||||||
STORED: 'reserve',
|
STORED: 'reserve',
|
||||||
RESERVED: 'ready-for-loading',
|
RESERVED: 'ready-for-loading',
|
||||||
|
ARRIVED_AT_WAREHOUSE: null,
|
||||||
|
UNDER_INSPECTION: null,
|
||||||
READY_FOR_LOADING: 'load',
|
READY_FOR_LOADING: 'load',
|
||||||
LOADED: 'dispatch',
|
LOADED: 'dispatch',
|
||||||
DISPATCHED: null,
|
DISPATCHED: null,
|
||||||
@@ -122,6 +126,7 @@ export interface WarehouseYard {
|
|||||||
currentVolume: number;
|
currentVolume: number;
|
||||||
status: WarehouseStatus;
|
status: WarehouseStatus;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
warehouse?: Pick<Warehouse, 'id' | 'name' | 'code'> | null;
|
||||||
zones?: WarehouseZone[];
|
zones?: WarehouseZone[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +151,8 @@ export interface Facility {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WarehouseFacility = Facility;
|
||||||
|
|
||||||
export interface Warehouse {
|
export interface Warehouse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -459,8 +466,11 @@ export interface ImportTrainItem {
|
|||||||
|
|
||||||
export interface InventoryInquiryResult {
|
export interface InventoryInquiryResult {
|
||||||
id: string;
|
id: string;
|
||||||
bookingId: string;
|
inventoryId: string | null;
|
||||||
|
bookingId: string | null;
|
||||||
|
bookingReference: string | null;
|
||||||
bookingNumber: string | null;
|
bookingNumber: string | null;
|
||||||
|
bookingStatus: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
cargoType: string | null;
|
cargoType: string | null;
|
||||||
@@ -469,7 +479,11 @@ export interface InventoryInquiryResult {
|
|||||||
warehouse: { id: string; name: string; code: string } | null;
|
warehouse: { id: string; name: string; code: string } | null;
|
||||||
yard: { id: string; name: string; code: string } | null;
|
yard: { id: string; name: string; code: string } | null;
|
||||||
zone: { id: string; name: string; code: string } | null;
|
zone: { id: string; name: string; code: string } | null;
|
||||||
status: InventoryStatus;
|
status: InventoryStatus | null;
|
||||||
|
trainNumber: string | null;
|
||||||
|
trainStatus: string | null;
|
||||||
|
route: string | null;
|
||||||
|
locationSummary: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
arrivedAt: string | null;
|
arrivedAt: string | null;
|
||||||
@@ -606,6 +620,8 @@ export interface FeePreview {
|
|||||||
endIsOpen: boolean;
|
endIsOpen: boolean;
|
||||||
elapsedDays: number;
|
elapsedDays: number;
|
||||||
chargeableDays: number;
|
chargeableDays: number;
|
||||||
|
containerCount: number;
|
||||||
|
billableUnits: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +773,28 @@ export interface ReceiveInventoryPayload {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MoveInventoryPayload {
|
||||||
|
warehouseId: string;
|
||||||
|
yardId: string;
|
||||||
|
zoneId: string;
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReserveInventoryPayload {
|
||||||
|
bookingId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarehouseDashboardSummary {
|
||||||
|
totalWarehouses: number;
|
||||||
|
totalInventory: number;
|
||||||
|
receivedToday: number;
|
||||||
|
stored: number;
|
||||||
|
reserved: number;
|
||||||
|
readyForLoading: number;
|
||||||
|
loaded: number;
|
||||||
|
dispatched: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WarehouseFilter {
|
export interface WarehouseFilter {
|
||||||
search?: string;
|
search?: string;
|
||||||
type?: WarehouseType;
|
type?: WarehouseType;
|
||||||
@@ -765,6 +803,7 @@ export interface WarehouseFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryFilter {
|
export interface InventoryFilter {
|
||||||
|
facilityId?: string;
|
||||||
warehouseId?: string;
|
warehouseId?: string;
|
||||||
yardId?: string;
|
yardId?: string;
|
||||||
zoneId?: string;
|
zoneId?: string;
|
||||||
@@ -774,9 +813,12 @@ export interface InventoryFilter {
|
|||||||
goodsId?: string;
|
goodsId?: string;
|
||||||
status?: InventoryStatus;
|
status?: InventoryStatus;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryInquiryFilter {
|
export interface InventoryInquiryFilter {
|
||||||
|
bookingReference?: string;
|
||||||
bookingNumber?: string;
|
bookingNumber?: string;
|
||||||
containerNumber?: string;
|
containerNumber?: string;
|
||||||
cargoType?: string;
|
cargoType?: string;
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
/**
|
|
||||||
* fhc.theme.ts — Federal Housing Corporation (FHC) look & feel preset.
|
|
||||||
*
|
|
||||||
* ┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
* │ HOST-OWNED config. Lives in app-config/, NOT inside the user-management │
|
|
||||||
* │ module. At submodule-split time this whole folder moves to the host repo. │
|
|
||||||
* │ It is fully self-contained — no imports from the module. │
|
|
||||||
* └─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
*
|
|
||||||
* WHAT IT GIVES YOU
|
|
||||||
* - The FHC Mantine color palettes: fhcBlue, fhcBrick, fhcGold, fhcGray
|
|
||||||
* - The FHC layout design tokens (brick-gradient sidebar, glassy header,
|
|
||||||
* page background, brand colors, sizes) under `theme.other.fhcLayout`
|
|
||||||
* (light) and `theme.other.fhcLayoutDark` (dark) — the classic shell reads
|
|
||||||
* these via useFhcLayout()
|
|
||||||
* - FHC typography (Plus Jakarta Sans), radii, shadows and component defaults
|
|
||||||
*
|
|
||||||
* HOW TO USE — in app-config/project.theme.ts:
|
|
||||||
*
|
|
||||||
* import { fhcMantineTheme } from "./fhc.theme";
|
|
||||||
*
|
|
||||||
* export const projectTheme: DesignConfig = {
|
|
||||||
* typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
|
|
||||||
* mantineTheme: fhcMantineTheme, // escape hatch — merges the FHC theme in
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* Load the font once in index.html:
|
|
||||||
* <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { MantineColorsTuple, MantineThemeOverride } from "@mantine/core";
|
|
||||||
|
|
||||||
/** Mantine 10-shade color scales used across the FHC UI. */
|
|
||||||
export const FHC_COLORS = {
|
|
||||||
fhcBlue: [
|
|
||||||
"#EEF4FC",
|
|
||||||
"#D9E8FA",
|
|
||||||
"#BCD5F5",
|
|
||||||
"#96BDEB",
|
|
||||||
"#6FA4E0",
|
|
||||||
"#4A90E2",
|
|
||||||
"#357ABD",
|
|
||||||
"#2C669D",
|
|
||||||
"#224F7A",
|
|
||||||
"#173654",
|
|
||||||
],
|
|
||||||
fhcBrick: [
|
|
||||||
"#F6ECE8",
|
|
||||||
"#EACFC4",
|
|
||||||
"#DBAD99",
|
|
||||||
"#C9876B",
|
|
||||||
"#B86B49",
|
|
||||||
"#A85735",
|
|
||||||
"#8C462B",
|
|
||||||
"#703622",
|
|
||||||
"#55281A",
|
|
||||||
"#3D1E14",
|
|
||||||
],
|
|
||||||
fhcGold: [
|
|
||||||
"#FFFBE6",
|
|
||||||
"#FFF3BF",
|
|
||||||
"#FEE98A",
|
|
||||||
"#FCDD57",
|
|
||||||
"#F9CF2F",
|
|
||||||
"#FFD700",
|
|
||||||
"#D9B700",
|
|
||||||
"#B39400",
|
|
||||||
"#8C7300",
|
|
||||||
"#665300",
|
|
||||||
],
|
|
||||||
fhcGray: [
|
|
||||||
"#F8FAFC",
|
|
||||||
"#F1F5F9",
|
|
||||||
"#E2E8F0",
|
|
||||||
"#CBD5E1",
|
|
||||||
"#94A3B8",
|
|
||||||
"#64748B",
|
|
||||||
"#475569",
|
|
||||||
"#334155",
|
|
||||||
"#1E293B",
|
|
||||||
"#0F172A",
|
|
||||||
],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Layout design tokens — the brick-gradient sidebar, glassy header, page
|
|
||||||
* surfaces, brand colors and sizes. Mirrored under `theme.other.fhcLayout`.
|
|
||||||
*/
|
|
||||||
export const FHC_LAYOUT = {
|
|
||||||
sidebar: {
|
|
||||||
bg: "linear-gradient(180deg, #5D2E1F 0%, #3D1E14 100%)",
|
|
||||||
headerBg: "rgba(93, 46, 31, 0.82)",
|
|
||||||
footerBg: "rgba(61, 30, 20, 0.62)",
|
|
||||||
border: "rgba(255,255,255,0.10)",
|
|
||||||
text: "rgba(255,255,255,0.76)",
|
|
||||||
mutedText: "rgba(255,255,255,0.42)",
|
|
||||||
childText: "rgba(255,255,255,0.68)",
|
|
||||||
activeText: "#FFFFFF",
|
|
||||||
iconBg: "rgba(255,255,255,0.06)",
|
|
||||||
iconActiveBg: "rgba(255,255,255,0.12)",
|
|
||||||
hoverBg: "rgba(255,255,255,0.08)",
|
|
||||||
activeBg: "rgba(255,255,255,0.15)",
|
|
||||||
activeBorder: "rgba(255,255,255,0.14)",
|
|
||||||
sectionLine: "rgba(255,255,255,0.10)",
|
|
||||||
rail: "linear-gradient(180deg, #FFD700 0%, #4A90E2 100%)",
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
bg: "rgba(255,255,255,0.92)",
|
|
||||||
border: "rgba(15, 23, 42, 0.08)",
|
|
||||||
searchBg: "#F9FAFB",
|
|
||||||
searchBorder: "#E5E7EB",
|
|
||||||
title: "#1F2937",
|
|
||||||
subtitle: "#6B7280",
|
|
||||||
},
|
|
||||||
page: {
|
|
||||||
bg: "#F8FAFC",
|
|
||||||
cardBg: "rgba(255,255,255,0.92)",
|
|
||||||
},
|
|
||||||
brand: {
|
|
||||||
brick: "#5D2E1F",
|
|
||||||
brickDark: "#3D1E14",
|
|
||||||
blue: "#4A90E2",
|
|
||||||
blueDark: "#357ABD",
|
|
||||||
gold: "#FFD700",
|
|
||||||
text: "#1F2937",
|
|
||||||
},
|
|
||||||
sizes: {
|
|
||||||
sidebarExpanded: 288,
|
|
||||||
sidebarCollapsed: 80,
|
|
||||||
headerHeight: 64,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dark-mode counterpart of FHC_LAYOUT. The brick-gradient sidebar, accent rail
|
|
||||||
* and sizes are intentionally kept (they already read well on dark), while the
|
|
||||||
* glassy white header, page background, card surfaces and dark text are flipped
|
|
||||||
* to dark equivalents.
|
|
||||||
*/
|
|
||||||
export const FHC_LAYOUT_DARK = {
|
|
||||||
...FHC_LAYOUT,
|
|
||||||
header: {
|
|
||||||
bg: "rgba(26, 27, 30, 0.92)",
|
|
||||||
border: "rgba(255,255,255,0.08)",
|
|
||||||
searchBg: "#25262B",
|
|
||||||
searchBorder: "#2C2E33",
|
|
||||||
title: "#F1F5F9",
|
|
||||||
subtitle: "#9CA3AF",
|
|
||||||
},
|
|
||||||
page: {
|
|
||||||
bg: "#141517",
|
|
||||||
cardBg: "rgba(26, 27, 30, 0.92)",
|
|
||||||
},
|
|
||||||
brand: {
|
|
||||||
...FHC_LAYOUT.brand,
|
|
||||||
text: "#F1F5F9",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full Mantine theme override carrying the FHC palettes, layout tokens,
|
|
||||||
* typography, radii, shadows and component defaults. Pass this as the
|
|
||||||
* `mantineTheme` escape hatch in project.theme.ts.
|
|
||||||
*
|
|
||||||
* Note: BOTH `fhcLayout` (light) and `fhcLayoutDark` (dark) are published under
|
|
||||||
* `other` — the module's useFhcLayout() reads the matching one per color scheme.
|
|
||||||
*/
|
|
||||||
export const fhcMantineTheme: MantineThemeOverride = {
|
|
||||||
fontFamily: "Plus Jakarta Sans, sans-serif",
|
|
||||||
headings: {
|
|
||||||
fontFamily: "Plus Jakarta Sans, sans-serif",
|
|
||||||
},
|
|
||||||
defaultRadius: "md",
|
|
||||||
radius: {
|
|
||||||
xs: "6px",
|
|
||||||
sm: "8px",
|
|
||||||
md: "10px",
|
|
||||||
lg: "14px",
|
|
||||||
xl: "18px",
|
|
||||||
},
|
|
||||||
shadows: {
|
|
||||||
xs: "0 1px 2px rgba(15, 23, 42, 0.04)",
|
|
||||||
sm: "0 2px 8px rgba(15, 23, 42, 0.06)",
|
|
||||||
md: "0 4px 20px rgba(15, 23, 42, 0.08)",
|
|
||||||
lg: "0 8px 30px rgba(15, 23, 42, 0.12)",
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
fhcBlue: FHC_COLORS.fhcBlue as unknown as MantineColorsTuple,
|
|
||||||
fhcBrick: FHC_COLORS.fhcBrick as unknown as MantineColorsTuple,
|
|
||||||
fhcGold: FHC_COLORS.fhcGold as unknown as MantineColorsTuple,
|
|
||||||
fhcGray: FHC_COLORS.fhcGray as unknown as MantineColorsTuple,
|
|
||||||
},
|
|
||||||
other: {
|
|
||||||
fhcLayout: FHC_LAYOUT,
|
|
||||||
fhcLayoutDark: FHC_LAYOUT_DARK,
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
Paper: {
|
|
||||||
defaultProps: {
|
|
||||||
radius: "lg",
|
|
||||||
shadow: "sm",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NavLink: {
|
|
||||||
defaultProps: {
|
|
||||||
radius: "md",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional convenience: the bits of a DesignConfig that carry the FHC look.
|
|
||||||
* Spread this into your projectTheme if you also want FHC as the primary brand
|
|
||||||
* (this re-tints buttons/links to fhcBlue). Leave it out to keep your own brand
|
|
||||||
* color while still getting the fhc* palettes + layout tokens via `mantineTheme`.
|
|
||||||
*/
|
|
||||||
export const fhcDesignPreset = {
|
|
||||||
colors: { primary: "#357ABD" },
|
|
||||||
typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
|
|
||||||
shape: { radius: "10px" },
|
|
||||||
mantineTheme: fhcMantineTheme,
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no"
|
|
||||||
/>
|
|
||||||
<title>User Management</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { StrictMode } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
// Consume the reusable module via its public barrel (@/ → ../user-management/src).
|
|
||||||
import { UserManagementApp } from "@/index";
|
|
||||||
// Your project's config lives HERE in the host folder (resolved via @app-config).
|
|
||||||
// The module never imports it; the host passes it in.
|
|
||||||
import { projectTheme } from "@app-config/project.theme";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<UserManagementApp config={projectTheme} />
|
|
||||||
</StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user-management-host",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "user-management-host",
|
|
||||||
"version": "0.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "user-management-host",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Host wrapper for the user-management module. Owns branding/theme (project.theme.ts / fhc.theme.ts), the Vite build (vite.config.ts), the HTML shell (index.html) and the entry (main.tsx). Consumes the module from ../user-management/src.",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite --port 4202 --host 0.0.0.0",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview --port 4202 --host"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user