This commit is contained in:
marshal
2026-06-25 10:37:21 +03:00
295 changed files with 16865 additions and 7724 deletions

View File

View File

@@ -0,0 +1 @@
{"dependencies":{"pnpm":"11.1.1"}}

BIN
.pnpm-store/v11/index.db Normal file

Binary file not shown.

View File

@@ -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",

View File

@@ -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,

View File

@@ -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';

View File

@@ -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');
}
}

View File

@@ -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`,
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);`,
);
}
}

View File

@@ -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 =

View File

@@ -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(

View File

@@ -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],

View File

@@ -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.

View File

@@ -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`,
);

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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 ?? [];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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",

View File

@@ -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;

View File

@@ -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 });
}
}

View File

@@ -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()

View File

@@ -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],

View File

@@ -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);

View File

@@ -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()

View File

@@ -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],

View File

@@ -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);

View File

@@ -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 {}

View File

@@ -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)}`);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
// ---------------------------------------------------------------------------

View File

@@ -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 =

View File

@@ -64,4 +64,4 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
controllers: [PaymentController, InternalPaymentController],
exports: [PaymentService],
})
export class PaymentModule { }
export class PaymentModule { }

View File

@@ -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) {

View File

@@ -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 ?? []) {

View File

@@ -37,4 +37,16 @@ export class CreateVehicleDto {
@IsOptional()
@IsString()
assignedDriverName?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
powerPlateNo?: string;
@IsOptional()
@IsString()
trailerPlateNo?: string;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
bookingReference?: string;
@ApiPropertyOptional({ description: 'Legacy alias for bookingReference' })
@IsOptional()
@IsString()
bookingNumber?: string;
@ApiPropertyOptional()

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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 },

View File

@@ -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) {

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
};
}
}

View File

@@ -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",
)}
/>
);
}

View File

@@ -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/",

View File

@@ -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>
) : (

View File

@@ -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 {

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"
/>
);
}

View File

@@ -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",

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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) =>

View File

@@ -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>
}

View 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 });
}

View File

@@ -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,
};

View File

@@ -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>

View File

@@ -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}

View File

@@ -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: "",

View File

@@ -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)} />

View File

@@ -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 }));

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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() {

View File

@@ -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 = () => {

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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"
}
}
}

View File

@@ -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