mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
18
.github/workflows/deploy.yml
vendored
18
.github/workflows/deploy.yml
vendored
@@ -182,24 +182,6 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
||||||
|
|
||||||
- name: Verify deployment health
|
|
||||||
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2)
|
|
||||||
echo "Waiting for service to become healthy on port ${PORT}..."
|
|
||||||
for i in $(seq 1 12); do
|
|
||||||
if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then
|
|
||||||
echo "Service is healthy."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Attempt ${i}/12 — not ready yet, waiting 10s..."
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
echo "Service failed health check after 120s — rolling back"
|
|
||||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Remove npm credentials from workspace
|
- name: Remove npm credentials from workspace
|
||||||
if: always()
|
if: always()
|
||||||
run: rm -f .npmrc .npmrc_temp
|
run: rm -f .npmrc .npmrc_temp
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
||||||
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||||
|
"seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts",
|
||||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||||
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
||||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
|
|||||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||||
|
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||||
@@ -170,6 +171,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
ExportDjiboutiInterchangeDemoSeeder,
|
ExportDjiboutiInterchangeDemoSeeder,
|
||||||
MarshallingDemoTrainsSeeder,
|
MarshallingDemoTrainsSeeder,
|
||||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||||
|
PaidImportExportMileDemoSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
|
|||||||
@@ -957,12 +957,27 @@ export class BillingService {
|
|||||||
returnUrl: opts.returnUrl,
|
returnUrl: opts.returnUrl,
|
||||||
failureUrl: opts.failureUrl,
|
failureUrl: opts.failureUrl,
|
||||||
});
|
});
|
||||||
|
//
|
||||||
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(Invoice)
|
.getRepository(Invoice)
|
||||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||||
|
|
||||||
|
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||||
|
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||||
|
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||||
|
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||||
|
if (!result.immediateSuccess) {
|
||||||
|
await this.payment.handlePaymentEvent({
|
||||||
|
eventType: "payment.succeeded",
|
||||||
|
eventId: `demo-${result.intentId}`,
|
||||||
|
referenceId: invoice.sourceId,
|
||||||
|
intentId: result.intentId,
|
||||||
|
providerTxnId: result.providerTxnId,
|
||||||
|
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (result.immediateSuccess) {
|
if (result.immediateSuccess) {
|
||||||
await this.settleByPaymentId(
|
await this.settleByPaymentId(
|
||||||
result.intentId,
|
result.intentId,
|
||||||
|
|||||||
@@ -1086,6 +1086,9 @@ export class BookingTransitionService {
|
|||||||
offeredAmount: number;
|
offeredAmount: number;
|
||||||
paymentDeadline: Date;
|
paymentDeadline: Date;
|
||||||
} | null;
|
} | null;
|
||||||
|
/** Flat list of physical container numbers on this booking (for the
|
||||||
|
* customer truck-assignment container picker). */
|
||||||
|
containerNumbers: string[];
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
// This enrichment runs AFTER the transition has committed. A failure here
|
// This enrichment runs AFTER the transition has committed. A failure here
|
||||||
@@ -1141,12 +1144,20 @@ export class BookingTransitionService {
|
|||||||
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
|
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Physical container numbers entered at booking time (booking_container
|
||||||
|
// units), flattened for the customer truck-assignment container picker.
|
||||||
|
const containerNumbers = (booking.bookingContainers ?? [])
|
||||||
|
.flatMap((bc) => bc.units ?? [])
|
||||||
|
.map((unit) => unit.containerNumber)
|
||||||
|
.filter((n): n is string => Boolean(n));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
latestChangeRequestNote: note?.note ?? null,
|
latestChangeRequestNote: note?.note ?? null,
|
||||||
contractSummary: summary,
|
contractSummary: summary,
|
||||||
nextStep,
|
nextStep,
|
||||||
activeBatchOffer,
|
activeBatchOffer,
|
||||||
|
containerNumbers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export class BookingClearanceService {
|
|||||||
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
||||||
const bookingMilestone = (code: string) =>
|
const bookingMilestone = (code: string) =>
|
||||||
milestones.find((m) => m.milestoneCode === code);
|
milestones.find((m) => m.milestoneCode === code);
|
||||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
|
||||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||||
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
||||||
@@ -242,14 +242,8 @@ export class BookingClearanceService {
|
|||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
train,
|
train,
|
||||||
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
gatepassGranted: gatepass.granted,
|
||||||
gatepassAt:
|
gatepassAt: gatepass.grantedAt,
|
||||||
gatepassMilestone?.status === 'COMPLETED'
|
|
||||||
? (gatepassMilestone.metadata?.gatepassAt ??
|
|
||||||
(gatepassMilestone.triggeredAt
|
|
||||||
? gatepassMilestone.triggeredAt.toISOString()
|
|
||||||
: null))
|
|
||||||
: null,
|
|
||||||
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||||
t1ClosedAt:
|
t1ClosedAt:
|
||||||
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||||
|
|||||||
@@ -278,7 +278,9 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
const bookingMilestone = (code: string) =>
|
const bookingMilestone = (code: string) =>
|
||||||
bookingMilestones.find((m) => m.milestoneCode === code);
|
bookingMilestones.find((m) => m.milestoneCode === code);
|
||||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
const gatepass = cycle?.bookingId
|
||||||
|
? await this.glOperationsService.gatepassForBooking(cycle.bookingId)
|
||||||
|
: { granted: false, grantedAt: null };
|
||||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||||
const secondDuty = this.glOperationsService.secondDutyState(
|
const secondDuty = this.glOperationsService.secondDutyState(
|
||||||
@@ -344,14 +346,8 @@ export class ContractClearanceService {
|
|||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
train,
|
train,
|
||||||
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
gatepassGranted: gatepass.granted,
|
||||||
gatepassAt:
|
gatepassAt: gatepass.grantedAt,
|
||||||
gatepassMilestone?.status === 'COMPLETED'
|
|
||||||
? (gatepassMilestone.metadata?.gatepassAt ??
|
|
||||||
(gatepassMilestone.triggeredAt
|
|
||||||
? gatepassMilestone.triggeredAt.toISOString()
|
|
||||||
: null))
|
|
||||||
: null,
|
|
||||||
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||||
t1ClosedAt:
|
t1ClosedAt:
|
||||||
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ import {
|
|||||||
} from './dto/gl-operations.dto';
|
} from './dto/gl-operations.dto';
|
||||||
import {
|
import {
|
||||||
AdviseContractDutyDto,
|
AdviseContractDutyDto,
|
||||||
GatepassDto,
|
|
||||||
RoAmendmentDto,
|
RoAmendmentDto,
|
||||||
} from './dto/phased-clearance.dto';
|
} from './dto/phased-clearance.dto';
|
||||||
|
|
||||||
@@ -688,30 +687,6 @@ export class ContractsController {
|
|||||||
return this.clearanceService.djQueue(filter);
|
return this.clearanceService.djQueue(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('clearance/dj-schedules')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
|
||||||
@ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' })
|
|
||||||
djClearanceSchedules() {
|
|
||||||
return this.glOperationsService.djSchedules();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('clearance/schedules/:scheduleId/gatepass')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'GL DJ grants the gate pass for every customs booking on a train schedule',
|
|
||||||
})
|
|
||||||
grantScheduleGatepass(
|
|
||||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
|
||||||
@Body() dto: GatepassDto,
|
|
||||||
@CurrentUser() user: AuthUserPayload,
|
|
||||||
) {
|
|
||||||
return this.glOperationsService.grantScheduleGatepass(
|
|
||||||
scheduleId,
|
|
||||||
dto?.gatepassAt,
|
|
||||||
resolveAuthUserId(user),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||||
|
|
||||||
@Get('clearance/ops-queue')
|
@Get('clearance/ops-queue')
|
||||||
@@ -947,21 +922,6 @@ export class ContractsController {
|
|||||||
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/gatepass')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
|
||||||
@ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' })
|
|
||||||
grantGatepass(
|
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
|
||||||
@Body() dto: GatepassDto,
|
|
||||||
@CurrentUser() user: AuthUserPayload,
|
|
||||||
) {
|
|
||||||
return this.glOperationsService.grantGatepass(
|
|
||||||
bookingId,
|
|
||||||
dto?.gatepassAt,
|
|
||||||
resolveAuthUserId(user),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('bookings/:bookingId/final-invoice')
|
@Post('bookings/:bookingId/final-invoice')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
|||||||
@@ -36,11 +36,3 @@ export class RoAmendmentDto {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatepassDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'When the gate pass was granted (ISO datetime; defaults to now)',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
gatepassAt?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource, In, IsNull } from 'typeorm';
|
import { DataSource, IsNull } from 'typeorm';
|
||||||
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
||||||
|
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
ClearanceIncident,
|
ClearanceIncident,
|
||||||
IncidentType,
|
IncidentType,
|
||||||
} from './entities/clearance-incident.entity';
|
} from './entities/clearance-incident.entity';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
|
||||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import {
|
import {
|
||||||
@@ -198,6 +197,7 @@ export class GlOperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
scheduleId: schedule?.id ?? null,
|
||||||
wagonAllocated,
|
wagonAllocated,
|
||||||
departedAt: schedule?.actualDepartureAt
|
departedAt: schedule?.actualDepartureAt
|
||||||
? new Date(schedule.actualDepartureAt).toISOString()
|
? new Date(schedule.actualDepartureAt).toISOString()
|
||||||
@@ -208,6 +208,41 @@ export class GlOperationsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate pass status for a booking, sourced from the train schedule's Djibouti
|
||||||
|
* gate-pass operation (secured via the train-scheduling "Save as Secured"
|
||||||
|
* action) rather than a clearance milestone. For EXPORT bookings this also
|
||||||
|
* backfills the arrival-chain milestones once secured, same as the retired
|
||||||
|
* clearance-side grant action used to.
|
||||||
|
*/
|
||||||
|
async gatepassForBooking(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<{ granted: boolean; grantedAt: string | null }> {
|
||||||
|
const train = await this.trainState(bookingId);
|
||||||
|
if (!train.scheduleId) return { granted: false, grantedAt: null };
|
||||||
|
const operation = await this.dataSource
|
||||||
|
.getRepository(ImportDjiboutiOperation)
|
||||||
|
.findOne({ where: { trainScheduleId: train.scheduleId } });
|
||||||
|
const grantedAt = operation?.gatepassGrantedAt
|
||||||
|
? new Date(operation.gatepassGrantedAt).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (grantedAt) {
|
||||||
|
const booking = await this.getBooking(bookingId);
|
||||||
|
if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') {
|
||||||
|
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||||
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
|
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
||||||
|
if (byCode.get(code)?.status === 'PENDING') {
|
||||||
|
await this.milestoneService.completeForBooking(bookingId, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { granted: Boolean(grantedAt), grantedAt };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* T1 transit-document lifecycle state for an import shipment booking. Wagon
|
* T1 transit-document lifecycle state for an import shipment booking. Wagon
|
||||||
* allocation opens the upload window; train departure locks it; train arrival
|
* allocation opens the upload window; train departure locks it; train arrival
|
||||||
@@ -302,8 +337,11 @@ export class GlOperationsService {
|
|||||||
'The transport document must be uploaded before T1 can be closed.',
|
'The transport document must be uploaded before T1 can be closed.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!done('GATEPASS_GRANTED')) {
|
const gatepass = await this.gatepassForBooking(bookingId);
|
||||||
throw new BadRequestException('Grant the gate pass before closing T1.');
|
if (!gatepass.granted) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Secure the Djibouti gate pass on the train schedule before closing T1.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
||||||
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
||||||
@@ -322,182 +360,6 @@ export class GlOperationsService {
|
|||||||
'ARRIVED_AT_DJIBOUTI',
|
'ARRIVED_AT_DJIBOUTI',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* GL Djibouti grants the gate pass for a customs booking, capturing the time.
|
|
||||||
* Export: requires the train to have arrived at Djibouti; back-fills the
|
|
||||||
* arrival-chain milestones. Import: requires wagon allocation (pre-loading).
|
|
||||||
*/
|
|
||||||
async grantGatepass(
|
|
||||||
bookingId: string,
|
|
||||||
gatepassAt?: string,
|
|
||||||
userId?: string,
|
|
||||||
): Promise<{ bookingId: string; gatepassAt: string }> {
|
|
||||||
const booking = await this.getBooking(bookingId);
|
|
||||||
if (!booking.customsClearingEnabled) {
|
|
||||||
throw new BadRequestException('Gate pass applies to customs bookings only.');
|
|
||||||
}
|
|
||||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
|
||||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
|
||||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
|
||||||
|
|
||||||
const existing = byCode.get('GATEPASS_GRANTED');
|
|
||||||
if (existing?.status === 'COMPLETED') {
|
|
||||||
return {
|
|
||||||
bookingId,
|
|
||||||
gatepassAt:
|
|
||||||
existing.metadata?.gatepassAt ??
|
|
||||||
(existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const train = await this.trainState(bookingId);
|
|
||||||
if (tradeDirection === 'EXPORT') {
|
|
||||||
if (!train.arrivedAt) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
|
||||||
if (byCode.get(code)?.status === 'PENDING') {
|
|
||||||
await this.milestoneService.completeForBooking(bookingId, code, userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (!train.wagonAllocated) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Wagons must be allocated before the gate pass can be granted.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const at = gatepassAt?.trim() || new Date().toISOString();
|
|
||||||
await this.milestoneService.completeWithMetadataForBooking(
|
|
||||||
bookingId,
|
|
||||||
'GATEPASS_GRANTED',
|
|
||||||
{ gatepassAt: at },
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
return { bookingId, gatepassAt: at };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */
|
|
||||||
async djSchedules(): Promise<Freight.DjClearanceSchedule[]> {
|
|
||||||
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
|
||||||
relations: {
|
|
||||||
scheduleBookings: { booking: true },
|
|
||||||
originStation: true,
|
|
||||||
destinationStation: true,
|
|
||||||
},
|
|
||||||
order: { scheduledDepartureDate: 'DESC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const withCustoms = schedules
|
|
||||||
.filter((s) => s.status !== 'CANCELLED')
|
|
||||||
.map((s) => ({
|
|
||||||
schedule: s,
|
|
||||||
customs: (s.scheduleBookings ?? [])
|
|
||||||
.map((sb) => sb.booking)
|
|
||||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled)),
|
|
||||||
}))
|
|
||||||
.filter((s) => s.customs.length > 0);
|
|
||||||
|
|
||||||
const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id));
|
|
||||||
const gatepassRows = bookingIds.length
|
|
||||||
? await this.dataSource.getRepository(ClearanceMilestone).find({
|
|
||||||
where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m]));
|
|
||||||
|
|
||||||
return withCustoms.map(({ schedule, customs }) => {
|
|
||||||
const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))];
|
|
||||||
return {
|
|
||||||
id: schedule.id,
|
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
|
||||||
routeName: null,
|
|
||||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
|
||||||
destination:
|
|
||||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
|
||||||
status: schedule.status,
|
|
||||||
scheduledDepartureDate: schedule.scheduledDepartureDate
|
|
||||||
? new Date(schedule.scheduledDepartureDate).toISOString()
|
|
||||||
: null,
|
|
||||||
actualDepartureAt: schedule.actualDepartureAt
|
|
||||||
? new Date(schedule.actualDepartureAt).toISOString()
|
|
||||||
: null,
|
|
||||||
actualArrivalAt: schedule.actualArrivalAt
|
|
||||||
? new Date(schedule.actualArrivalAt).toISOString()
|
|
||||||
: null,
|
|
||||||
freightType:
|
|
||||||
freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null,
|
|
||||||
customsBookings: customs.map((b) => {
|
|
||||||
const m = gatepassByBooking.get(b.id);
|
|
||||||
const granted = m?.status === 'COMPLETED';
|
|
||||||
return {
|
|
||||||
bookingId: b.id,
|
|
||||||
reference: b.reference ?? b.id,
|
|
||||||
tradeDirection: b.tradeDirection ?? 'IMPORT',
|
|
||||||
contractId: b.contractId ?? null,
|
|
||||||
gatepassGranted: granted,
|
|
||||||
gatepassAt: granted
|
|
||||||
? (m?.metadata?.gatepassAt ??
|
|
||||||
(m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null))
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One-click gate pass for every customs booking on a train schedule. Per-booking
|
|
||||||
* guard failures are collected, not fatal. Import schedules also get the
|
|
||||||
* schedule-level ImportDjiboutiOperation gate pass so loading unblocks.
|
|
||||||
*/
|
|
||||||
async grantScheduleGatepass(
|
|
||||||
scheduleId: string,
|
|
||||||
gatepassAt?: string,
|
|
||||||
userId?: string,
|
|
||||||
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> {
|
|
||||||
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
|
||||||
where: { id: scheduleId },
|
|
||||||
relations: { scheduleBookings: { booking: true } },
|
|
||||||
});
|
|
||||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
|
||||||
|
|
||||||
const customs = (schedule.scheduleBookings ?? [])
|
|
||||||
.map((sb) => sb.booking)
|
|
||||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled));
|
|
||||||
if (customs.length === 0) {
|
|
||||||
throw new BadRequestException('No customs bookings ride this schedule.');
|
|
||||||
}
|
|
||||||
|
|
||||||
let granted = 0;
|
|
||||||
const skipped: Array<{ bookingId: string; error: string }> = [];
|
|
||||||
for (const booking of customs) {
|
|
||||||
try {
|
|
||||||
await this.grantGatepass(booking.id, gatepassAt, userId);
|
|
||||||
granted += 1;
|
|
||||||
} catch (e) {
|
|
||||||
skipped.push({
|
|
||||||
bookingId: booking.id,
|
|
||||||
error: e instanceof Error ? e.message : 'Failed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) {
|
|
||||||
const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
|
||||||
let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } });
|
|
||||||
if (!operation) {
|
|
||||||
operation = opRepo.create({ trainScheduleId: scheduleId });
|
|
||||||
}
|
|
||||||
if (!operation.gatepassGrantedAt) {
|
|
||||||
operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date();
|
|
||||||
await opRepo.save(operation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { granted, skipped };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ export class PaymentService {
|
|||||||
alreadyFinalized?: boolean;
|
alreadyFinalized?: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}> {
|
}> {
|
||||||
console.log(`Received payment event: ${JSON.stringify(event)}`);
|
this.logger.log(`Received payment event: ${JSON.stringify(event)}`);
|
||||||
if (event.eventType === "payment.succeeded") {
|
if (event.eventType === "payment.succeeded") {
|
||||||
const intent = await this.paymentRepo.findOneBy({
|
const intent = await this.paymentRepo.findOneBy({
|
||||||
refId: event.referenceId,
|
refId: event.referenceId,
|
||||||
@@ -490,13 +490,12 @@ export class PaymentService {
|
|||||||
reason: `No local intent for reference ${event.referenceId}`,
|
reason: `No local intent for reference ${event.referenceId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
console.log(`Processing payment succeeded event for intent: }`, intent);
|
|
||||||
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
||||||
providerTxnId: event.providerTxnId,
|
providerTxnId: event.providerTxnId,
|
||||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||||
notify: true,
|
notify: true,
|
||||||
});
|
});
|
||||||
console.log(
|
this.logger.log(
|
||||||
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
|
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -310,7 +310,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||||
const open = (
|
const open = (
|
||||||
await this.trainSchedulesRepository.findAll({
|
await this.trainSchedulesRepository.findAll({
|
||||||
where: { bookingWindowStatus: "OPEN" },
|
where: [
|
||||||
|
{ bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft },
|
||||||
|
{ bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
).filter((s) => s.windowPhase == null);
|
).filter((s) => s.windowPhase == null);
|
||||||
const groups = new Map<string, RouteDayGroup>();
|
const groups = new Map<string, RouteDayGroup>();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
|
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||||
@@ -1168,12 +1169,47 @@ export class TrainSchedulingService {
|
|||||||
notes: dto.notes ?? operation.notes ?? null,
|
notes: dto.notes ?? operation.notes ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
|
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
|
||||||
);
|
);
|
||||||
return this.getImportDjiboutiOperation(schedule.id);
|
return this.getImportDjiboutiOperation(schedule.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED
|
||||||
|
* milestone for every customs booking on this schedule, so contract/booking
|
||||||
|
* clearance views still reading that milestone (older deployed builds) see
|
||||||
|
* the gate pass as done. Drop once every clearance-api deployment reads
|
||||||
|
* ImportDjiboutiOperation.gatepassGrantedAt directly.
|
||||||
|
*/
|
||||||
|
private async completeGatepassMilestoneForSchedule(
|
||||||
|
scheduleId: string,
|
||||||
|
securedAt: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
const bookings = await this.dataSource.getRepository(Booking).find({
|
||||||
|
where: { trainScheduleId: scheduleId, customsClearingEnabled: true },
|
||||||
|
});
|
||||||
|
if (bookings.length === 0) return;
|
||||||
|
|
||||||
|
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
|
||||||
|
const rows = await milestoneRepo.find({
|
||||||
|
where: {
|
||||||
|
bookingId: In(bookings.map((b) => b.id)),
|
||||||
|
milestoneCode: 'GATEPASS_GRANTED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.status === 'COMPLETED') continue;
|
||||||
|
row.status = 'COMPLETED';
|
||||||
|
row.triggeredAt = securedAt;
|
||||||
|
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
|
||||||
|
await milestoneRepo.save(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||||
@@ -1265,7 +1301,9 @@ export class TrainSchedulingService {
|
|||||||
performedBy: 'DOCUMENT_GENERATION',
|
performedBy: 'DOCUMENT_GENERATION',
|
||||||
});
|
});
|
||||||
const html = this.buildImportLoadListHtml(loadList);
|
const html = this.buildImportLoadListHtml(loadList);
|
||||||
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
// Generic render — NOT the release-order fallback (would mislabel this as a
|
||||||
|
// gate-clearance / release order when Chromium is unavailable).
|
||||||
|
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
|
||||||
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
||||||
return {
|
return {
|
||||||
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||||
@@ -1283,7 +1321,8 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const html = this.buildExportLoadListHtml(schedule);
|
const html = this.buildExportLoadListHtml(schedule);
|
||||||
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
// Generic render — NOT the release-order fallback (see importLoadListDocument).
|
||||||
|
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
|
||||||
const reference = schedule.trainNumber ?? schedule.id;
|
const reference = schedule.trainNumber ?? schedule.id;
|
||||||
return {
|
return {
|
||||||
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||||
@@ -2049,7 +2088,9 @@ export class TrainSchedulingService {
|
|||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
id,
|
id,
|
||||||
TrainScheduleStatusEnum.Cancelled,
|
TrainScheduleStatusEnum.Cancelled,
|
||||||
{},
|
// Retire the booking window so a canceled schedule never lingers as an
|
||||||
|
// "open window" in booking-window lists or the legacy batch fill.
|
||||||
|
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
if (schedule.trainSetId) {
|
if (schedule.trainSetId) {
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render arbitrary document HTML to PDF via the shared renderer WITHOUT the
|
||||||
|
* release-order fallback. Non-release documents (e.g. the import/export
|
||||||
|
* marshalling load list) must use this so a Chromium-less fallback degrades to
|
||||||
|
* a plain-text dump of *their own* content — instead of masquerading as a
|
||||||
|
* "Warehouse Gate Clearance / Release Order", which the release-specific
|
||||||
|
* fallback would otherwise draw regardless of the input HTML.
|
||||||
|
*/
|
||||||
|
renderDocumentHtml(html: string, label = 'Document'): Promise<Buffer> {
|
||||||
|
return this.pdf.htmlToPdfBuffer(html, { label });
|
||||||
|
}
|
||||||
|
|
||||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||||
const doc = this.extractReleaseDocument(html);
|
const doc = this.extractReleaseDocument(html);
|
||||||
const body: string[] = [
|
const body: string[] = [
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
config({ path: resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../app.module';
|
||||||
|
import { PaidImportExportMileDemoSeeder } from '../seed/paid-import-export-mile-demo.seeder';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seeder = app.get(PaidImportExportMileDemoSeeder);
|
||||||
|
await seeder.run();
|
||||||
|
console.log('Paid import/export mile demo bookings seeded.');
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Paid import/export mile demo booking seed failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||||
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
||||||
|
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||||
|
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
|
||||||
|
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||||
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
|
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||||
|
|
||||||
|
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE';
|
||||||
|
const COMPANY_TIN = 'PAIDMILE001';
|
||||||
|
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
|
||||||
|
|
||||||
|
const YARDS = [
|
||||||
|
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
|
||||||
|
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONTAINER_TYPES = [
|
||||||
|
{ code: '20FT', label: '20FT', sizeFt: 20 },
|
||||||
|
{ code: '40FT', label: '40FT', sizeFt: 40 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Six paid, approved container bookings that mirror the real trucking legs:
|
||||||
|
* - EXPORT (Ethiopia -> Djibouti) carries a FIRST-MILE leg (factory -> rail terminal).
|
||||||
|
* - IMPORT (Djibouti -> Ethiopia) carries a LAST-MILE leg (dry port -> final delivery).
|
||||||
|
* Each booking is paymentStatus PAID and its single mile leg is marked paid + ready to transit.
|
||||||
|
*/
|
||||||
|
const DEMO_BOOKINGS = [
|
||||||
|
// ── IMPORT: last mile only ─────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
reference: 'PAID-IMP-001',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
containerCode: '40FT',
|
||||||
|
quantity: 8,
|
||||||
|
totalWeightTons: 224,
|
||||||
|
originCode: 'DJIBOUTI',
|
||||||
|
destinationCode: 'ADDIS_ABABA',
|
||||||
|
scheduledDate: '2026-07-01T08:00:00.000Z',
|
||||||
|
lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa',
|
||||||
|
lastMileDeliveryLat: 8.8808,
|
||||||
|
lastMileDeliveryLng: 38.7876,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'PAID-IMP-002',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
containerCode: '20FT',
|
||||||
|
quantity: 12,
|
||||||
|
totalWeightTons: 240,
|
||||||
|
originCode: 'DJIBOUTI',
|
||||||
|
destinationCode: 'ADDIS_ABABA',
|
||||||
|
scheduledDate: '2026-07-02T08:00:00.000Z',
|
||||||
|
lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa',
|
||||||
|
lastMileDeliveryLat: 8.9137,
|
||||||
|
lastMileDeliveryLng: 38.7815,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'PAID-IMP-003',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
containerCode: '40FT',
|
||||||
|
quantity: 6,
|
||||||
|
totalWeightTons: 180,
|
||||||
|
originCode: 'DJIBOUTI',
|
||||||
|
destinationCode: 'ADDIS_ABABA',
|
||||||
|
scheduledDate: '2026-07-03T08:00:00.000Z',
|
||||||
|
lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa',
|
||||||
|
lastMileDeliveryLat: 8.9806,
|
||||||
|
lastMileDeliveryLng: 38.8736,
|
||||||
|
},
|
||||||
|
// ── EXPORT: first mile only ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
reference: 'PAID-EXP-001',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
containerCode: '40FT',
|
||||||
|
quantity: 7,
|
||||||
|
totalWeightTons: 196,
|
||||||
|
originCode: 'ADDIS_ABABA',
|
||||||
|
destinationCode: 'DJIBOUTI',
|
||||||
|
scheduledDate: '2026-07-01T10:00:00.000Z',
|
||||||
|
firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa',
|
||||||
|
firstMilePickupLat: 8.9806,
|
||||||
|
firstMilePickupLng: 38.8736,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'PAID-EXP-002',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
containerCode: '20FT',
|
||||||
|
quantity: 11,
|
||||||
|
totalWeightTons: 220,
|
||||||
|
originCode: 'ADDIS_ABABA',
|
||||||
|
destinationCode: 'DJIBOUTI',
|
||||||
|
scheduledDate: '2026-07-02T10:00:00.000Z',
|
||||||
|
firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa',
|
||||||
|
firstMilePickupLat: 8.8808,
|
||||||
|
firstMilePickupLng: 38.7876,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'PAID-EXP-003',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
containerCode: '40FT',
|
||||||
|
quantity: 4,
|
||||||
|
totalWeightTons: 128,
|
||||||
|
originCode: 'ADDIS_ABABA',
|
||||||
|
destinationCode: 'DJIBOUTI',
|
||||||
|
scheduledDate: '2026-07-03T10:00:00.000Z',
|
||||||
|
firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa',
|
||||||
|
firstMilePickupLat: 8.9137,
|
||||||
|
firstMilePickupLng: 38.7815,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PaidImportExportMileDemoSeeder {
|
||||||
|
private readonly logger = new Logger(PaidImportExportMileDemoSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(Yard).upsert(
|
||||||
|
YARDS.map((yard) => ({ ...yard, isActive: true })),
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.getRepository(ServiceType).upsert(
|
||||||
|
{
|
||||||
|
code: SERVICE_TYPE_CODE,
|
||||||
|
serviceName: 'Rail Container with Paid First/Last Mile',
|
||||||
|
description: 'Demo service type for paid import/export bookings with a single mile leg',
|
||||||
|
canBeBookedAlone: true,
|
||||||
|
includesFirstMile: true,
|
||||||
|
includesLastMile: true,
|
||||||
|
includesCustoms: false,
|
||||||
|
priorityBonusPoints: 0,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 11,
|
||||||
|
},
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.getRepository(ContainerType).upsert(
|
||||||
|
CONTAINER_TYPES.map((containerType, index) => ({
|
||||||
|
...containerType,
|
||||||
|
wagonsPerUnit: 1,
|
||||||
|
isReefer: false,
|
||||||
|
isOpenTop: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: index + 1,
|
||||||
|
})),
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.getRepository(Company).upsert(
|
||||||
|
{
|
||||||
|
name: 'Paid Import/Export Mile Demo Customer',
|
||||||
|
type: CompanyType.Customer,
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
tin: COMPANY_TIN,
|
||||||
|
vatNumber: COMPANY_TIN,
|
||||||
|
fanNumber: 'PMD0000000000001',
|
||||||
|
country: 'Ethiopia',
|
||||||
|
address: 'Addis Ababa',
|
||||||
|
phone: '251900000202',
|
||||||
|
email: COMPANY_EMAIL,
|
||||||
|
website: null,
|
||||||
|
contactPersonName: 'Paid Mile Demo',
|
||||||
|
contactPersonPhone: '251900000202',
|
||||||
|
generalManagerName: 'Demo Manager',
|
||||||
|
generalManagerEmail: COMPANY_EMAIL,
|
||||||
|
generalManagerPhone: '251900000202',
|
||||||
|
},
|
||||||
|
{ conflictPaths: { tin: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [serviceType, company, yards, containerTypes] = await Promise.all([
|
||||||
|
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
||||||
|
manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }),
|
||||||
|
manager.getRepository(Yard).find(),
|
||||||
|
manager.getRepository(ContainerType).find(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
|
||||||
|
const containerTypeByCode = new Map(
|
||||||
|
containerTypes.map((containerType) => [containerType.code, containerType]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const demoBooking of DEMO_BOOKINGS) {
|
||||||
|
const origin = yardByCode.get(demoBooking.originCode);
|
||||||
|
const destination = yardByCode.get(demoBooking.destinationCode);
|
||||||
|
const containerType = containerTypeByCode.get(demoBooking.containerCode);
|
||||||
|
|
||||||
|
if (!origin || !destination || !containerType) {
|
||||||
|
throw new Error(`paid_import_export_mile_demo_dependency_missing:${demoBooking.reference}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImport = demoBooking.tradeDirection === 'IMPORT';
|
||||||
|
const wagonsRequired =
|
||||||
|
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
||||||
|
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
||||||
|
|
||||||
|
await manager.getRepository(Booking).upsert(
|
||||||
|
{
|
||||||
|
reference: demoBooking.reference,
|
||||||
|
companyId: company.id,
|
||||||
|
status: 'APPROVED',
|
||||||
|
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||||
|
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
|
||||||
|
totalAmount: demoBooking.totalWeightTons * 25,
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
contractType: 'NEW',
|
||||||
|
serviceTypeId: serviceType.id,
|
||||||
|
// Only the leg that matches the trade direction carries an address.
|
||||||
|
firstMilePickupAddress: isImport ? null : demoBooking.firstMilePickupAddress,
|
||||||
|
firstMilePickupLat: isImport ? null : demoBooking.firstMilePickupLat,
|
||||||
|
firstMilePickupLng: isImport ? null : demoBooking.firstMilePickupLng,
|
||||||
|
lastMileDeliveryAddress: isImport ? demoBooking.lastMileDeliveryAddress : null,
|
||||||
|
lastMileDeliveryLat: isImport ? demoBooking.lastMileDeliveryLat : null,
|
||||||
|
lastMileDeliveryLng: isImport ? demoBooking.lastMileDeliveryLng : null,
|
||||||
|
equipmentReturn: 'WITHOUT_RETURN',
|
||||||
|
originYardId: origin.id,
|
||||||
|
destinationYardId: destination.id,
|
||||||
|
tradeDirection: demoBooking.tradeDirection,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTypeId: null,
|
||||||
|
cargoFreeText: 'Demo container cargo',
|
||||||
|
shippingLineId: null,
|
||||||
|
cargoTotalWeightVgm: demoBooking.totalWeightTons,
|
||||||
|
isHazardous: false,
|
||||||
|
isReefer: false,
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
approvedByStaffAt: new Date(),
|
||||||
|
priorityScore: 20,
|
||||||
|
wagonsRequired,
|
||||||
|
schedulingStatus: 'NOT_SCHEDULED',
|
||||||
|
versionNumber: 1,
|
||||||
|
},
|
||||||
|
{ conflictPaths: { reference: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const booking = await manager.getRepository(Booking).findOneByOrFail({
|
||||||
|
reference: demoBooking.reference,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
|
||||||
|
await manager.getRepository(BookingContainer).insert({
|
||||||
|
id: randomUUID(),
|
||||||
|
bookingId: booking.id,
|
||||||
|
containerTypeId: containerType.id,
|
||||||
|
quantity: demoBooking.quantity,
|
||||||
|
vgmPerUnitTons,
|
||||||
|
totalVgmTons: demoBooking.totalWeightTons,
|
||||||
|
wagonsRequired,
|
||||||
|
weightLimitRuleId: null,
|
||||||
|
isOverweight: vgmPerUnitTons > 35,
|
||||||
|
overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset any existing legs for idempotency, then create the single paid leg.
|
||||||
|
await manager.getRepository(FirstMile).delete({ bookingId: booking.id });
|
||||||
|
await manager.getRepository(LastMile).delete({ bookingId: booking.id });
|
||||||
|
|
||||||
|
const paidAmount = demoBooking.totalWeightTons * 25;
|
||||||
|
|
||||||
|
if (isImport) {
|
||||||
|
await manager.getRepository(LastMile).insert({
|
||||||
|
bookingId: booking.id,
|
||||||
|
status: 'READY_TO_TRANSIT',
|
||||||
|
advancedPayment: paidAmount,
|
||||||
|
remainingPayment: 0,
|
||||||
|
paid: true,
|
||||||
|
estimatedKm: 22,
|
||||||
|
exactKm: null,
|
||||||
|
vehicleId: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await manager.getRepository(FirstMile).insert({
|
||||||
|
bookingId: booking.id,
|
||||||
|
status: 'READY_TO_TRANSIT',
|
||||||
|
advancedPayment: paidAmount,
|
||||||
|
remainingPayment: 0,
|
||||||
|
paid: true,
|
||||||
|
estimatedKm: 18,
|
||||||
|
exactKm: null,
|
||||||
|
vehicleId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
'Seeded 6 paid bookings: 3 import (last-mile) + 3 export (first-mile).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DateInput, DateTimePicker } from "@mantine/dates";
|
import { DateInput } from "@mantine/dates";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -397,15 +397,10 @@ export function ExportClearanceStepper({
|
|||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
label="Gate pass"
|
label="Gate pass"
|
||||||
description="GL Djibouti grants after arrival"
|
description="Secured on the train schedule after arrival"
|
||||||
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
|
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
|
||||||
>
|
>
|
||||||
<GatepassStep
|
<GatepassStep clearance={clearance} />
|
||||||
bookingId={actionBookingId}
|
|
||||||
clearance={clearance}
|
|
||||||
canAct={showDj && canDj}
|
|
||||||
onChanged={onChanged}
|
|
||||||
/>
|
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
@@ -491,27 +486,19 @@ function ConfirmExportReleaseFallback({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GatepassStep({
|
/**
|
||||||
bookingId,
|
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
||||||
clearance,
|
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
|
||||||
canAct,
|
*/
|
||||||
onChanged,
|
function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
|
||||||
}: {
|
const scheduleId = clearance.train?.scheduleId ?? null;
|
||||||
bookingId: string | null;
|
|
||||||
clearance: ClearanceViewLike;
|
|
||||||
canAct: boolean;
|
|
||||||
onChanged?: () => void;
|
|
||||||
}) {
|
|
||||||
const [opened, setOpened] = useState(false);
|
|
||||||
const [at, setAt] = useState<Date | null>(new Date());
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
if (clearance.gatepassGranted) {
|
if (clearance.gatepassGranted) {
|
||||||
return (
|
return (
|
||||||
<StepStatus
|
<StepStatus
|
||||||
done
|
done
|
||||||
pendingLabel=""
|
pendingLabel=""
|
||||||
doneLabel={`Gate pass granted${
|
doneLabel={`Gate pass secured${
|
||||||
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -526,68 +513,21 @@ function GatepassStep({
|
|||||||
done={false}
|
done={false}
|
||||||
pendingLabel={
|
pendingLabel={
|
||||||
arrived
|
arrived
|
||||||
? "Train arrived — GL Djibouti can grant the gate pass."
|
? "Train arrived — secure the gate pass on the train schedule."
|
||||||
: "Available once the train arrives at Djibouti."
|
: "Available once the train arrives at Djibouti."
|
||||||
}
|
}
|
||||||
doneLabel=""
|
doneLabel=""
|
||||||
/>
|
/>
|
||||||
{canAct && bookingId ? (
|
{scheduleId ? (
|
||||||
<>
|
<Button
|
||||||
<Button
|
component="a"
|
||||||
color="edr-green"
|
href={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||||
leftSection={<Truck size={16} />}
|
variant="light"
|
||||||
disabled={!arrived}
|
color="edr-green"
|
||||||
onClick={() => {
|
leftSection={<Truck size={16} />}
|
||||||
setAt(new Date());
|
>
|
||||||
setOpened(true);
|
Secure gate pass on train schedule
|
||||||
}}
|
</Button>
|
||||||
>
|
|
||||||
Grant gate pass
|
|
||||||
</Button>
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={() => setOpened(false)}
|
|
||||||
title={<Text fw={700}>Grant gate pass</Text>}
|
|
||||||
radius="md"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<DateTimePicker
|
|
||||||
label="Gate pass time"
|
|
||||||
value={at}
|
|
||||||
onChange={(v) => setAt(v ? new Date(v) : null)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={() => setOpened(false)} disabled={loading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
loading={loading}
|
|
||||||
onClick={async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await contractsService.grantGatepass(
|
|
||||||
bookingId,
|
|
||||||
(at ?? new Date()).toISOString(),
|
|
||||||
);
|
|
||||||
toast.success("Gate pass granted");
|
|
||||||
setOpened(false);
|
|
||||||
onChanged?.();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Failed");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Grant
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ export function GlUpcomingWindowsSection() {
|
|||||||
const rows = (data ?? []).filter(
|
const rows = (data ?? []).filter(
|
||||||
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
|
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
|
||||||
);
|
);
|
||||||
|
// Canceled schedules are retired to windowPhase='DONE' server-side, so the
|
||||||
|
// guard above already excludes them; they never reach the upcoming list.
|
||||||
// Open lanes first, then by opening time.
|
// Open lanes first, then by opening time.
|
||||||
return rows.sort((a, b) => {
|
return rows.sort((a, b) => {
|
||||||
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
|
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Paper,
|
Paper,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DateTimePicker } from "@mantine/dates";
|
|
||||||
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||||
import {
|
import {
|
||||||
TransitPermitMultiUpload,
|
TransitPermitMultiUpload,
|
||||||
@@ -536,17 +534,12 @@ export function PhasedClearanceActionPanel({
|
|||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
label="Gate pass"
|
label="Gate pass"
|
||||||
description="GL Djibouti grants after wagon allocation"
|
description="Secured on the train schedule after wagon allocation"
|
||||||
icon={
|
icon={
|
||||||
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ImportGatepassStep
|
<ImportGatepassStep clearance={clearance} />
|
||||||
bookingId={actionBookingId}
|
|
||||||
clearance={clearance}
|
|
||||||
canAct={showDj && canDj}
|
|
||||||
onChanged={onChanged}
|
|
||||||
/>
|
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
@@ -816,28 +809,19 @@ function ImportT1Section({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GL DJ grants the import gate pass once wagons are allocated (captures time). */
|
/**
|
||||||
function ImportGatepassStep({
|
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
||||||
bookingId,
|
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
|
||||||
clearance,
|
*/
|
||||||
canAct,
|
function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
|
||||||
onChanged,
|
const scheduleId = clearance.train?.scheduleId ?? null;
|
||||||
}: {
|
|
||||||
bookingId: string | null;
|
|
||||||
clearance: ClearanceViewLike;
|
|
||||||
canAct: boolean;
|
|
||||||
onChanged?: () => void;
|
|
||||||
}) {
|
|
||||||
const [opened, setOpened] = useState(false);
|
|
||||||
const [at, setAt] = useState<Date | null>(new Date());
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
if (clearance.gatepassGranted) {
|
if (clearance.gatepassGranted) {
|
||||||
return (
|
return (
|
||||||
<StepStatus
|
<StepStatus
|
||||||
done
|
done
|
||||||
pendingLabel=""
|
pendingLabel=""
|
||||||
doneLabel={`Gate pass granted${
|
doneLabel={`Gate pass secured${
|
||||||
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -852,68 +836,21 @@ function ImportGatepassStep({
|
|||||||
done={false}
|
done={false}
|
||||||
pendingLabel={
|
pendingLabel={
|
||||||
wagonAllocated
|
wagonAllocated
|
||||||
? "Wagons allocated — GL Djibouti can grant the gate pass."
|
? "Wagons allocated — secure the gate pass on the train schedule."
|
||||||
: "Available once wagons are allocated."
|
: "Available once wagons are allocated."
|
||||||
}
|
}
|
||||||
doneLabel=""
|
doneLabel=""
|
||||||
/>
|
/>
|
||||||
{canAct && bookingId ? (
|
{scheduleId ? (
|
||||||
<>
|
<Button
|
||||||
<Button
|
component="a"
|
||||||
color="edr-green"
|
href={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||||
leftSection={<Truck size={16} />}
|
variant="light"
|
||||||
disabled={!wagonAllocated}
|
color="edr-green"
|
||||||
onClick={() => {
|
leftSection={<Truck size={16} />}
|
||||||
setAt(new Date());
|
>
|
||||||
setOpened(true);
|
Secure gate pass on train schedule
|
||||||
}}
|
</Button>
|
||||||
>
|
|
||||||
Grant gate pass
|
|
||||||
</Button>
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={() => setOpened(false)}
|
|
||||||
title={<Text fw={700}>Grant gate pass</Text>}
|
|
||||||
radius="md"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<DateTimePicker
|
|
||||||
label="Gate pass time"
|
|
||||||
value={at}
|
|
||||||
onChange={(v) => setAt(v ? new Date(v) : null)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={() => setOpened(false)} disabled={loading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
loading={loading}
|
|
||||||
onClick={async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await contractsService.grantGatepass(
|
|
||||||
bookingId,
|
|
||||||
(at ?? new Date()).toISOString(),
|
|
||||||
);
|
|
||||||
toast.success("Gate pass granted");
|
|
||||||
setOpened(false);
|
|
||||||
onChanged?.();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Failed");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Grant
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export const QUERY_KEYS = {
|
|||||||
["contracts", "clearance-queue", region ?? "ET"] as const,
|
["contracts", "clearance-queue", region ?? "ET"] as const,
|
||||||
clearanceHistory: (region?: string) =>
|
clearanceHistory: (region?: string) =>
|
||||||
["contracts", "clearance-history", region ?? "ET"] as const,
|
["contracts", "clearance-history", region ?? "ET"] as const,
|
||||||
djSchedules: ["contracts", "clearance-dj-schedules"] as const,
|
|
||||||
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
||||||
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
||||||
bookingMilestones: (bookingId: string) =>
|
bookingMilestones: (bookingId: string) =>
|
||||||
|
|||||||
@@ -232,11 +232,6 @@ export const URL_CONSTANTS = {
|
|||||||
`/contracts/bookings/${bookingId}/t1-documents`,
|
`/contracts/bookings/${bookingId}/t1-documents`,
|
||||||
BOOKING_T1_CLOSE: (bookingId: string) =>
|
BOOKING_T1_CLOSE: (bookingId: string) =>
|
||||||
`/contracts/bookings/${bookingId}/t1-close`,
|
`/contracts/bookings/${bookingId}/t1-close`,
|
||||||
CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules",
|
|
||||||
CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) =>
|
|
||||||
`/contracts/clearance/schedules/${scheduleId}/gatepass`,
|
|
||||||
BOOKING_GATEPASS: (bookingId: string) =>
|
|
||||||
`/contracts/bookings/${bookingId}/gatepass`,
|
|
||||||
BOOKING_FINAL_INVOICE: (bookingId: string) =>
|
BOOKING_FINAL_INVOICE: (bookingId: string) =>
|
||||||
`/contracts/bookings/${bookingId}/final-invoice`,
|
`/contracts/bookings/${bookingId}/final-invoice`,
|
||||||
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
|
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
|
||||||
|
|||||||
@@ -68,15 +68,6 @@ export function useDjClearanceQueue(enabled = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
|
|
||||||
export function useDjClearanceSchedules(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: QUERY_KEYS.CONTRACTS.djSchedules,
|
|
||||||
queryFn: () => contractsService.getDjClearanceSchedules(),
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
||||||
export function useOpsClearanceQueue(enabled = true) {
|
export function useOpsClearanceQueue(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -1,326 +1,65 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||||
Badge,
|
import { ChevronRight, Ship } from "lucide-react";
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Tabs,
|
|
||||||
Text,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { DateTimePicker } from "@mantine/dates";
|
|
||||||
import { ChevronRight, Ship, Train, Truck } from "lucide-react";
|
|
||||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import {
|
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||||
useDjClearanceQueue,
|
|
||||||
useDjClearanceSchedules,
|
|
||||||
} from "@/hooks/contracts/useContracts";
|
|
||||||
import { contractsService } from "@/services/contracts.service";
|
|
||||||
|
|
||||||
export default function GlDjiboutiClearanceListPage() {
|
export default function GlDjiboutiClearanceListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||||
const schedulesQuery = useDjClearanceSchedules();
|
|
||||||
|
|
||||||
const contractItems = contractQueue?.items ?? [];
|
const contractItems = contractQueue?.items ?? [];
|
||||||
const scheduleItems = schedulesQuery.data ?? [];
|
|
||||||
|
|
||||||
const [gatepassTarget, setGatepassTarget] =
|
|
||||||
useState<Freight.DjClearanceSchedule | null>(null);
|
|
||||||
const [gatepassAt, setGatepassAt] = useState<Date | null>(new Date());
|
|
||||||
const [granting, setGranting] = useState(false);
|
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<Freight.DjClearanceSchedule>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
header: "Train",
|
|
||||||
accessorKey: "trainNumber",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm" fw={700}>
|
|
||||||
{row.original.trainNumber ?? "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Route",
|
|
||||||
id: "route",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm">
|
|
||||||
{row.original.origin ?? "—"} → {row.original.destination ?? "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Scheduled departure",
|
|
||||||
id: "scheduled",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm">
|
|
||||||
{row.original.scheduledDepartureDate
|
|
||||||
? new Date(row.original.scheduledDepartureDate).toLocaleDateString()
|
|
||||||
: "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Departed",
|
|
||||||
id: "departed",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm">
|
|
||||||
{row.original.actualDepartureAt
|
|
||||||
? new Date(row.original.actualDepartureAt).toLocaleString()
|
|
||||||
: "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Arrived",
|
|
||||||
id: "arrived",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm">
|
|
||||||
{row.original.actualArrivalAt
|
|
||||||
? new Date(row.original.actualArrivalAt).toLocaleString()
|
|
||||||
: "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Status",
|
|
||||||
accessorKey: "status",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge variant="light" color={statusColor(row.original.status)} radius="sm">
|
|
||||||
{row.original.status}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Customs bookings",
|
|
||||||
id: "customs",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const bookings = row.original.customsBookings;
|
|
||||||
const directions = [...new Set(bookings.map((b) => b.tradeDirection))];
|
|
||||||
return (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<Badge variant="light" color="edr-green" radius="sm">
|
|
||||||
{bookings.length}
|
|
||||||
</Badge>
|
|
||||||
{directions.map((d) => (
|
|
||||||
<Badge key={d} variant="outline" color={d === "IMPORT" ? "edr-green" : "blue"} radius="sm">
|
|
||||||
{d}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Gate pass",
|
|
||||||
id: "gatepass",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const bookings = row.original.customsBookings;
|
|
||||||
const allGranted =
|
|
||||||
bookings.length > 0 && bookings.every((b) => b.gatepassGranted);
|
|
||||||
const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null;
|
|
||||||
if (allGranted) {
|
|
||||||
return (
|
|
||||||
<Badge variant="light" color="edr-green" radius="sm">
|
|
||||||
Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Truck size={14} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setGatepassAt(new Date());
|
|
||||||
setGatepassTarget(row.original);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Gate pass
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="GL Djibouti — Clearance"
|
title="GL Djibouti — Clearance"
|
||||||
subtitle="Customs contracts handed off to Djibouti GL, plus train schedules for gate-pass control."
|
subtitle="Customs contracts handed off to Djibouti GL."
|
||||||
/>
|
/>
|
||||||
<Tabs defaultValue="contracts" keepMounted={false}>
|
{contractsLoading ? (
|
||||||
<Tabs.List mb="md">
|
<Group justify="center" py={60}>
|
||||||
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
|
<Loader color="edr-green" />
|
||||||
<Tabs.Tab value="schedules" leftSection={<Train size={14} />}>
|
</Group>
|
||||||
Schedules ({scheduleItems.length})
|
) : (
|
||||||
</Tabs.Tab>
|
<Stack gap="sm">
|
||||||
</Tabs.List>
|
{contractItems.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
<Tabs.Panel value="contracts">
|
No Djibouti customs contracts yet.
|
||||||
{contractsLoading ? (
|
|
||||||
<Group justify="center" py={60}>
|
|
||||||
<Loader color="edr-green" />
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Stack gap="sm">
|
|
||||||
{contractItems.length === 0 ? (
|
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
|
||||||
No Djibouti customs contracts yet.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
contractItems.map((c) => (
|
|
||||||
<Card
|
|
||||||
key={c.id}
|
|
||||||
withBorder
|
|
||||||
radius="md"
|
|
||||||
padding="md"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="sm">
|
|
||||||
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
|
||||||
<div>
|
|
||||||
<Text fw={700}>{c.reference}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{c.tradeDirection} · {c.status}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Badge variant="light" color="edr-green">
|
|
||||||
Contract
|
|
||||||
</Badge>
|
|
||||||
<ChevronRight size={18} className="text-muted-foreground" />
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="schedules">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={scheduleItems}
|
|
||||||
status={
|
|
||||||
schedulesQuery.isLoading
|
|
||||||
? "loading"
|
|
||||||
: schedulesQuery.isError
|
|
||||||
? "error"
|
|
||||||
: "success"
|
|
||||||
}
|
|
||||||
error={
|
|
||||||
schedulesQuery.isError
|
|
||||||
? {
|
|
||||||
message: "Failed to load train schedules.",
|
|
||||||
onRetry: () => void schedulesQuery.refetch(),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
emptyMessage="No train schedules carry customs bookings yet."
|
|
||||||
/>
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={gatepassTarget != null}
|
|
||||||
onClose={() => setGatepassTarget(null)}
|
|
||||||
title={
|
|
||||||
<Group gap={8}>
|
|
||||||
<Truck size={18} />
|
|
||||||
<Text fw={700}>
|
|
||||||
Gate pass — train {gatepassTarget?.trainNumber ?? ""}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
) : (
|
||||||
}
|
contractItems.map((c) => (
|
||||||
radius="md"
|
<Card
|
||||||
size="sm"
|
key={c.id}
|
||||||
>
|
withBorder
|
||||||
<Stack gap="md">
|
radius="md"
|
||||||
<Text size="sm" c="dimmed">
|
padding="md"
|
||||||
Grants the gate pass for all{" "}
|
style={{ cursor: "pointer" }}
|
||||||
{gatepassTarget?.customsBookings.length ?? 0} customs booking
|
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||||
{(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this
|
>
|
||||||
train.
|
<Group justify="space-between" wrap="nowrap">
|
||||||
</Text>
|
<Group gap="sm">
|
||||||
<DateTimePicker
|
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||||
label="Gate pass time"
|
<div>
|
||||||
value={gatepassAt}
|
<Text fw={700}>{c.reference}</Text>
|
||||||
onChange={(v) => setGatepassAt(v ? new Date(v) : null)}
|
<Text size="sm" c="dimmed">
|
||||||
required
|
{c.tradeDirection} · {c.status}
|
||||||
/>
|
</Text>
|
||||||
<Group justify="flex-end">
|
</div>
|
||||||
<Button
|
</Group>
|
||||||
variant="default"
|
<Group gap="xs">
|
||||||
onClick={() => setGatepassTarget(null)}
|
<Badge variant="light" color="edr-green">
|
||||||
disabled={granting}
|
Contract
|
||||||
>
|
</Badge>
|
||||||
Cancel
|
<ChevronRight size={18} className="text-muted-foreground" />
|
||||||
</Button>
|
</Group>
|
||||||
<Button
|
</Group>
|
||||||
color="edr-green"
|
</Card>
|
||||||
loading={granting}
|
))
|
||||||
leftSection={<Truck size={16} />}
|
)}
|
||||||
onClick={async () => {
|
|
||||||
if (!gatepassTarget) return;
|
|
||||||
setGranting(true);
|
|
||||||
try {
|
|
||||||
const result = await contractsService.grantScheduleGatepass(
|
|
||||||
gatepassTarget.id,
|
|
||||||
(gatepassAt ?? new Date()).toISOString(),
|
|
||||||
);
|
|
||||||
if (result.skipped.length > 0) {
|
|
||||||
toast.error(
|
|
||||||
`${result.granted} granted, ${result.skipped.length} skipped: ${result.skipped[0]?.error ?? ""}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.success(
|
|
||||||
`Gate pass granted for ${result.granted} booking${result.granted === 1 ? "" : "s"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setGatepassTarget(null);
|
|
||||||
void schedulesQuery.refetch();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Failed");
|
|
||||||
} finally {
|
|
||||||
setGranting(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Grant gate pass
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "SCHEDULED":
|
|
||||||
return "blue";
|
|
||||||
case "DISPATCHED":
|
|
||||||
return "yellow";
|
|
||||||
case "ARRIVED":
|
|
||||||
return "edr-green";
|
|
||||||
default:
|
|
||||||
return "gray";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -391,35 +391,6 @@ export const contractsService = {
|
|||||||
return unwrap(response.data) as Freight.ClearanceT1State;
|
return unwrap(response.data) as Freight.ClearanceT1State;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
|
|
||||||
getDjClearanceSchedules: async (): Promise<Freight.DjClearanceSchedule[]> => {
|
|
||||||
const response = await client.get(C.CLEARANCE_DJ_SCHEDULES);
|
|
||||||
return unwrap(response.data) as Freight.DjClearanceSchedule[];
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Gate pass for every customs booking on a train schedule (captures time). */
|
|
||||||
grantScheduleGatepass: async (
|
|
||||||
scheduleId: string,
|
|
||||||
gatepassAt?: string,
|
|
||||||
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => {
|
|
||||||
const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), {
|
|
||||||
gatepassAt,
|
|
||||||
});
|
|
||||||
return unwrap(response.data) as {
|
|
||||||
granted: number;
|
|
||||||
skipped: Array<{ bookingId: string; error: string }>;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Gate pass for a single customs booking (captures time). */
|
|
||||||
grantGatepass: async (
|
|
||||||
bookingId: string,
|
|
||||||
gatepassAt?: string,
|
|
||||||
): Promise<{ bookingId: string; gatepassAt: string }> => {
|
|
||||||
const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt });
|
|
||||||
return unwrap(response.data) as { bookingId: string; gatepassAt: string };
|
|
||||||
},
|
|
||||||
|
|
||||||
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
|
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
|
||||||
sendFinalInvoice: async (
|
sendFinalInvoice: async (
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
|||||||
@@ -173,5 +173,6 @@ export const URL_CONSTANTS = {
|
|||||||
BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`,
|
BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`,
|
||||||
DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`,
|
DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`,
|
||||||
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
||||||
|
PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ export function CustomerTruckAssignmentCard({
|
|||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Physical container numbers on this booking — the customer picks which one to
|
||||||
|
// load onto the truck instead of typing it. Falls back to free entry when the
|
||||||
|
// booking has no container numbers recorded.
|
||||||
|
const containerOptions = booking.containerNumbers ?? [];
|
||||||
|
|
||||||
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
|
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
|
||||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
||||||
|
|
||||||
@@ -120,13 +125,27 @@ export function CustomerTruckAssignmentCard({
|
|||||||
onChange={(value) => setTruckType(value ?? "")}
|
onChange={(value) => setTruckType(value ?? "")}
|
||||||
disabled={assigned}
|
disabled={assigned}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
{containerOptions.length > 0 ? (
|
||||||
label="Container Number to Load"
|
<Select
|
||||||
required
|
label="Container Number to Load"
|
||||||
value={containerNumberToLoad}
|
required
|
||||||
onChange={(e) => setContainerNumberToLoad(e.currentTarget.value.toUpperCase())}
|
placeholder="Select a container from this booking"
|
||||||
readOnly={assigned}
|
data={containerOptions}
|
||||||
/>
|
value={containerNumberToLoad || null}
|
||||||
|
onChange={(value) => setContainerNumberToLoad(value ?? "")}
|
||||||
|
searchable
|
||||||
|
disabled={assigned}
|
||||||
|
nothingFoundMessage="No matching container"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
label="Container Number to Load"
|
||||||
|
required
|
||||||
|
value={containerNumberToLoad}
|
||||||
|
onChange={(e) => setContainerNumberToLoad(e.currentTarget.value.toUpperCase())}
|
||||||
|
readOnly={assigned}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Download, Receipt } from "lucide-react";
|
import { CreditCard, Download, Receipt } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
import {
|
import {
|
||||||
warehouseInvoicesService,
|
warehouseInvoicesService,
|
||||||
type PortalWarehouseInvoice,
|
type PortalWarehouseInvoice,
|
||||||
} from "@/services/warehouse-invoices.service";
|
} from "@/services/warehouse-invoices.service";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
|
|
||||||
|
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
|
/** Warehouse fee invoices the customer can still settle online. */
|
||||||
|
const PAYABLE_STATUSES = new Set(["ISSUED", "PARTIALLY_PAID"]);
|
||||||
|
const isPayable = (inv: PortalWarehouseInvoice) =>
|
||||||
|
PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0;
|
||||||
|
|
||||||
const money = (amount: number | string | null | undefined, currency: string) =>
|
const money = (amount: number | string | null | undefined, currency: string) =>
|
||||||
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
|
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
|
||||||
|
|
||||||
@@ -44,10 +52,12 @@ function StatusPill({ status }: { status: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Warehouse fee invoices linked to this booking — display + PDF download only.
|
* Warehouse fee invoices linked to this booking. Customers can pay outstanding
|
||||||
* Paying them online is tracked separately (in-system demurrage/storage
|
* demurrage/storage invoices online (Telebirr/Waafi) so they can then sign the
|
||||||
* payment). Renders nothing when the booking has no warehouse fees. Carries
|
* delivery handover; paid invoices expose the receipt PDF. The backoffice cash
|
||||||
* `id="warehouse-payments"` so the invoice detail page can deep-link here.
|
* `/pay` (record-a-payment) path is unaffected. Renders nothing when the booking
|
||||||
|
* has no warehouse fees. Carries `id="warehouse-payments"` so the invoice detail
|
||||||
|
* page can deep-link here.
|
||||||
*/
|
*/
|
||||||
export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||||
const { data: invoices = [] } = useQuery({
|
const { data: invoices = [] } = useQuery({
|
||||||
@@ -55,6 +65,41 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
queryFn: () => warehouseInvoicesService.listForBooking(bookingId),
|
queryFn: () => warehouseInvoicesService.listForBooking(bookingId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
||||||
|
|
||||||
|
const payMutation = useMutation({
|
||||||
|
mutationFn: (method: PaymentMethod) => {
|
||||||
|
if (!payInvoice) throw new Error("No invoice selected for payment.");
|
||||||
|
return warehouseInvoicesService.payOnline(payInvoice.id, {
|
||||||
|
method,
|
||||||
|
platform: "web",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data, method) => {
|
||||||
|
if (!payInvoice) return;
|
||||||
|
// Redirect to the provider (or the fallback checkout page) — same as the
|
||||||
|
// booking "Pay now" flow, so behaviour is identical everywhere.
|
||||||
|
const redirectUrl =
|
||||||
|
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||||
|
? data.clientAction.url
|
||||||
|
: paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method });
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payError = payMutation.isError
|
||||||
|
? payMutation.error instanceof Error
|
||||||
|
? payMutation.error.message
|
||||||
|
: "Could not start payment. Please try again."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const closePayModal = () => {
|
||||||
|
if (!payMutation.isPending) {
|
||||||
|
setPayInvoice(null);
|
||||||
|
payMutation.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (invoices.length === 0) return null;
|
if (invoices.length === 0) return null;
|
||||||
|
|
||||||
const download = async (inv: PortalWarehouseInvoice) => {
|
const download = async (inv: PortalWarehouseInvoice) => {
|
||||||
@@ -128,6 +173,17 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{isPayable(inv) && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius={10}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CreditCard size={14} />}
|
||||||
|
onClick={() => setPayInvoice(inv)}
|
||||||
|
>
|
||||||
|
Pay
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -151,6 +207,18 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<PaymentMethodModal
|
||||||
|
opened={payInvoice !== null}
|
||||||
|
onClose={closePayModal}
|
||||||
|
amountLabel={
|
||||||
|
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
||||||
|
}
|
||||||
|
currency={payInvoice?.currency}
|
||||||
|
onConfirm={(method) => payMutation.mutate(method)}
|
||||||
|
processing={payMutation.isPending}
|
||||||
|
error={payError}
|
||||||
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import { client } from "../utils/api";
|
import { client } from "../utils/api";
|
||||||
|
import type {
|
||||||
|
InitiateResponse,
|
||||||
|
PaymentMethod,
|
||||||
|
PaymentPlatform,
|
||||||
|
} from "./payments.service";
|
||||||
|
|
||||||
const W = URL_CONSTANTS.WAREHOUSE_INVOICES;
|
const W = URL_CONSTANTS.WAREHOUSE_INVOICES;
|
||||||
|
|
||||||
@@ -51,4 +56,27 @@ export const warehouseInvoicesService = {
|
|||||||
const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" });
|
const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a Telebirr/Waafi online payment for a warehouse demurrage/storage
|
||||||
|
* invoice. Returns the payment intent + `clientAction` to redirect the browser
|
||||||
|
* to the provider (mirrors the booking `/pay` flow). The backoffice cash
|
||||||
|
* `/pay` (record-a-payment) path is unaffected.
|
||||||
|
*/
|
||||||
|
payOnline: async (
|
||||||
|
id: string,
|
||||||
|
payload: {
|
||||||
|
method: PaymentMethod;
|
||||||
|
platform?: PaymentPlatform;
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
},
|
||||||
|
): Promise<InitiateResponse> => {
|
||||||
|
const { data } = await client.post(W.PAY_ONLINE(id), {
|
||||||
|
platform: "web",
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes
|
|||||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { PassengerAuthService } from './passenger-auth.service';
|
import { PassengerAuthService } from './passenger-auth.service';
|
||||||
import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
|
import { RegisterDto, LoginDto, ResendRegistrationCodeDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
|
|
||||||
@ApiTags('Passenger Auth')
|
@ApiTags('Passenger Auth')
|
||||||
@@ -14,14 +14,28 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@IsPublic()
|
@IsPublic()
|
||||||
@ApiOperation({ summary: 'Register new passenger account' })
|
@ApiOperation({ summary: 'Register new passenger account (sends SMS verification code)' })
|
||||||
@ApiResponse({ status: 201, description: 'Account created. Returns token + user.' })
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description:
|
||||||
|
'Account created as pending. A verification code is sent via SMS — complete signup via PATCH /v1/auth/set-password.',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
|
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
|
||||||
@ApiBody({ type: RegisterDto })
|
@ApiBody({ type: RegisterDto })
|
||||||
register(@Request() req: any, @Body() dto: RegisterDto) {
|
register(@Request() req: any, @Body() dto: RegisterDto) {
|
||||||
return this.passengerAuthService.register(dto, req);
|
return this.passengerAuthService.register(dto, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('register/resend-code')
|
||||||
|
@IsPublic()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Resend the registration verification code for a pending account' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Verification code re-sent if the account is pending.' })
|
||||||
|
@ApiBody({ type: ResendRegistrationCodeDto })
|
||||||
|
resendRegistrationCode(@Request() req: any, @Body() dto: ResendRegistrationCodeDto) {
|
||||||
|
return this.passengerAuthService.resendRegistrationCode(dto, req);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@IsPublic()
|
@IsPublic()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
|
import { IsEmail, IsString, ValidateNested } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class NameDto {
|
export class NameDto {
|
||||||
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
|
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
|
||||||
@@ -29,15 +29,16 @@ export class RegisterDto {
|
|||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => NameDto)
|
@Type(() => NameDto)
|
||||||
name: NameDto;
|
name: NameDto;
|
||||||
|
}
|
||||||
|
|
||||||
@ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' })
|
export class ResendRegistrationCodeDto {
|
||||||
@IsString()
|
@ApiProperty({ example: 'kelemu@email.com' })
|
||||||
@MinLength(8)
|
@IsEmail()
|
||||||
password: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'SecurePass123', format: 'password' })
|
@ApiProperty({ example: '+251912345678' })
|
||||||
@IsString()
|
@IsString()
|
||||||
confirmPassword: string;
|
phoneNumber: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
|
|||||||
@@ -50,14 +50,17 @@ export class PassengerAuthService {
|
|||||||
|
|
||||||
const iamAuthService = await this.resolveIamAuthService(req);
|
const iamAuthService = await this.resolveIamAuthService(req);
|
||||||
|
|
||||||
const { token, refreshToken } = await iamAuthService.signupWithPassword({
|
// IAM `signup` creates the user as PENDING/isActive=false with NO credential and
|
||||||
|
// SMS-sends a 6-digit verification code. The account cannot log in until the code is
|
||||||
|
// redeemed via PATCH /v1/auth/set-password. We intentionally discard the session
|
||||||
|
// token `signup` returns — the account is not verified yet, so it must never reach
|
||||||
|
// the client.
|
||||||
|
await iamAuthService.signup({
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
username: dto.username,
|
username: dto.username,
|
||||||
phoneNumber: dto.phoneNumber,
|
phoneNumber: dto.phoneNumber,
|
||||||
userType: EUserType.INDIVIDUAL,
|
userType: EUserType.INDIVIDUAL,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
password: dto.password,
|
|
||||||
confirmPassword: dto.confirmPassword,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||||
@@ -70,20 +73,98 @@ export class PassengerAuthService {
|
|||||||
}
|
}
|
||||||
const iamUserId = iamRows[0].id;
|
const iamUserId = iamRows[0].id;
|
||||||
|
|
||||||
let passengerId: string;
|
// The Prisma "passenger satellite" (Passenger + wallet + loyalty) is NOT provisioned
|
||||||
|
// here — `login()` lazy-provisions it on first successful login, so satellites exist
|
||||||
|
// only for verified users who complete set-password and sign in.
|
||||||
|
return {
|
||||||
|
iamUserId,
|
||||||
|
email: dto.email,
|
||||||
|
phoneNumber: dto.phoneNumber,
|
||||||
|
requiresPasswordSetup: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediate-activation account creation used by the payment-gated guest-checkout
|
||||||
|
* "create account" path only. Unlike the public `register()` (OTP-gated), this creates a
|
||||||
|
* ready-to-use account from the password entered at checkout and provisions the passenger
|
||||||
|
* satellite synchronously so the booking can attach to it. Do NOT wire this to the public
|
||||||
|
* registration form — that flow must stay behind SMS verification.
|
||||||
|
*/
|
||||||
|
async registerWithPassword(
|
||||||
|
dto: {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
name: { en: string; am: string };
|
||||||
|
password: string;
|
||||||
|
},
|
||||||
|
req: any,
|
||||||
|
): Promise<{ iamUserId: string; passengerId: string }> {
|
||||||
|
const existing = await this.dataSource.query<{ id: string }[]>(
|
||||||
|
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`,
|
||||||
|
[dto.email, dto.phoneNumber],
|
||||||
|
);
|
||||||
|
if (existing.length) throw new ConflictException('Email or phone already registered');
|
||||||
|
|
||||||
|
const iamAuthService = await this.resolveIamAuthService(req);
|
||||||
|
await iamAuthService.signupWithPassword({
|
||||||
|
email: dto.email,
|
||||||
|
username: dto.username,
|
||||||
|
phoneNumber: dto.phoneNumber,
|
||||||
|
userType: EUserType.INDIVIDUAL,
|
||||||
|
name: dto.name,
|
||||||
|
password: dto.password,
|
||||||
|
confirmPassword: dto.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||||
|
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`,
|
||||||
|
[dto.email],
|
||||||
|
);
|
||||||
|
if (!iamRows.length) {
|
||||||
|
await this.compensateIamSignup(dto.email);
|
||||||
|
throw new InternalServerErrorException('Account creation failed. Please try again.');
|
||||||
|
}
|
||||||
|
const iamUserId = iamRows[0].id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' });
|
const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' });
|
||||||
passengerId = result.passengerId;
|
return { iamUserId, passengerId: result.passengerId };
|
||||||
} catch {
|
} catch {
|
||||||
await this.compensateIamSignup(dto.email);
|
await this.compensateIamSignup(dto.email);
|
||||||
throw new InternalServerErrorException('Account creation failed. Please try again.');
|
throw new InternalServerErrorException('Account creation failed. Please try again.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
async resendRegistrationCode(
|
||||||
token,
|
dto: { email: string; phoneNumber: string },
|
||||||
refreshToken,
|
req: any,
|
||||||
user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.name.en, passengerId },
|
): Promise<{ sent: boolean }> {
|
||||||
};
|
// Only regenerate for accounts still pending password setup. A fully-registered user
|
||||||
|
// should use forgot-password instead. Always return { sent: true } to avoid leaking
|
||||||
|
// whether the email/phone maps to a pending account (enumeration guard).
|
||||||
|
const users = await this.dataSource.query<{ email: string; phone_number: string }[]>(
|
||||||
|
`SELECT email, phone_number FROM iam.users
|
||||||
|
WHERE email = $1 AND phone_number = $2 AND has_set_password = false LIMIT 1`,
|
||||||
|
[dto.email, dto.phoneNumber],
|
||||||
|
);
|
||||||
|
if (!users.length) return { sent: true };
|
||||||
|
|
||||||
|
const iamAuthService = await this.resolveIamAuthService(req);
|
||||||
|
try {
|
||||||
|
await iamAuthService.generateVerificationCode({
|
||||||
|
email: users[0].email,
|
||||||
|
phoneNumber: users[0].phone_number,
|
||||||
|
type: EOtpType.VERIFY_PHONE_NUMBER,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`[PassengerAuthService] resend registration code failed for ${dto.email}`,
|
||||||
|
(err as Error).message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { sent: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(dto: LoginDto, req: any) {
|
async login(dto: LoginDto, req: any) {
|
||||||
|
|||||||
@@ -886,18 +886,17 @@ export class GuestBookingService {
|
|||||||
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
|
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
|
||||||
if (dto.createAccount && firstPassenger.email && dto.password) {
|
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||||||
const guestName = firstPassenger.passengerName ?? 'Guest';
|
const guestName = firstPassenger.passengerName ?? 'Guest';
|
||||||
const result = await this.passengerAuthService.register(
|
const result = await this.passengerAuthService.registerWithPassword(
|
||||||
{
|
{
|
||||||
email: firstPassenger.email,
|
email: firstPassenger.email,
|
||||||
username: firstPassenger.email,
|
username: firstPassenger.email,
|
||||||
phoneNumber: firstPassenger.phone || `+251900000000`,
|
phoneNumber: firstPassenger.phone || `+251900000000`,
|
||||||
name: { en: guestName, am: guestName },
|
name: { en: guestName, am: guestName },
|
||||||
password: dto.password,
|
password: dto.password,
|
||||||
confirmPassword: dto.password,
|
|
||||||
},
|
},
|
||||||
req,
|
req,
|
||||||
);
|
);
|
||||||
return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true };
|
return { guestPassengerId: result.passengerId, iamUserId: result.iamUserId, createdAccount: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create guest passenger with basic profile
|
// Create guest passenger with basic profile
|
||||||
|
|||||||
@@ -9,18 +9,11 @@ import { useAuthStore } from '@/lib/auth-store';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Train, ShieldCheck } from 'lucide-react';
|
import { Train, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
const registerSchema = z
|
const registerSchema = z.object({
|
||||||
.object({
|
fullName: z.string().min(2, 'Full name is required'),
|
||||||
fullName: z.string().min(2, 'Full name is required'),
|
email: z.string().email('Invalid email address'),
|
||||||
email: z.string().email('Invalid email address'),
|
phone: z.string().min(9, 'Phone number is required'),
|
||||||
phone: z.string().min(9, 'Phone number is required'),
|
});
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
||||||
confirmPassword: z.string(),
|
|
||||||
})
|
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
|
||||||
message: 'Passwords do not match',
|
|
||||||
path: ['confirmPassword'],
|
|
||||||
});
|
|
||||||
|
|
||||||
type RegisterForm = z.infer<typeof registerSchema>;
|
type RegisterForm = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
@@ -38,14 +31,17 @@ export default function RegisterPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await registerUser({
|
const result = await registerUser({
|
||||||
fullName: data.fullName,
|
fullName: data.fullName,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
password: data.password,
|
|
||||||
confirmPassword: data.confirmPassword,
|
|
||||||
});
|
});
|
||||||
router.push('/booking/search');
|
const params = new URLSearchParams({
|
||||||
|
email: result.email,
|
||||||
|
userId: result.iamUserId,
|
||||||
|
phone: result.phoneNumber,
|
||||||
|
});
|
||||||
|
router.push(`/verify-account?${params.toString()}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.response?.status === 409) {
|
if (err.response?.status === 409) {
|
||||||
setError('An account with this email or phone number already exists.');
|
setError('An account with this email or phone number already exists.');
|
||||||
@@ -67,7 +63,7 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Create account</h1>
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Create account</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Book faster and manage your trips</p>
|
<p className="text-gray-600 dark:text-gray-400 mt-2">We'll text you a code to verify your phone</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -120,36 +116,8 @@ export default function RegisterPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
{...register('password')}
|
|
||||||
className="input-field"
|
|
||||||
placeholder="••••••••"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Confirm password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
{...register('confirmPassword')}
|
|
||||||
className="input-field"
|
|
||||||
placeholder="••••••••"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
{errors.confirmPassword && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||||
{loading ? 'Creating account...' : 'Create account'}
|
{loading ? 'Sending code...' : 'Send verification code'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
216
apps/edr-passenger-web/portal/src/app/verify-account/page.tsx
Normal file
216
apps/edr-passenger-web/portal/src/app/verify-account/page.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useState } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
|
||||||
|
import { iamAuthApi } from '@/lib/api/auth';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
|
// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults):
|
||||||
|
// min length 8, with lower- and upper-case letters, a number, and a symbol.
|
||||||
|
function isStrongPassword(pw: string): boolean {
|
||||||
|
return (
|
||||||
|
pw.length >= 8 &&
|
||||||
|
/[a-z]/.test(pw) &&
|
||||||
|
/[A-Z]/.test(pw) &&
|
||||||
|
/[0-9]/.test(pw) &&
|
||||||
|
/[^A-Za-z0-9]/.test(pw)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerifyAccountContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const login = useAuthStore((s) => s.login);
|
||||||
|
|
||||||
|
const email = searchParams.get('email') || '';
|
||||||
|
const userId = searchParams.get('userId') || '';
|
||||||
|
const phone = searchParams.get('phone') || '';
|
||||||
|
const linkValid = Boolean(email && userId);
|
||||||
|
|
||||||
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [resending, setResending] = useState(false);
|
||||||
|
const [resent, setResent] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (!verificationCode.trim()) {
|
||||||
|
setError('Enter the verification code sent to your phone.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isStrongPassword(newPassword)) {
|
||||||
|
setError('Password must be at least 8 characters and include upper- and lower-case letters, a number, and a symbol.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Completes signup: PATCH /v1/auth/set-password with the SMS code, which activates
|
||||||
|
// the account and sets the password.
|
||||||
|
await iamAuthApi.resetPassword({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
verificationCode: verificationCode.trim(),
|
||||||
|
newPassword,
|
||||||
|
confirmPassword,
|
||||||
|
});
|
||||||
|
// Auto-login with the freshly-set password; login lazy-provisions the passenger record.
|
||||||
|
await login(email, newPassword);
|
||||||
|
router.push('/booking/search');
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || err.message || '';
|
||||||
|
setError(msg || 'Could not verify your account. Check the code and try again, or resend it.');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
setError('');
|
||||||
|
setResent(false);
|
||||||
|
setResending(true);
|
||||||
|
try {
|
||||||
|
await iamAuthApi.resendRegistrationCode({ email, phoneNumber: phone });
|
||||||
|
setResent(true);
|
||||||
|
} catch {
|
||||||
|
setError('Could not resend the code. Please try again in a moment.');
|
||||||
|
} finally {
|
||||||
|
setResending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
||||||
|
<div className="max-w-md w-full">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="w-12 h-12 bg-[rgb(20_113_76)] rounded-lg flex items-center justify-center">
|
||||||
|
<Train className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Verify your account</h1>
|
||||||
|
{linkValid && (
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
Enter the code we sent to your phone and choose a password for{' '}
|
||||||
|
<span className="font-medium">{email}</span>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{!linkValid ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||||
|
This verification link is invalid or incomplete. Please start registration again.
|
||||||
|
</div>
|
||||||
|
<Link href="/register" className="btn-primary w-full flex items-center justify-center">
|
||||||
|
Back to registration
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resent && !error && (
|
||||||
|
<div className="flex items-start gap-3 bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 px-4 py-3 rounded">
|
||||||
|
<ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm">A new code has been sent to your phone.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Verification code</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => { setVerificationCode(e.target.value); setError(''); }}
|
||||||
|
className="input-field tracking-widest"
|
||||||
|
placeholder="123456"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">New password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
At least 8 characters with upper & lower case, a number, and a symbol.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Confirm password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary w-full"
|
||||||
|
disabled={loading || !verificationCode || !newPassword || !confirmPassword}
|
||||||
|
>
|
||||||
|
{loading ? 'Verifying...' : 'Verify and continue'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={resending}
|
||||||
|
className="w-full text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{resending ? 'Resending...' : "Didn't get a code? Resend"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="flex items-center justify-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to registration
|
||||||
|
</Link>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerifyAccountPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<VerifyAccountContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ export const iamAuthApi = {
|
|||||||
forgotPassword: (email: string) =>
|
forgotPassword: (email: string) =>
|
||||||
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
|
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
|
||||||
|
|
||||||
|
// Re-sends the registration verification code for a still-pending account.
|
||||||
|
resendRegistrationCode: (data: { email: string; phoneNumber: string }) =>
|
||||||
|
axios.post(`${API_URL}/auth/register/resend-code`, data),
|
||||||
|
|
||||||
// Completes the forgot-password flow using the link sent via SMS:
|
// Completes the forgot-password flow using the link sent via SMS:
|
||||||
// ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=..
|
// ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=..
|
||||||
resetPassword: (data: {
|
resetPassword: (data: {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface AuthState {
|
|||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (data: RegisterData) => Promise<void>;
|
register: (data: RegisterData) => Promise<RegisterResult>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
setUser: (user: User, token: string) => void;
|
setUser: (user: User, token: string) => void;
|
||||||
updateUser: (userData: Partial<User>) => void;
|
updateUser: (userData: Partial<User>) => void;
|
||||||
@@ -43,8 +43,12 @@ interface RegisterData {
|
|||||||
fullName: string;
|
fullName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
password: string;
|
}
|
||||||
confirmPassword: string;
|
|
||||||
|
interface RegisterResult {
|
||||||
|
iamUserId: string;
|
||||||
|
email: string;
|
||||||
|
phoneNumber: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
@@ -118,25 +122,24 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
set({ user, token, isAuthenticated: true });
|
set({ user, token, isAuthenticated: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
register: async (data: RegisterData) => {
|
register: async (data: RegisterData): Promise<RegisterResult> => {
|
||||||
// Shape required by the passenger-api RegisterDto; username = email by convention.
|
// Shape required by the passenger-api RegisterDto; username = email by convention.
|
||||||
|
// Registration no longer takes a password — the account is created as pending and
|
||||||
|
// an SMS verification code is sent. The user completes signup on the verify-account
|
||||||
|
// page (set-password). No token is issued here; the user is NOT logged in yet.
|
||||||
const payload = {
|
const payload = {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
username: data.email,
|
username: data.email,
|
||||||
phoneNumber: data.phone,
|
phoneNumber: data.phone,
|
||||||
name: { en: data.fullName, am: data.fullName },
|
name: { en: data.fullName, am: data.fullName },
|
||||||
password: data.password,
|
|
||||||
confirmPassword: data.confirmPassword,
|
|
||||||
};
|
};
|
||||||
const response: any = await apiClient.post('/auth/register', payload);
|
const response: any = await apiClient.post('/auth/register', payload);
|
||||||
const { token, user } = response.data || response;
|
const result = response.data || response;
|
||||||
|
return {
|
||||||
if (typeof window !== 'undefined') {
|
iamUserId: result.iamUserId,
|
||||||
localStorage.setItem('auth_token', token);
|
email: result.email,
|
||||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
phoneNumber: result.phoneNumber,
|
||||||
}
|
};
|
||||||
|
|
||||||
set({ user, token, isAuthenticated: true });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export interface ClearanceT1State {
|
|||||||
|
|
||||||
/** Train link state for the booking tied to a customs clearance flow. */
|
/** Train link state for the booking tied to a customs clearance flow. */
|
||||||
export interface ClearanceTrainState {
|
export interface ClearanceTrainState {
|
||||||
|
scheduleId: string | null;
|
||||||
wagonAllocated: boolean;
|
wagonAllocated: boolean;
|
||||||
departedAt: string | null;
|
departedAt: string | null;
|
||||||
arrivedAt: string | null;
|
arrivedAt: string | null;
|
||||||
@@ -305,31 +306,6 @@ export interface ClearanceSecondDuty {
|
|||||||
paid: boolean;
|
paid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A customs booking riding a train schedule, as shown on the GL DJ schedules tab. */
|
|
||||||
export interface DjClearanceScheduleBooking {
|
|
||||||
bookingId: string;
|
|
||||||
reference: string;
|
|
||||||
tradeDirection: string;
|
|
||||||
contractId: string | null;
|
|
||||||
gatepassGranted: boolean;
|
|
||||||
gatepassAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Train schedule row for the GL Djibouti gate-pass table. */
|
|
||||||
export interface DjClearanceSchedule {
|
|
||||||
id: string;
|
|
||||||
trainNumber: string | null;
|
|
||||||
routeName: string | null;
|
|
||||||
origin: string | null;
|
|
||||||
destination: string | null;
|
|
||||||
status: string;
|
|
||||||
scheduledDepartureDate: string | null;
|
|
||||||
actualDepartureAt: string | null;
|
|
||||||
actualArrivalAt: string | null;
|
|
||||||
freightType: string | null;
|
|
||||||
customsBookings: DjClearanceScheduleBooking[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContractClearanceView {
|
export interface ContractClearanceView {
|
||||||
contractId: string;
|
contractId: string;
|
||||||
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
|
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
|
||||||
|
|||||||
@@ -466,6 +466,10 @@ export interface IBooking extends BaseEntity {
|
|||||||
|
|
||||||
containers?: Array<{ type: string; qty: number; vgm: number }> | null;
|
containers?: Array<{ type: string; qty: number; vgm: number }> | null;
|
||||||
|
|
||||||
|
/** Physical container numbers on this booking (from booking container units),
|
||||||
|
* surfaced for the customer truck-assignment container picker. */
|
||||||
|
containerNumbers?: string[] | null;
|
||||||
|
|
||||||
versionNumber: number;
|
versionNumber: number;
|
||||||
priorityScore: number;
|
priorityScore: number;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user