mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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/typeorm": "^11.0.1",
|
||||
"@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",
|
||||
"amqplib": "^2.0.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 { RoutesModule } from './modules/routes/routes.module';
|
||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||
import { FacilitiesModule } from './modules/facilities/facilities.module';
|
||||
import { OverviewModule } from './modules/overview/overview.module';
|
||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
import { DriversModule } from './modules/drivers/drivers.module';
|
||||
@@ -123,7 +122,6 @@ import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||
ContainersModule,
|
||||
CargoesModule,
|
||||
RoutesModule,
|
||||
FacilitiesModule,
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
VehiclesModule,
|
||||
|
||||
@@ -7,13 +7,13 @@ export function deriveTradeDirection(
|
||||
originYard: YardLike,
|
||||
destinationYard: YardLike,
|
||||
): ScheduleTradeDirection {
|
||||
const originCountry = originYard.country?.trim();
|
||||
const destinationCountry = destinationYard.country?.trim();
|
||||
const originCountry = originYard.country?.trim().toLowerCase();
|
||||
const destinationCountry = destinationYard.country?.trim().toLowerCase();
|
||||
|
||||
if (originCountry === 'Djibouti') {
|
||||
if (originCountry === 'djibouti') {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
||||
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
|
||||
return 'EXPORT';
|
||||
}
|
||||
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,
|
||||
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 =
|
||||
|
||||
@@ -41,6 +41,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
import { ListCompaniesQueryDto } from "./dto/list-companies-query.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 { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
@@ -226,6 +227,17 @@ export class CompaniesController {
|
||||
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")
|
||||
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
||||
async completeOnboarding(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { CompaniesController } from "./companies.controller";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
@@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service";
|
||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||
HttpModule,
|
||||
FilesModule,
|
||||
FileUploadSettingsModule,
|
||||
MinioModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
@@ -12,7 +13,10 @@ import {
|
||||
DashboardScope,
|
||||
} from "./company-dashboard.repository";
|
||||
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 { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
@@ -53,9 +57,67 @@ export class CompaniesService {
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
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> {
|
||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||
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) {
|
||||
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,
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
email: identity.email,
|
||||
phone: normalizeE164(identity.phone) ?? identity.phone,
|
||||
jobTitle: dto.jobTitle ?? null,
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
activeProfileType,
|
||||
@@ -134,15 +196,13 @@ export class CompaniesService {
|
||||
input.type,
|
||||
);
|
||||
if (existing) continue;
|
||||
const reference = await this.companyProfilesRepo.generateReference(
|
||||
input.type,
|
||||
);
|
||||
// No reference yet — these profiles await backoffice approval, which
|
||||
// is when the reference is minted (see setCompanyProfileStatus).
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId: company.id,
|
||||
type: input.type,
|
||||
reference,
|
||||
businessLicense: input.businessLicense ?? null,
|
||||
status: ProfileStatus.Active,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||
@@ -191,15 +251,6 @@ export class CompaniesService {
|
||||
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 chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||
const activeProfileType =
|
||||
@@ -224,8 +275,6 @@ export class CompaniesService {
|
||||
companyId: company.id,
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
email: identity.email,
|
||||
phone: normalizeE164(identity.phone) ?? identity.phone,
|
||||
isPrimaryContact: true,
|
||||
activeProfileType,
|
||||
onboardingStep: "company",
|
||||
@@ -251,12 +300,11 @@ export class CompaniesService {
|
||||
type,
|
||||
);
|
||||
if (existing) continue;
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -527,6 +575,8 @@ export class CompaniesService {
|
||||
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
|
||||
if (dto.contactPersonPhone !== undefined)
|
||||
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
||||
if (dto.contactVerifiedPhone !== undefined)
|
||||
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
|
||||
if (dto.generalManagerName !== undefined)
|
||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||
if (dto.generalManagerEmail !== undefined)
|
||||
@@ -576,10 +626,10 @@ export class CompaniesService {
|
||||
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
||||
await this.findCompanyById(dto.companyId);
|
||||
|
||||
const existing = await this.profilesRepo.findByEmail(dto.email);
|
||||
const existing = await this.profilesRepo.findByUserId(dto.userId);
|
||||
if (existing) {
|
||||
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,
|
||||
status: ProfileStatus,
|
||||
): Promise<CompanyProfile> {
|
||||
const updated = await this.companyProfilesRepo.updateStatus(
|
||||
profileId,
|
||||
status,
|
||||
);
|
||||
const existing = await this.companyProfilesRepo.findById(profileId);
|
||||
if (!existing)
|
||||
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)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -649,7 +720,7 @@ export class CompaniesService {
|
||||
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (existing) {
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
userId: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
@@ -821,23 +986,21 @@ export class CompaniesService {
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
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).
|
||||
if (!company.tin || company.tin.startsWith("D")) {
|
||||
const requirements = await this.getOnboardingRequirements(userId);
|
||||
if (!requirements.isComplete) {
|
||||
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
|
||||
// (stored directly on the profile).
|
||||
// Send every operational profile in for approval; the company itself becomes
|
||||
// active once the backoffice approves at least one profile.
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
for (const cp of profiles) {
|
||||
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
|
||||
);
|
||||
if (cp.status !== ProfileStatus.Pending) {
|
||||
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,6 +1015,25 @@ export class CompaniesService {
|
||||
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
|
||||
* 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> {
|
||||
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(
|
||||
`SELECT nextval('${seqName}') AS next_id`,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsString, IsNotEmpty, IsOptional, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateExternalProfileDto {
|
||||
@IsUUID()
|
||||
@@ -20,16 +19,6 @@ export class CreateExternalProfileDto {
|
||||
@MaxLength(100)
|
||||
lastName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsValidPhone()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@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;
|
||||
contactPersonEmail: string | null;
|
||||
contactPersonPhone: string | null;
|
||||
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
|
||||
contactVerifiedPhone: string | null;
|
||||
generalManagerName: string | null;
|
||||
generalManagerEmail: string | null;
|
||||
generalManagerPhone: string | null;
|
||||
@@ -81,6 +83,7 @@ export class ProfileResponseDto {
|
||||
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
|
||||
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
||||
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
|
||||
this.generalManagerName = attrs.generalManagerName ?? null;
|
||||
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
||||
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
||||
|
||||
@@ -28,7 +28,7 @@ export class ResponseCompanyProfileDto {
|
||||
this.id = profile.id;
|
||||
this.companyId = profile.companyId;
|
||||
this.type = profile.type;
|
||||
this.reference = profile.reference;
|
||||
this.reference = profile.reference ?? '';
|
||||
this.status = profile.status;
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
||||
|
||||
@@ -10,8 +10,6 @@ export class ResponseExternalProfileDto {
|
||||
companyId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
nationalId?: string | null;
|
||||
jobTitle?: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
@@ -34,8 +32,6 @@ export class ResponseExternalProfileDto {
|
||||
this.companyId = profile.companyId;
|
||||
this.firstName = profile.firstName;
|
||||
this.lastName = profile.lastName;
|
||||
this.email = profile.email;
|
||||
this.phone = profile.phone;
|
||||
this.nationalId = profile.nationalId;
|
||||
this.jobTitle = profile.jobTitle;
|
||||
this.isPrimaryContact = profile.isPrimaryContact;
|
||||
|
||||
@@ -67,6 +67,16 @@ export class UpdateProfileDto {
|
||||
@IsValidPhone()
|
||||
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()
|
||||
@IsString()
|
||||
generalManagerName?: string;
|
||||
|
||||
@@ -40,14 +40,19 @@ export class CompanyProfile extends BaseEntity {
|
||||
@Column({ name: "type", type: "varchar", length: 32, enum: 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({
|
||||
name: "reference",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: false,
|
||||
unique: true,
|
||||
nullable: true,
|
||||
})
|
||||
reference!: string;
|
||||
reference!: string | null;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
|
||||
@@ -23,12 +23,6 @@ export class ExternalProfile extends BaseEntity {
|
||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||
lastName!: string;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
||||
phone?: string | null;
|
||||
|
||||
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
|
||||
nationalId?: string | null;
|
||||
|
||||
|
||||
@@ -23,8 +23,4 @@ export class ExternalProfileRepository extends BaseRepository<ExternalProfile> {
|
||||
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
|
||||
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()
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
|
||||
acceptBooking(@Param('reference') reference: string) {
|
||||
return this.firstMileService.acceptBooking(reference);
|
||||
return this.firstMileService.acceptBookingByReference(reference);
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
||||
@@ -2,13 +2,22 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
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 { FirstMileController } from './first-mile.controller';
|
||||
import { FirstMileRepository } from './first-mile.repository';
|
||||
import { FirstMileService } from './first-mile.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FirstMile]), forwardRef(() => BookingsModule)],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([FirstMile]),
|
||||
forwardRef(() => BookingsModule),
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [FirstMileController],
|
||||
providers: [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 { 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 { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
||||
@@ -26,9 +29,14 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
|
||||
|
||||
@Injectable()
|
||||
export class FirstMileService {
|
||||
private readonly logger = new Logger(FirstMileService.name);
|
||||
|
||||
constructor(
|
||||
private readonly firstMileRepository: FirstMileRepository,
|
||||
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
|
||||
* unknown or the booking has not reached PAID status.
|
||||
*/
|
||||
async acceptBooking(bookingReference: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
|
||||
if (!booking) {
|
||||
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<{
|
||||
data: FirstMile[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
@@ -119,7 +143,7 @@ export class FirstMileService {
|
||||
}
|
||||
|
||||
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, {
|
||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||
@@ -135,9 +159,45 @@ export class FirstMileService {
|
||||
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;
|
||||
}
|
||||
|
||||
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> {
|
||||
await this.findById(id);
|
||||
await this.firstMileRepository.softDelete(id);
|
||||
|
||||
@@ -59,7 +59,7 @@ export class LastMileController {
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||
acceptBooking(@Param('reference') reference: string) {
|
||||
return this.lastMileService.acceptBooking(reference);
|
||||
return this.lastMileService.acceptBookingByReference(reference);
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
||||
@@ -2,13 +2,22 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
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 { LastMileController } from './last-mile.controller';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
import { LastMileService } from './last-mile.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LastMile]),
|
||||
BookingsModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [LastMileController],
|
||||
providers: [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 { 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 { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||
@@ -26,9 +29,14 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
|
||||
|
||||
@Injectable()
|
||||
export class LastMileService {
|
||||
private readonly logger = new Logger(LastMileService.name);
|
||||
|
||||
constructor(
|
||||
private readonly lastMileRepository: LastMileRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly driversService: DriversService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async acceptBooking(bookingReference: string): Promise<LastMile> {
|
||||
@@ -46,7 +54,26 @@ export class LastMileService {
|
||||
|
||||
return this.create({
|
||||
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> {
|
||||
await this.findById(id);
|
||||
const existing = await this.findById(id);
|
||||
|
||||
const updated = await this.lastMileRepository.update(id, {
|
||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||
@@ -132,9 +159,50 @@ export class LastMileService {
|
||||
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;
|
||||
}
|
||||
|
||||
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> {
|
||||
await this.findById(id);
|
||||
await this.lastMileRepository.softDelete(id);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
imports: [ConfigModule],
|
||||
controllers: [],
|
||||
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule { }
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -27,9 +27,29 @@ export class NotificationsService {
|
||||
if (!strategy) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const sent = await strategy.send(recipient, message)
|
||||
this.logger.log(`is sent - ${sent}`)
|
||||
const sent = await strategy.send(recipient, message);
|
||||
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 { NotificationStrategy } from "./notification.strategy";
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import axios from "axios";
|
||||
|
||||
import { NotificationStrategy } from "./notification.strategy";
|
||||
|
||||
@Injectable()
|
||||
export class SmsNotificationStrategy implements NotificationStrategy {
|
||||
constructor(private readonly httpService: HttpService, 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,
|
||||
),
|
||||
);
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
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")
|
||||
async sendOtp(
|
||||
@Body("phone")
|
||||
phone: string,
|
||||
@Body("otp")
|
||||
otp: string
|
||||
phone: string
|
||||
) {
|
||||
return this.otpService.sendOtp(
|
||||
phone,otp
|
||||
);
|
||||
return this.otpService.sendOtp(phone);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -29,11 +29,12 @@ export class OtpService {
|
||||
// Send OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async sendOtp(phone: string, otp: string) {
|
||||
async sendOtp(phone: string) {
|
||||
try {
|
||||
// generate otp
|
||||
// const otp =
|
||||
// this.generateOtp();
|
||||
// The verification code is generated server-side — never supplied by the
|
||||
// caller — so the OTP stays a secret known only to the server and the
|
||||
// recipient of the SMS.
|
||||
const otp = this.generateOtp();
|
||||
|
||||
// find existing phone
|
||||
const existingPhone =
|
||||
|
||||
@@ -64,4 +64,4 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
||||
controllers: [PaymentController, InternalPaymentController],
|
||||
exports: [PaymentService],
|
||||
})
|
||||
export class PaymentModule { }
|
||||
export class PaymentModule { }
|
||||
@@ -436,7 +436,7 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||
}
|
||||
|
||||
@Post("bulk/schedules/:id/cancel")
|
||||
@Post('bulk/schedules/:id/cancel')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
||||
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
AllocationLoadType,
|
||||
SchedulingStatus,
|
||||
TrainCheckpointKind,
|
||||
@@ -1005,12 +1005,29 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
const arrivingLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
if (arrivingLocoIds.length) {
|
||||
await manager.getRepository(Locomotive).update(
|
||||
{ id: In(arrivingLocoIds) },
|
||||
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
|
||||
);
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = $2,
|
||||
scheduling_status = $3
|
||||
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 ?? []) {
|
||||
|
||||
@@ -37,4 +37,16 @@ export class CreateVehicleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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 })
|
||||
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 vehicles = await this.repository.save(vehicle);
|
||||
return vehicles?.[0] as Vehicle;
|
||||
return this.repository.save(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 = [
|
||||
WagonStatus.Available,
|
||||
WagonStatus.Assigned,
|
||||
WagonStatus.ImportReady,
|
||||
WagonStatus.ExportReady,
|
||||
WagonStatus.Maintenance,
|
||||
WagonStatus.Retired,
|
||||
] as const;
|
||||
@@ -65,7 +67,7 @@ export class Wagon extends BaseEntity {
|
||||
@JoinColumn({ name: 'current_train_schedule_id' })
|
||||
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' })
|
||||
@JoinColumn({ name: 'train_id' })
|
||||
train!: Train | null;
|
||||
|
||||
@@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto {
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
facilityId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateFrom?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Legacy alias for bookingReference' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -78,17 +78,13 @@ export class WarehouseAllocationService {
|
||||
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
|
||||
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
|
||||
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(
|
||||
yardCode
|
||||
? `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`
|
||||
: `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] : [],
|
||||
`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`,
|
||||
[rule.targetYardCode],
|
||||
);
|
||||
if (!yard) return null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
@@ -26,6 +26,29 @@ export interface WarehouseDashboard {
|
||||
export class WarehouseDashboardService {
|
||||
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> {
|
||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
@@ -47,21 +70,18 @@ export class WarehouseDashboardService {
|
||||
delivered,
|
||||
receivedToday,
|
||||
] = await Promise.all([
|
||||
warehouseRepo.count(),
|
||||
inventoryRepo.count(),
|
||||
inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
|
||||
inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }),
|
||||
inventoryRepo.count({ where: { status: 'STORED' } }),
|
||||
inventoryRepo.count({ where: { status: 'RESERVED' } }),
|
||||
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
|
||||
inventoryRepo.count({ where: { status: 'LOADED' } }),
|
||||
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
|
||||
inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }),
|
||||
inventoryRepo.count({ where: { status: 'DELIVERED' } }),
|
||||
inventoryRepo
|
||||
.createQueryBuilder('inv')
|
||||
.where('inv.arrived_at >= :start', { start: startOfToday })
|
||||
.getCount(),
|
||||
this.safeCount(warehouseRepo),
|
||||
this.safeCount(inventoryRepo),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
|
||||
this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'STORED' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }),
|
||||
this.safeReceivedToday(startOfToday),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,8 @@ interface ItemAttributes {
|
||||
tradeDirection: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
containerTypeCode: string | null;
|
||||
inventoryQuantity: number;
|
||||
bookingContainerCount: number;
|
||||
facilityId: string | null;
|
||||
warehouseId: string | null;
|
||||
yardId: string | null;
|
||||
@@ -31,6 +33,8 @@ export interface FeePreview {
|
||||
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
containerCount: number;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@@ -67,6 +71,7 @@ export class WarehouseFeeService {
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
inv.gate_cleared_at AS "gateClearedAt",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.quantity AS "inventoryQuantity",
|
||||
inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId",
|
||||
inv.zone_id AS "zoneId",
|
||||
@@ -74,7 +79,8 @@ export class WarehouseFeeService {
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
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
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_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.containers ct ON ct.id = inv.container_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`,
|
||||
[inventoryId],
|
||||
);
|
||||
@@ -131,12 +143,18 @@ export class WarehouseFeeService {
|
||||
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
||||
const freeDays = rule?.freeDays ?? 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
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||
: 0;
|
||||
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 {
|
||||
ruleType,
|
||||
@@ -150,6 +168,8 @@ export class WarehouseFeeService {
|
||||
endIsOpen,
|
||||
elapsedDays,
|
||||
chargeableDays,
|
||||
containerCount,
|
||||
billableUnits,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class WarehouseInspectionService {
|
||||
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> {
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } });
|
||||
@@ -29,8 +29,9 @@ export class WarehouseInspectionService {
|
||||
const expected = dto.expectedWeight ?? null;
|
||||
const actual = dto.actualWeight ?? 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,
|
||||
bookingId: inventory.bookingId ?? null,
|
||||
reportType: dto.reportType,
|
||||
@@ -46,13 +47,27 @@ export class WarehouseInspectionService {
|
||||
missingItemsDescription: dto.missingItemsDescription ?? null,
|
||||
remarks: dto.remarks ?? 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.
|
||||
await inventoryRepo.update(inventoryId, {
|
||||
inspectionStatus: dto.inspectionStatus,
|
||||
inspectedAt: new Date(),
|
||||
inspectedAt,
|
||||
});
|
||||
|
||||
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 type { Response } from 'express';
|
||||
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
@@ -226,6 +227,16 @@ export class WarehouseInventoryController {
|
||||
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')
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
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,
|
||||
description:
|
||||
p.ruleType === 'STORAGE_FEE'
|
||||
? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`
|
||||
: `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`,
|
||||
quantity: p.chargeableDays,
|
||||
? `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) x ${p.containerCount} container(s) after ${p.freeDays} free`,
|
||||
quantity: p.billableUnits,
|
||||
unitRate: p.ratePerDay,
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
|
||||
@@ -15,6 +15,12 @@ export class WarehouseYardsController {
|
||||
private readonly zonesService: WarehouseZonesService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all warehouse yards' })
|
||||
findAll() {
|
||||
return this.yardsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -13,6 +13,13 @@ export class WarehouseYardsService {
|
||||
private readonly warehousesService: WarehousesService,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
relations: { warehouse: true, zones: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
where: { warehouseId },
|
||||
|
||||
@@ -10,6 +10,12 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all warehouse zones' })
|
||||
findAll() {
|
||||
return this.zonesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse zone by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -13,6 +13,13 @@ export class WarehouseZonesService {
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<WarehouseZone[]> {
|
||||
return this.zonesRepository.findAll({
|
||||
relations: { yard: { warehouse: true } },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findByYard(yardId: string): Promise<WarehouseZone[]> {
|
||||
return this.zonesRepository.findAll({
|
||||
where: { yardId },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
@@ -100,6 +101,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInvoiceService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
SchedulingReadFacade,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [
|
||||
WarehousesService,
|
||||
|
||||
@@ -102,6 +102,12 @@ export class Batch5TestDataSeeder {
|
||||
serviceTypeId: serviceType.id,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: now,
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: seed.weight,
|
||||
|
||||
@@ -89,6 +89,7 @@ export class WarehouseDemoSeeder {
|
||||
): Promise<Booking> =>
|
||||
bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
...this.demoBookingDefaults(),
|
||||
reference,
|
||||
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
|
||||
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id,
|
||||
@@ -237,6 +238,7 @@ export class WarehouseDemoSeeder {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
...this.demoBookingDefaults(),
|
||||
reference: `WH-DEMO-ARR-${i}`,
|
||||
originYardId: djibYard.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 { ArrowRight, Building2, Package } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, Calendar, Package, User } from "lucide-react";
|
||||
import { Group } from "@mantine/core";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
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 { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge, DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
export function OperationsBookingQueue({
|
||||
bookings,
|
||||
@@ -82,144 +21,144 @@ export function OperationsBookingQueue({
|
||||
isLoading?: boolean;
|
||||
onAllocate: (bookingIds: string[]) => void;
|
||||
}) {
|
||||
const { government, commercial } = useMemo(
|
||||
() => groupBookingsForOperationsQueue(bookings),
|
||||
[bookings],
|
||||
const navigate = useNavigate();
|
||||
const suppressRowClickRef = useRef(false);
|
||||
|
||||
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) =>
|
||||
row.status === "PAID" &&
|
||||
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus });
|
||||
|
||||
const govSelection = govSelected.length
|
||||
? govSelected
|
||||
: government.filter(allocatable).map((b) => b.id);
|
||||
|
||||
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
|
||||
const existing = selectedByBucket[bucketKey];
|
||||
if (existing) return existing;
|
||||
return bucketBookings.filter(allocatable).map((b) => b.id);
|
||||
};
|
||||
|
||||
const toggleGov = (bookingId: string) => {
|
||||
setGovSelected((prev) => {
|
||||
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
|
||||
return base.includes(bookingId)
|
||||
? base.filter((id) => id !== bookingId)
|
||||
: [...base, bookingId];
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBucket = (bucketKey: string, bookingId: string) => {
|
||||
setSelectedByBucket((prev) => {
|
||||
const current = prev[bucketKey] ?? [];
|
||||
const next = current.includes(bookingId)
|
||||
? current.filter((id) => id !== bookingId)
|
||||
: [...current, bookingId];
|
||||
return { ...prev, [bucketKey]: next };
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Text size="sm" c="dimmed">Loading operations queue…</Text>;
|
||||
}
|
||||
|
||||
if (!government.length && !commercial.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No PAID bookings ready to allocate.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const columns: ColumnDef<BookingListRow>[] = [
|
||||
{
|
||||
id: "booking",
|
||||
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<Package className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<p className="truncate font-medium text-foreground">{booking.reference}</p>
|
||||
{booking.isGovernment ? (
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||
Government
|
||||
</Badge>
|
||||
) : null}
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
return (
|
||||
<div className="space-y-1 py-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="max-w-[8rem] truncate">{booking.originLabel}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[8rem] truncate">{booking.destinationLabel}</span>
|
||||
</div>
|
||||
<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 (
|
||||
<Stack gap="lg">
|
||||
{government.length > 0 ? (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={5}>Government priority</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
Served first — not grouped by 3-hour window
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light">{govSelection.length} selected</Badge>
|
||||
<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>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={bookings}
|
||||
status={isLoading ? "loading" : "success"}
|
||||
emptyMessage="No PAID bookings ready to load."
|
||||
onRowClick={handleRowClick}
|
||||
containerClassName={cn(
|
||||
"border-0 shadow-none",
|
||||
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
||||
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
||||
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
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(),
|
||||
{
|
||||
prefix: "/dashboard/trains/",
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
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> = {
|
||||
DRAFT: 'gray',
|
||||
@@ -32,6 +34,9 @@ function fmtDate(iso: string | null) {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
|
||||
function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
|
||||
const configured = Boolean(fee.ruleId);
|
||||
@@ -48,7 +53,7 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={800} size="lg" c={`${meta.color}.7`}>
|
||||
{fee.amount.toLocaleString()} {fee.currency}
|
||||
{money(fee.amount, fee.currency)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -60,10 +65,12 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
<Stack gap={4}>
|
||||
<Row label="Rule" value={fee.ruleName ?? '—'} />
|
||||
<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="Elapsed days" value={String(fee.elapsedDays)} />
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
@@ -106,7 +113,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
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) {
|
||||
const msg = extractErrorMessage(error);
|
||||
if (/no payable warehouse fee/i.test(msg)) {
|
||||
@@ -121,11 +128,21 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
|
||||
const handleGateClearance = async () => {
|
||||
if (!inventoryId) return;
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
await gateClear.mutateAsync(inventoryId);
|
||||
toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' });
|
||||
const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
|
||||
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();
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
@@ -165,7 +182,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due
|
||||
{money(Number(activeInvoice.balanceAmount), activeInvoice.currency)} due
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
@@ -12,10 +12,8 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { Upload } from 'lucide-react';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
|
||||
import {
|
||||
INSPECTION_REPORT_TYPES,
|
||||
INSPECTION_STATUSES,
|
||||
@@ -47,12 +45,9 @@ const STATUS_LABELS: Record<InspectionResultStatus, string> = {
|
||||
/** Batch 4.5 — record an inspection / damage report with optional image upload. */
|
||||
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
|
||||
const { toast } = useToast();
|
||||
const createReport = useMutation(
|
||||
api.warehouses.createInspectionReport.mutationOptions(),
|
||||
);
|
||||
const uploadAttachments = useMutation(
|
||||
api.warehouses.uploadInspectionAttachments.mutationOptions(),
|
||||
);
|
||||
const createReport = useCreateInspectionReport();
|
||||
const uploadAttachments = useUploadInspectionAttachments();
|
||||
const reportsQuery = useInspectionReports(opened ? inventoryId ?? undefined : undefined);
|
||||
|
||||
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
|
||||
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
|
||||
@@ -82,6 +77,27 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
|
||||
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 () => {
|
||||
if (!inventoryId) return;
|
||||
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 { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryDetailModal } from './InventoryDetailModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
@@ -17,6 +19,7 @@ import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -33,6 +36,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadItem, setLoadItem] = 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 [feeItem, setFeeItem] = 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) => {
|
||||
switch (action) {
|
||||
case 'store':
|
||||
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored');
|
||||
return storeInventory(item);
|
||||
case 'reserve':
|
||||
setReserveItem(item);
|
||||
return;
|
||||
@@ -151,8 +191,10 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
onHistory={setHistoryItem}
|
||||
onView={setViewItem}
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
onReleaseDocument={downloadReleaseDocument}
|
||||
onLastMile={onLastMile}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
@@ -174,6 +216,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onClose={() => setHistoryItem(null)}
|
||||
item={historyItem}
|
||||
/>
|
||||
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectItem)}
|
||||
onClose={() => setInspectItem(null)}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface ReleaseOrderModalProps {
|
||||
opened: boolean;
|
||||
@@ -19,6 +21,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||
const [reference, setReference] = useState('');
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setReference(item?.releaseOrderReference ?? '');
|
||||
@@ -26,36 +29,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } });
|
||||
toast({ title: 'Release order issued' });
|
||||
const released = await releaseMutation.mutateAsync({
|
||||
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();
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Records the delivery order / release order sent to the customer. Once issued, the goods can be
|
||||
picked up and delivered.
|
||||
Creates the warehouse release document with booking, customer, cargo and location details. The
|
||||
printed paper authorizes the goods to leave the warehouse gate.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Release order reference"
|
||||
placeholder="e.g. DO-2026-001"
|
||||
label="Release document reference"
|
||||
placeholder="e.g. REL-2026-001"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}>
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}>
|
||||
Issue release order
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
Issue & view exit paper
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import { Building2, Eye, MapPin, Pencil } from 'lucide-react';
|
||||
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -35,56 +35,101 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{warehouses.map((warehouse) => (
|
||||
<Card key={warehouse.id} withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Card
|
||||
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">
|
||||
<div>
|
||||
<Text fw={700}>{warehouse.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{warehouse.code}
|
||||
</Text>
|
||||
</div>
|
||||
<WarehouseStatusBadge status={warehouse.status} />
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
</Group>
|
||||
|
||||
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
|
||||
<Group gap={6} c="dimmed">
|
||||
<Building2 size={14} />
|
||||
<Text size="sm">{stationNameById.get(warehouse.stationId)}</Text>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
background: 'var(--mantine-color-green-0)',
|
||||
color: 'var(--mantine-color-green-7)',
|
||||
border: '1px solid var(--mantine-color-green-2)',
|
||||
}}
|
||||
>
|
||||
<Building2 size={18} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{warehouse.locationName && (
|
||||
<Group gap={6} c="dimmed">
|
||||
<MapPin size={14} />
|
||||
<Text size="sm">{warehouse.locationName}</Text>
|
||||
</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>
|
||||
<Stack gap={6} align="flex-end">
|
||||
<WarehouseStatusBadge status={warehouse.status} />
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" gap="xs" mt="xs">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<Stack gap={8}>
|
||||
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
|
||||
<Group gap={8} c="dimmed" wrap="nowrap">
|
||||
<Building2 size={15} />
|
||||
<Text size="sm" truncate>
|
||||
{stationNameById.get(warehouse.stationId)}
|
||||
</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>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -92,3 +137,40 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
||||
</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 { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { ActionIcon, Badge, Stack, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
import type { InventoryInquiryResult } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
@@ -7,71 +7,111 @@ import { formatDate, formatNumber } from './options';
|
||||
|
||||
interface WarehouseInquiryTableProps {
|
||||
results: InventoryInquiryResult[];
|
||||
onView?: (result: InventoryInquiryResult) => void;
|
||||
}
|
||||
|
||||
const dash = '-';
|
||||
|
||||
const itemDescriptor = (result: InventoryInquiryResult) => {
|
||||
if (result.containerNumber) return `Container ${result.containerNumber}`;
|
||||
if (result.cargoType) return `Cargo · ${result.cargoType}`;
|
||||
if (result.cargoDescription) return `Cargo · ${result.cargoDescription}`;
|
||||
if (result.cargoType) return `Cargo - ${result.cargoType}`;
|
||||
if (result.cargoDescription) return `Cargo - ${result.cargoDescription}`;
|
||||
if (result.goodsId) return 'Goods';
|
||||
return '—';
|
||||
return dash;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<InventoryInquiryResult>[] = [
|
||||
{
|
||||
id: 'booking',
|
||||
header: 'Booking',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600}>
|
||||
{row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)}
|
||||
export function WarehouseInquiryTable({ results, onView }: WarehouseInquiryTableProps) {
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No matching items. Adjust your search to locate cargo, containers or goods.
|
||||
</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 (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results}
|
||||
status="success"
|
||||
emptyMessage="No matching items. Adjust your search to locate cargo, containers or goods."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<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, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import {
|
||||
INVENTORY_NEXT_ACTION,
|
||||
type InventoryAction,
|
||||
type WarehouseInventoryItem,
|
||||
} from "@/types/warehouse";
|
||||
import { InventoryStatusBadge } from "./badges";
|
||||
import { formatDate, formatNumber, humanizeEnum } from "./options";
|
||||
getNextInventoryAction,
|
||||
type InventoryAction,
|
||||
type WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -17,11 +15,11 @@ interface WarehouseInventoryTableProps {
|
||||
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
|
||||
onMove: (item: WarehouseInventoryItem) => void;
|
||||
onHistory: (item: WarehouseInventoryItem) => void;
|
||||
onView?: (item: WarehouseInventoryItem) => void;
|
||||
onInspect?: (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;
|
||||
// Optional row selection (used for bulk Mark-as-Inspected).
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
onToggleSelectAll?: () => void;
|
||||
@@ -30,21 +28,21 @@ interface WarehouseInventoryTableProps {
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
if (item.containerId) return { label: "Container", color: "blue" };
|
||||
if (item.cargoId) return { label: "Cargo", color: "grape" };
|
||||
if (item.goodsId) return { label: "Goods", color: "orange" };
|
||||
return { label: "—", color: "gray" };
|
||||
if (item.containerId) return { label: 'Container', color: 'blue' };
|
||||
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
|
||||
if (item.goodsId) return { label: 'Goods', color: 'orange' };
|
||||
return { label: '-', color: 'gray' };
|
||||
};
|
||||
|
||||
const actionColor: Record<InventoryAction, string> = {
|
||||
store: "blue",
|
||||
reserve: "grape",
|
||||
"ready-for-loading": "cyan",
|
||||
load: "teal",
|
||||
dispatch: "edr-green",
|
||||
"ready-for-pickup": "orange",
|
||||
release: "yellow",
|
||||
deliver: "green",
|
||||
store: 'blue',
|
||||
reserve: 'grape',
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'edr-green',
|
||||
'ready-for-pickup': 'orange',
|
||||
release: 'yellow',
|
||||
deliver: 'green',
|
||||
};
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
@@ -53,165 +51,195 @@ export function WarehouseInventoryTable({
|
||||
onAdvance,
|
||||
onMove,
|
||||
onHistory,
|
||||
onView,
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
onReleaseDocument,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "booking",
|
||||
header: "Booking",
|
||||
cell: ({ row }) =>
|
||||
row.original.bookingId ? (
|
||||
<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],
|
||||
);
|
||||
const selectable = Boolean(onToggleSelect);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No inventory items found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
status="success"
|
||||
emptyMessage="No inventory items found."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
<Table.ScrollContainer minWidth={1150}>
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{selectable && (
|
||||
<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 type { ReactNode } from 'react';
|
||||
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 { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { bookingTable } from '@/components/bookings/booking-ui.styles';
|
||||
import { api } from '@/services/api';
|
||||
import type { Warehouse } from '@/types/warehouse';
|
||||
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||
@@ -16,6 +26,43 @@ interface WarehouseTableProps {
|
||||
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) {
|
||||
const { data: stations } = useQuery(
|
||||
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
||||
@@ -28,63 +75,98 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
const columns: ColumnDef<Warehouse>[] = [
|
||||
{
|
||||
id: 'code',
|
||||
header: 'Code',
|
||||
header: () => <span className={HEADER}>Warehouse</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
c="edr-green.7"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onView(row.original)}
|
||||
>
|
||||
{row.original.code}
|
||||
</Text>
|
||||
<div className="flex min-w-[11rem] items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<WarehouseIcon className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="block max-w-full truncate text-left text-sm font-semibold text-edr-green transition-colors hover:text-edr-green/80"
|
||||
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',
|
||||
header: 'Facility',
|
||||
header: () => <span className={HEADER}>Facility</span>,
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.stationId
|
||||
? stationNameById.get(row.original.stationId)
|
||||
: undefined;
|
||||
return name ? (
|
||||
<Text size="sm" fw={500}>
|
||||
{name}
|
||||
</Text>
|
||||
<div className="flex min-w-[9rem] items-center gap-2 py-1 text-sm font-medium text-foreground">
|
||||
<Building2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
-
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => <WarehouseTypeBadge type={row.original.type} />,
|
||||
header: () => <span className={HEADER}>Type</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<WarehouseTypeBadge type={row.original.type} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => row.original.locationName ?? '—',
|
||||
header: () => <span className={HEADER}>Location</span>,
|
||||
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',
|
||||
header: 'Weight (cur / cap)',
|
||||
cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight),
|
||||
header: () => <span className={HEADER}>Weight</span>,
|
||||
cell: ({ row }) => (
|
||||
<CapacityCell
|
||||
current={row.original.currentWeight}
|
||||
capacity={row.original.capacityWeight}
|
||||
icon={<Scale className="size-3.5" />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'containers',
|
||||
header: 'Containers (cur / cap)',
|
||||
cell: ({ row }) =>
|
||||
formatCapacity(row.original.currentContainers, row.original.capacityContainers),
|
||||
header: () => <span className={HEADER}>Containers</span>,
|
||||
cell: ({ row }) => (
|
||||
<CapacityCell
|
||||
current={row.original.currentContainers}
|
||||
capacity={row.original.capacityContainers}
|
||||
icon={<Package className="size-3.5" />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <WarehouseStatusBadge status={row.original.status} />,
|
||||
header: () => <span className={HEADER}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<WarehouseStatusBadge status={row.original.status} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -109,7 +191,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
status="success"
|
||||
onRowClick={(warehouse) => onView(warehouse)}
|
||||
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",
|
||||
STORED: "blue",
|
||||
RESERVED: "grape",
|
||||
ARRIVED_AT_WAREHOUSE: "orange",
|
||||
UNDER_INSPECTION: "yellow",
|
||||
READY_FOR_LOADING: "cyan",
|
||||
LOADED: "teal",
|
||||
READY_FOR_PICKUP: "teal",
|
||||
|
||||
@@ -16,6 +16,8 @@ export { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
|
||||
export { ActivityTimeline } from './ActivityTimeline';
|
||||
export { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
export { InventoryDetailModal } from './InventoryDetailModal';
|
||||
export { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||
export { InventoryWorkbench } from './InventoryWorkbench';
|
||||
export { BookingSelect } from './BookingSelect';
|
||||
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: {
|
||||
BASE: '/warehouse-yards',
|
||||
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
||||
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
||||
},
|
||||
|
||||
WAREHOUSE_ZONES: {
|
||||
BASE: '/warehouse-zones',
|
||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVENTORY: {
|
||||
BASE: '/warehouse-inventory',
|
||||
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',
|
||||
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
|
||||
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
|
||||
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
|
||||
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
|
||||
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',
|
||||
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
||||
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
||||
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
|
||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||
// Receive (Import/Export bulk)
|
||||
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,
|
||||
pageSize: 100,
|
||||
statuses: "PAID",
|
||||
schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE",
|
||||
assignedToSchedule: "false",
|
||||
sortBy: "isGovernment",
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -32,7 +35,7 @@ import {
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company } from "@/types/customer";
|
||||
import type { Company, CompanyStatus } from "@/types/customer";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
@@ -45,14 +48,17 @@ export default function CustomersPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
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(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
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: {} }));
|
||||
@@ -107,7 +113,25 @@ export default function CustomersPage() {
|
||||
{
|
||||
id: "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",
|
||||
@@ -216,6 +240,20 @@ export default function CustomersPage() {
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
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">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
|
||||
@@ -360,7 +360,7 @@ const FleetResourcePage = () => {
|
||||
{config.subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => {
|
||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}>
|
||||
@@ -391,7 +391,7 @@ const FleetResourcePage = () => {
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant={filter.value === option.value ? "filled" : "outline"}
|
||||
color="green"
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => {
|
||||
setListFilterValues((prev) => ({
|
||||
...prev,
|
||||
@@ -417,7 +417,7 @@ const FleetResourcePage = () => {
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant={statusFilter === option.value ? "filled" : "outline"}
|
||||
color="green"
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setStatusFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@@ -47,7 +47,10 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
],
|
||||
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
|
||||
columns: [
|
||||
{ id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 },
|
||||
{ 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: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 },
|
||||
{ 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 },
|
||||
],
|
||||
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: "manufacturer", label: "Manufacturer", 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" },
|
||||
],
|
||||
emptyValues: {
|
||||
code: "",
|
||||
plateNumber: "",
|
||||
powerPlateNo: "",
|
||||
trailerPlateNo: "",
|
||||
vehicleType: "TRUCK",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Printer,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -73,7 +75,11 @@ const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
const vehicleLabel = (record: FirstMileRecord) => {
|
||||
if (!record.vehicle) return null;
|
||||
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);
|
||||
@@ -89,8 +95,9 @@ const cargoDesc = (r: FirstMileRecord) => {
|
||||
};
|
||||
const priceAmount = (r: FirstMileRecord) =>
|
||||
r.booking?.totalAmount ?? r.advancedPayment;
|
||||
// First-mile destination is the origin yard (pickup → origin yard)
|
||||
const destinationYardName = (r: FirstMileRecord) =>
|
||||
r.booking?.destinationYard?.name ?? "—";
|
||||
r.booking?.originYard?.label ?? "—";
|
||||
const contactPersonName = (r: FirstMileRecord) =>
|
||||
r.booking?.company?.contactPersonName ?? "—";
|
||||
const contactPhone = (r: FirstMileRecord) =>
|
||||
@@ -100,7 +107,7 @@ const requestedDate = (r: FirstMileRecord) => {
|
||||
return d ? new Date(d).toISOString().slice(0, 10) : "—";
|
||||
};
|
||||
const serviceTypeName = (r: FirstMileRecord) =>
|
||||
r.booking?.serviceType?.name ?? "—";
|
||||
r.booking?.serviceType?.label ?? "—";
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||
<Stack gap={2}>
|
||||
@@ -133,7 +140,7 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => (
|
||||
<InfoRow label="Customer" value={customerName(record)} />
|
||||
<InfoRow label="Service type" value={serviceTypeName(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="Price" value={formatPrice(priceAmount(record))} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
@@ -343,10 +350,13 @@ const FirstMilePage = () => {
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.manufacturer} ${v.model} (${v.plateNumber})`,
|
||||
})),
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
|
||||
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 { value: v.id, label: parts.join(" · ") };
|
||||
}),
|
||||
[vehiclesData],
|
||||
);
|
||||
|
||||
@@ -585,6 +595,12 @@ const FirstMilePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => pickupLocation(row.original),
|
||||
},
|
||||
{
|
||||
id: "destination",
|
||||
header: "Destination",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => destinationYardName(row.original),
|
||||
},
|
||||
{
|
||||
id: "cargo",
|
||||
header: "Cargo",
|
||||
@@ -701,11 +717,11 @@ const FirstMilePage = () => {
|
||||
/>
|
||||
<Group gap="sm">
|
||||
{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})
|
||||
</Button>
|
||||
)}
|
||||
<Button leftSection={<Truck size={16} />} onClick={openAccept}>
|
||||
<Button leftSection={<Truck size={16} />} onClick={openAccept} styles={{ label: { fontWeight: 500 } }}>
|
||||
Assign Mile
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -718,6 +734,7 @@ const FirstMilePage = () => {
|
||||
key={option.value}
|
||||
size="xs"
|
||||
variant={active ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => {
|
||||
setStatusFilter(option.value);
|
||||
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>
|
||||
) : (
|
||||
filteredPaidBookings.map((b) => (
|
||||
<Card
|
||||
<UnstyledButton
|
||||
key={b.id}
|
||||
withBorder
|
||||
padding="sm"
|
||||
radius="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
w="100%"
|
||||
onClick={() => {
|
||||
setSelectedBooking(b);
|
||||
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">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">{b.reference}</Text>
|
||||
<Text size="xs" c="dimmed">{b.company?.name ?? b.company?.companyName ?? "—"}</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="dimmed">
|
||||
{b.originYard?.name ?? "—"} → {b.destinationYard?.name ?? "—"}
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Stack gap={3} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" c="dark">{b.reference}</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{b.company?.name ?? b.company?.companyName ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<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 size="sm" fw={500}>{formatPrice(b.totalAmount)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{b.scheduledDate ? b.scheduledDate.slice(0, 10) : "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ChevronRight size={16} color="var(--mantine-color-gray-5)" />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
@@ -912,8 +950,8 @@ const FirstMilePage = () => {
|
||||
<InfoRow label="Customer" value={selectedBooking.company?.name ?? selectedBooking.company?.companyName ?? "—"} />
|
||||
<InfoRow label="Service type" value={selectedBooking.serviceType?.name ?? "—"} />
|
||||
<InfoRow label="Pickup address" value={selectedBooking.firstMilePickupAddress ?? "—"} />
|
||||
<InfoRow label="Origin yard" value={selectedBooking.originYard?.name ?? "—"} />
|
||||
<InfoRow label="Destination yard" value={selectedBooking.destinationYard?.name ?? "—"} />
|
||||
<InfoRow label="Destination (origin yard)" value={selectedBooking.originYard?.label ?? "—"} />
|
||||
<InfoRow label="Train destination yard" value={selectedBooking.destinationYard?.label ?? "—"} />
|
||||
<InfoRow label="Cargo type" value={selectedBooking.cargoType?.name ?? "—"} />
|
||||
<InfoRow label="Weight (VGM)" value={`${selectedBooking.cargoTotalWeightVgm} t`} />
|
||||
<InfoRow label="Total amount" value={formatPrice(selectedBooking.totalAmount)} />
|
||||
|
||||
@@ -71,7 +71,11 @@ const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
const vehicleLabel = (record: LastMileRecord) => {
|
||||
if (!record.vehicle) return null;
|
||||
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);
|
||||
@@ -313,10 +317,13 @@ const LastMilePage = () => {
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.manufacturer} ${v.model} (${v.plateNumber})`,
|
||||
})),
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
|
||||
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 { value: v.id, label: parts.join(" · ") };
|
||||
}),
|
||||
[vehiclesData],
|
||||
);
|
||||
|
||||
@@ -622,11 +629,11 @@ const LastMilePage = () => {
|
||||
/>
|
||||
<Group gap="sm">
|
||||
{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})
|
||||
</Button>
|
||||
)}
|
||||
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)}>
|
||||
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)} styles={{ label: { fontWeight: 500 } }}>
|
||||
Assign Mile
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -639,6 +646,7 @@ const LastMilePage = () => {
|
||||
key={option.value}
|
||||
size="xs"
|
||||
variant={active ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => {
|
||||
setStatusFilter(option.value);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
|
||||
@@ -1,197 +1,271 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Fragment, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { PageHeader } from '@/components/page';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
InspectionReportModal,
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
useImportArriveQueue,
|
||||
useImportTrainItems,
|
||||
} from '@/hooks/useWarehouses';
|
||||
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) {
|
||||
if (!status) return <Badge variant="light" color="gray" size="sm">Not inspected</Badge>;
|
||||
const color = status === 'PASSED' ? 'edr-green' : status === 'FAILED' ? 'red' : 'orange';
|
||||
return <Badge variant="light" color={color} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
||||
}
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: unknown } } }).response;
|
||||
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. */
|
||||
export default function ArrivalQueuePage() {
|
||||
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);
|
||||
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
|
||||
const items = data ?? [];
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAutoUnload = async () => {
|
||||
try {
|
||||
const r = await autoUnload.mutateAsync();
|
||||
toast({
|
||||
title: 'Auto-unload complete',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="md" size="sm">
|
||||
No assigned bookings found for this train.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived bookings ready to unload, store and inspect."
|
||||
action={
|
||||
<Button
|
||||
leftSection={<PackageOpen size={16} />}
|
||||
loading={autoUnload.isPending}
|
||||
onClick={handleAutoUnload}
|
||||
>
|
||||
Auto Unload Arrived Bookings
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Text fw={600} mb="md">
|
||||
{items.length} arrived booking(s)
|
||||
</Text>
|
||||
|
||||
{!isLoading && items.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No arrived bookings"
|
||||
description="Bookings in transit that arrive appear here for unloading and inspection."
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectInventoryId)}
|
||||
onClose={() => setInspectInventoryId(null)}
|
||||
inventoryId={inspectInventoryId}
|
||||
/>
|
||||
</PageContainer>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Cargo</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Pickup</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => (
|
||||
<Table.Tr key={item.bookingId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.bookingReference ?? item.bookingId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.customerName ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.cargoType ?? '-'}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||
<Table.Td>{formatDate(item.arrivalTime)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'}
|
||||
size="sm"
|
||||
>
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
||||
import { api } from '@/services/api';
|
||||
import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
import {
|
||||
InventoryInquiryDetailModal,
|
||||
VisualEmptyState,
|
||||
WarehouseInquiryTable,
|
||||
inventoryStatusOptions,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAllWarehouseYards,
|
||||
useAllWarehouseZones,
|
||||
useInventoryInquiry,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function InventoryInquiryPage() {
|
||||
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
||||
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
||||
const [viewResult, setViewResult] = useState<InventoryInquiryResult | null>(null);
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: {} }),
|
||||
);
|
||||
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 warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useAllWarehouseYards();
|
||||
const zonesQuery = useAllWarehouseZones();
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
api.warehouses.inquiry.queryOptions({ input: { filter: applied } }),
|
||||
);
|
||||
const { data, isFetching } = useInventoryInquiry(applied);
|
||||
const results = data ?? [];
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
@@ -39,15 +34,39 @@ export default function InventoryInquiryPage() {
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
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(
|
||||
() => (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 = () => {
|
||||
setDraft({});
|
||||
setApplied({});
|
||||
@@ -60,101 +79,109 @@ export default function InventoryInquiryPage() {
|
||||
subtitle="Locate any cargo, container or goods inside the warehouse network."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
label="Booking number"
|
||||
placeholder="e.g. BKG-00123"
|
||||
value={draft.bookingNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
placeholder="e.g. MSKU1234567"
|
||||
value={draft.containerNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Goods name"
|
||||
placeholder="e.g. Coffee"
|
||||
value={draft.goodsName ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!draft.warehouseId}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!draft.yardId}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={draft.status ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
<Stack gap="lg" mt="sm">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
label="Booking reference"
|
||||
placeholder="e.g. BKG-00123"
|
||||
value={draft.bookingReference ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingReference: v || undefined })); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') runSearch();
|
||||
}}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
placeholder="e.g. MSKU1234567"
|
||||
value={draft.containerNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Goods name"
|
||||
placeholder="e.g. Coffee"
|
||||
value={draft.goodsName ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={draft.status ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<Button variant="default" onClick={reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Group>
|
||||
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<Button variant="default" onClick={reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{isFetching ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : results.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No items found"
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} />
|
||||
)}
|
||||
</Card>
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{isFetching ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : results.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No items found"
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} onView={setViewResult} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
<InventoryInquiryDetailModal
|
||||
opened={Boolean(viewResult)}
|
||||
onClose={() => setViewResult(null)}
|
||||
result={viewResult}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { WarehouseDashboardCharts } from '@/components/warehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||
|
||||
interface Metric {
|
||||
@@ -31,8 +29,8 @@ interface Metric {
|
||||
theme: string;
|
||||
}
|
||||
|
||||
const ORANGE = '#f08c00';
|
||||
const GREEN = '#22c55e';
|
||||
const ORANGE = 'rgb(241, 147, 23)';
|
||||
const GREEN = '#084b21';
|
||||
|
||||
const METRICS: Metric[] = [
|
||||
{ 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() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useQuery(api.warehouses.dashboard.queryOptions());
|
||||
const { data, isError, isLoading } = useWarehouseDashboard();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -60,45 +58,58 @@ export default function WarehouseDashboardPage() {
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</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>
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
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 { KpiStrip, PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -27,8 +26,6 @@ import {
|
||||
formatCapacity,
|
||||
humanizeEnum,
|
||||
} from '@/components/warehouses';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
@@ -51,7 +48,6 @@ export default function WarehouseDetailPage() {
|
||||
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
|
||||
|
||||
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
||||
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||
@@ -169,14 +165,18 @@ export default function WarehouseDetailPage() {
|
||||
|
||||
if (!warehouse) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<PageContainer>
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<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
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,9 +189,7 @@ export default function WarehouseDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/warehouses"
|
||||
title={warehouse.name}
|
||||
subtitle={`${warehouse.code}${
|
||||
warehouse.locationName ? ` · ${warehouse.locationName}` : ''
|
||||
}`}
|
||||
subtitle={`${warehouse.code}${warehouse.locationName ? ` - ${warehouse.locationName}` : ''}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
@@ -216,7 +214,6 @@ export default function WarehouseDetailPage() {
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<KpiStrip
|
||||
items={[
|
||||
@@ -237,113 +234,105 @@ export default function WarehouseDetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* YARDS */}
|
||||
<Tabs.Panel value="yards" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Yards</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingYard(null);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Yard
|
||||
</Button>
|
||||
</Group>
|
||||
<Tabs.Panel value="yards" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Yards</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingYard(null);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Yard
|
||||
</Button>
|
||||
</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
|
||||
columns={yardColumns}
|
||||
data={yards}
|
||||
columns={zoneColumns}
|
||||
data={zonesQuery.data ?? []}
|
||||
status={
|
||||
yardsQuery.isLoading
|
||||
? 'loading'
|
||||
: yardsQuery.isError
|
||||
? 'error'
|
||||
: 'success'
|
||||
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
|
||||
}
|
||||
emptyMessage="No yards yet."
|
||||
emptyMessage="No zones in this yard yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
error={
|
||||
yardsQuery.isError
|
||||
zonesQuery.isError
|
||||
? {
|
||||
message: 'Failed to load yards.',
|
||||
onRetry: () => void yardsQuery.refetch(),
|
||||
message: 'Failed to load zones.',
|
||||
onRetry: () => void zonesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ZONES */}
|
||||
<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
|
||||
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.Panel value="inventory" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<InventoryWorkbench
|
||||
items={inventoryQuery.data ?? []}
|
||||
isLoading={inventoryQuery.isLoading}
|
||||
/>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{id && (
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
ReceiveInventoryModal,
|
||||
inventoryStatusOptions,
|
||||
} from '@/components/warehouses';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
useWarehouseInventory,
|
||||
useWarehouseYards,
|
||||
useWarehouseZones,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseInventoryPage() {
|
||||
@@ -30,24 +33,10 @@ export default function WarehouseInventoryPage() {
|
||||
[filter, debouncedSearch],
|
||||
);
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: {} }),
|
||||
);
|
||||
const yardsQuery = useQuery(
|
||||
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 warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useWarehouseYards(filter.warehouseId);
|
||||
const zonesQuery = useWarehouseZones(filter.yardId);
|
||||
const inventoryQuery = useWarehouseInventory(queryFilter);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
@@ -91,7 +80,12 @@ export default function WarehouseInventoryPage() {
|
||||
data={warehouseOptions}
|
||||
value={filter.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
setFilter((f) => ({
|
||||
...f,
|
||||
warehouseId: value ?? undefined,
|
||||
yardId: undefined,
|
||||
zoneId: undefined,
|
||||
}))
|
||||
}
|
||||
w={220}
|
||||
/>
|
||||
@@ -102,7 +96,9 @@ export default function WarehouseInventoryPage() {
|
||||
disabled={!filter.warehouseId}
|
||||
data={yardOptions}
|
||||
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}
|
||||
/>
|
||||
<Select
|
||||
@@ -120,7 +116,9 @@ export default function WarehouseInventoryPage() {
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
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}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -37,7 +37,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
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() : '—');
|
||||
|
||||
export default function WarehouseInvoicesPage() {
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
WarehouseTable,
|
||||
type WarehouseView,
|
||||
} from '@/components/warehouses';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useWarehouses } from '@/hooks/useWarehouses';
|
||||
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseListPage() {
|
||||
@@ -30,9 +28,7 @@ export default function WarehouseListPage() {
|
||||
[filter, debouncedSearch],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: queryFilter } }),
|
||||
);
|
||||
const { data, isLoading, isError } = useWarehouses(queryFilter);
|
||||
const warehouses = data ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
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 {
|
||||
useAllWarehouseYards,
|
||||
useAllocationRules,
|
||||
useCreateAllocationRule,
|
||||
useCreateFeeRule,
|
||||
useDeleteAllocationRule,
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
|
||||
const FREIGHT = [
|
||||
@@ -32,15 +40,26 @@ const TRADE = [
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
];
|
||||
const CURRENCIES = [
|
||||
{ value: 'USD', label: 'USD - Dollar' },
|
||||
{ value: 'ETB', label: 'ETB - Birr' },
|
||||
];
|
||||
|
||||
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() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
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>
|
||||
<Tabs defaultValue="allocation">
|
||||
@@ -62,11 +81,10 @@ export default function WarehouseRulesPage() {
|
||||
|
||||
function AllocationRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.allocationRules.queryOptions(),
|
||||
);
|
||||
const create = useMutation(api.warehouses.createAllocationRule.mutationOptions());
|
||||
const remove = useMutation(api.warehouses.deleteAllocationRule.mutationOptions());
|
||||
const { data, isLoading } = useAllocationRules();
|
||||
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
|
||||
const create = useCreateAllocationRule();
|
||||
const remove = useDeleteAllocationRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
@@ -78,13 +96,33 @@ function AllocationRules() {
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
priority: form.priority,
|
||||
@@ -98,77 +136,179 @@ function AllocationRules() {
|
||||
} as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
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 (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — matched by ascending priority</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) matched by ascending priority
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
New allocation rule
|
||||
</Button>
|
||||
</Group>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rules}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No allocation rules yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light" mb="md">
|
||||
<Text size="sm">
|
||||
Allocation rules tell the system where to place a booking when it enters the warehouse.
|
||||
Lower priority numbers are checked first.
|
||||
</Text>
|
||||
</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">
|
||||
<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>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} />
|
||||
<TextInput
|
||||
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 grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
<Select
|
||||
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 grow>
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} />
|
||||
<TextInput
|
||||
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 grow>
|
||||
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} />
|
||||
<Select
|
||||
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 justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>Create</Button>
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
@@ -178,9 +318,9 @@ function AllocationRules() {
|
||||
|
||||
function FeeRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useQuery(api.warehouses.feeRules.queryOptions());
|
||||
const create = useMutation(api.warehouses.createFeeRule.mutationOptions());
|
||||
const remove = useMutation(api.warehouses.deleteFeeRule.mutationOptions());
|
||||
const { data, isLoading } = useFeeRules();
|
||||
const create = useCreateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
@@ -199,6 +339,7 @@ function FeeRules() {
|
||||
toast({ variant: 'destructive', title: 'Name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
@@ -208,86 +349,158 @@ function FeeRules() {
|
||||
freeDays: form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
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 (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — most specific match applies</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) - most specific match applies
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
New fee rule
|
||||
</Button>
|
||||
</Group>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rules}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No fee rules yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<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">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<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} />
|
||||
<TextInput
|
||||
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 grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
<Select
|
||||
label="Freight type"
|
||||
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 grow>
|
||||
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} />
|
||||
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} />
|
||||
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} />
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
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 justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>Create</Button>
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -18,10 +18,10 @@ export interface FirstMileBooking {
|
||||
totalAmount: number;
|
||||
scheduledDate?: string | null;
|
||||
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
|
||||
serviceType?: { id: string; name?: string } | null;
|
||||
originYard?: { id: string; name?: string } | null;
|
||||
destinationYard?: { id: string; name?: string } | null;
|
||||
cargoType?: { id: string; name?: string } | null;
|
||||
serviceType?: { id: string; label?: string } | null;
|
||||
originYard?: { id: string; label?: string } | null;
|
||||
destinationYard?: { id: string; label?: string } | null;
|
||||
cargoType?: { id: string; label?: string } | null;
|
||||
}
|
||||
|
||||
export interface FirstMileVehicle {
|
||||
@@ -29,6 +29,9 @@ export interface FirstMileVehicle {
|
||||
plateNumber: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
}
|
||||
|
||||
export interface FirstMileRecord {
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface LastMileVehicle {
|
||||
plateNumber: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
}
|
||||
|
||||
export interface LastMileRecord {
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface Vehicle {
|
||||
capacity: number;
|
||||
status: VehicleStatus;
|
||||
description?: string | null;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
Warehouse,
|
||||
WarehouseActivityLog,
|
||||
WarehouseDashboard,
|
||||
WarehouseFacility,
|
||||
WarehouseFilter,
|
||||
WarehouseInventoryItem,
|
||||
WarehouseLoading,
|
||||
@@ -67,15 +68,19 @@ export const warehouseService = {
|
||||
params: cleanParams(filter ?? {}),
|
||||
}),
|
||||
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)),
|
||||
create: (payload: SaveWarehousePayload) =>
|
||||
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
|
||||
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
|
||||
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
|
||||
listFacilities: () => apiClient.get<WarehouseFacility[]>(URL_CONSTANTS.RULE_ENGINE.YARDS),
|
||||
|
||||
// ── Yards ────────────────────────────────────────────────────────────────
|
||||
listYards: (warehouseId: string) =>
|
||||
apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId)),
|
||||
listAllYards: () => apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSE_YARDS.BASE),
|
||||
createYard: (warehouseId: string, payload: SaveYardPayload) =>
|
||||
apiClient.post<WarehouseYard>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId), payload),
|
||||
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
||||
@@ -85,6 +90,7 @@ export const warehouseService = {
|
||||
// ── Zones ──────────────────────────────────────────────────────────────
|
||||
listZones: (yardId: string) =>
|
||||
apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId)),
|
||||
listAllZones: () => apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_ZONES.BASE),
|
||||
createZone: (yardId: string, payload: SaveZonePayload) =>
|
||||
apiClient.post<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId), payload),
|
||||
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)),
|
||||
release: (id: string, payload: ReleaseOrderPayload) =>
|
||||
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) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export const INVENTORY_STATUSES = [
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'ARRIVED_AT_WAREHOUSE',
|
||||
'UNDER_INSPECTION',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
@@ -57,6 +59,8 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
|
||||
RECEIVED: 'store',
|
||||
STORED: 'reserve',
|
||||
RESERVED: 'ready-for-loading',
|
||||
ARRIVED_AT_WAREHOUSE: null,
|
||||
UNDER_INSPECTION: null,
|
||||
READY_FOR_LOADING: 'load',
|
||||
LOADED: 'dispatch',
|
||||
DISPATCHED: null,
|
||||
@@ -122,6 +126,7 @@ export interface WarehouseYard {
|
||||
currentVolume: number;
|
||||
status: WarehouseStatus;
|
||||
isActive: boolean;
|
||||
warehouse?: Pick<Warehouse, 'id' | 'name' | 'code'> | null;
|
||||
zones?: WarehouseZone[];
|
||||
}
|
||||
|
||||
@@ -146,6 +151,8 @@ export interface Facility {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export type WarehouseFacility = Facility;
|
||||
|
||||
export interface Warehouse {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -459,8 +466,11 @@ export interface ImportTrainItem {
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
inventoryId: string | null;
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
bookingNumber: string | null;
|
||||
bookingStatus: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
@@ -469,7 +479,11 @@ export interface InventoryInquiryResult {
|
||||
warehouse: { id: string; name: string; code: string } | null;
|
||||
yard: { 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;
|
||||
weight: number;
|
||||
arrivedAt: string | null;
|
||||
@@ -606,6 +620,8 @@ export interface FeePreview {
|
||||
endIsOpen: boolean;
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
containerCount: number;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@@ -757,6 +773,28 @@ export interface ReceiveInventoryPayload {
|
||||
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 {
|
||||
search?: string;
|
||||
type?: WarehouseType;
|
||||
@@ -765,6 +803,7 @@ export interface WarehouseFilter {
|
||||
}
|
||||
|
||||
export interface InventoryFilter {
|
||||
facilityId?: string;
|
||||
warehouseId?: string;
|
||||
yardId?: string;
|
||||
zoneId?: string;
|
||||
@@ -774,9 +813,12 @@ export interface InventoryFilter {
|
||||
goodsId?: string;
|
||||
status?: InventoryStatus;
|
||||
search?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export interface InventoryInquiryFilter {
|
||||
bookingReference?: string;
|
||||
bookingNumber?: string;
|
||||
containerNumber?: 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