mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
chnages
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Drop the `active_profile_type` "active mode" column. A booking/contract now
|
||||
* resolves its company_profile from the trade direction at creation time (with
|
||||
* a forwarder passing an explicit companyProfileId), so no per-user active mode
|
||||
* is stored. `onboarding_step` / `onboarding_completed` are unaffected.
|
||||
*/
|
||||
export class DropActiveProfileTypeFromExternalProfiles2450000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'DropActiveProfileTypeFromExternalProfiles2450000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS active_profile_type;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
|
||||
`);
|
||||
// Rebuild the mode the same way the original column was backfilled:
|
||||
// importer first, then exporter, then whichever profile the company has.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.external_profiles ep
|
||||
SET active_profile_type = cp.type
|
||||
FROM (
|
||||
SELECT DISTINCT ON (company_id) company_id, type
|
||||
FROM freight.company_profiles
|
||||
ORDER BY company_id,
|
||||
CASE type
|
||||
WHEN 'importer' THEN 0
|
||||
WHEN 'exporter' THEN 1
|
||||
ELSE 2
|
||||
END
|
||||
) cp
|
||||
WHERE ep.company_id = cp.company_id
|
||||
AND ep.active_profile_type IS NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface {
|
||||
name = "AddCacBankPaymentMethod2460000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// The entity + frontend already list 'cac-bank' as a valid method, but the
|
||||
// DB enum was never extended. Filtering payments by 'cac-bank' cast the
|
||||
// literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301.
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values directly.
|
||||
// To roll back, recreate the type without the added value and update the column.
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RESET_LINK_TTL_MS,
|
||||
} from "./forgot-password.service";
|
||||
import { maskOtpTarget } from "./mask-target.util";
|
||||
import { isDomesticPhone } from "../otp/otp.service";
|
||||
|
||||
/** The account a staff-triggered reset would land on. */
|
||||
export interface CustomerResetTarget {
|
||||
@@ -19,6 +20,12 @@ export interface CustomerResetTarget {
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
/**
|
||||
* Whether the SMS gateway (domestic-only) can reach `phone`. `null` when
|
||||
* there is no phone. The backoffice uses this to disable the SMS channel for
|
||||
* foreign numbers instead of sending a link that will never arrive.
|
||||
*/
|
||||
phoneIsDomestic: boolean | null;
|
||||
}
|
||||
|
||||
export interface SentResetLink {
|
||||
@@ -58,6 +65,9 @@ export class CustomerResetService {
|
||||
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
||||
email: user.email ?? null,
|
||||
phone: user.phoneNumber ?? null,
|
||||
phoneIsDomestic: user.phoneNumber
|
||||
? isDomesticPhone(user.phoneNumber)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +90,17 @@ export class CustomerResetService {
|
||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
// A foreign number is unreachable by the domestic-only SMS gateway — treat
|
||||
// it like a missing phone rather than reporting "link sent" for a message
|
||||
// that will never arrive. The backoffice disables the channel up front via
|
||||
// `phoneIsDomestic`; this guards direct API calls.
|
||||
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
|
||||
this.logger.warn(
|
||||
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mint first, send second: a failed send leaves an unused ticket that simply
|
||||
// expires, whereas sending a link before the ticket exists would hand the
|
||||
// customer a URL that is dead on arrival.
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -635,12 +634,9 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
// A customer can only book once their company has been approved.
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
throw new ForbiddenException(
|
||||
"Your company is awaiting approval — you can't create bookings yet.",
|
||||
);
|
||||
}
|
||||
// A customer can only book once their company has been approved; the
|
||||
// helper names the real status (suspended/blacklisted) when it isn't.
|
||||
this.companiesService.assertCompanyActiveFor(company, 'bookings');
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
@@ -746,21 +742,13 @@ export class BookingsService {
|
||||
);
|
||||
companyProfileId = profile.id;
|
||||
} else if (companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
const { profile } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
fallbackType = profile.activeProfileType ?? null;
|
||||
} catch {
|
||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
||||
}
|
||||
}
|
||||
// No explicit profile pin: resolve from the booking's trade direction
|
||||
// (import→importer, export→exporter; otherwise the first profile). A
|
||||
// forwarder booking sends dto.companyProfileId and takes the branch above.
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
tradeDirection,
|
||||
fallbackType,
|
||||
);
|
||||
|
||||
// A customer booking under their own account may only do so once the
|
||||
@@ -1068,9 +1056,6 @@ export class BookingsService {
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
existing.companyId,
|
||||
tradeDirection,
|
||||
existing.companyProfileId
|
||||
? undefined
|
||||
: (existing.companyProfile?.type as ProfileType | undefined),
|
||||
);
|
||||
}
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
@@ -1391,15 +1376,6 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active company_profile id a customer's bookings should be
|
||||
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
|
||||
* back to company-level scoping.
|
||||
*/
|
||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
||||
return this.companiesService.resolveActiveCompanyProfileId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||
|
||||
@@ -26,7 +26,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
|
||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||
@@ -353,21 +352,6 @@ export class CompaniesController {
|
||||
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
||||
}
|
||||
|
||||
@Patch("active-mode")
|
||||
@ApiOperation({
|
||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
||||
})
|
||||
async setActiveMode(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: SetActiveModeDto,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } = await this.companiesService.setActiveMode(
|
||||
user.id,
|
||||
dto.type,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Patch("onboarding-step")
|
||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
|
||||
@@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
AND ccr.deleted_at IS NULL
|
||||
)`;
|
||||
|
||||
/**
|
||||
* The `sortBy = 'review'` queue ordering: whatever marketing must act on
|
||||
* floats to the top. Tier 0 — submitted applications awaiting first approval
|
||||
* (drafts excluded: nothing to review yet). Tier 1 — approved customers with
|
||||
* a pending change request. Tier 2 — everyone else, drafts included.
|
||||
*/
|
||||
private static readonly REVIEW_TIER_SQL = `(CASE
|
||||
WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0
|
||||
WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1
|
||||
ELSE 2
|
||||
END)`;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Company)
|
||||
repo: Repository<Company>,
|
||||
@@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
status,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
sortBy = 'name',
|
||||
sortOrder = 'ASC',
|
||||
sortBy = 'review',
|
||||
sortOrder = 'DESC',
|
||||
} = query;
|
||||
|
||||
const qb = this.repository
|
||||
@@ -137,8 +149,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
}
|
||||
|
||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||
if (sortBy === 'review') {
|
||||
// Queue ordering: actionable tiers first, newest first within each. The
|
||||
// tier is selected under an alias because skip/take pagination with
|
||||
// joins re-derives the ORDER BY in a subquery — a raw expression there
|
||||
// breaks, a selected alias survives.
|
||||
qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier')
|
||||
.orderBy('review_tier', 'ASC')
|
||||
.addOrderBy('company.createdAt', 'DESC');
|
||||
} else {
|
||||
qb.orderBy(`company.${sortBy}`, sortOrder);
|
||||
}
|
||||
const [items, total] = await qb
|
||||
.orderBy(`company.${sortBy}`, sortOrder)
|
||||
// Names are not unique and createdAt can tie on bulk imports; the id
|
||||
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
||||
.addOrderBy('company.id', 'ASC')
|
||||
|
||||
@@ -201,18 +201,6 @@ export class CompaniesService {
|
||||
attributes: dto.attributes ?? null,
|
||||
});
|
||||
|
||||
// Default active mode from the chosen role(s): importer wins when both are
|
||||
// picked, otherwise the first allowed type chosen.
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
const chosenTypes = (dto.companyProfiles ?? [])
|
||||
.map((p) => p.type)
|
||||
.filter((t) => allowedTypes.includes(t));
|
||||
const activeProfileType =
|
||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
||||
chosenTypes[0] ??
|
||||
allowedTypes[0] ??
|
||||
null;
|
||||
|
||||
const profile = await this.profilesRepo.create({
|
||||
userId: identity.userId,
|
||||
companyId: company.id,
|
||||
@@ -220,7 +208,6 @@ export class CompaniesService {
|
||||
lastName: identity.lastName,
|
||||
jobTitle: dto.jobTitle ?? null,
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
activeProfileType,
|
||||
onboardingStep: "company",
|
||||
});
|
||||
|
||||
@@ -293,11 +280,6 @@ export class CompaniesService {
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||
const activeProfileType =
|
||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
||||
chosenTypes[0] ??
|
||||
allowedTypes[0] ??
|
||||
null;
|
||||
|
||||
const company = await this.companiesRepo.create({
|
||||
name: identity.firstName
|
||||
@@ -316,7 +298,6 @@ export class CompaniesService {
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
isPrimaryContact: true,
|
||||
activeProfileType,
|
||||
onboardingStep: "company",
|
||||
onboardingCompleted: false,
|
||||
});
|
||||
@@ -1090,6 +1071,23 @@ export class CompaniesService {
|
||||
if (!existing)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
|
||||
// Suspension and reactivation must carry a staff explanation — the customer
|
||||
// sees it, so "why" can never be left blank. Reactivation is the
|
||||
// active-write that leaves Suspended; a first approval stays note-free.
|
||||
const reactivating =
|
||||
status === ProfileStatus.Active &&
|
||||
existing.status === ProfileStatus.Suspended;
|
||||
if (
|
||||
(status === ProfileStatus.Suspended || reactivating) &&
|
||||
!note?.trim()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
status === ProfileStatus.Suspended
|
||||
? "A message explaining the suspension is required — the customer will see it."
|
||||
: "A message explaining the reactivation is required — the customer will see it.",
|
||||
);
|
||||
}
|
||||
|
||||
// A self-registered company is only reviewable once its owner submits the
|
||||
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
||||
// half-filled drafts and approving one would mint a reference against an
|
||||
@@ -1176,9 +1174,13 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Track the review outcome. Rejection keeps the note so the customer knows
|
||||
// why; approval clears it. Any decision stamps the reviewer + time.
|
||||
if (status === ProfileStatus.Rejected) {
|
||||
// Track the review outcome. Rejection and suspension keep the note so the
|
||||
// customer knows why; approval/reactivation clears it. Any decision stamps
|
||||
// the reviewer + time.
|
||||
if (
|
||||
status === ProfileStatus.Rejected ||
|
||||
status === ProfileStatus.Suspended
|
||||
) {
|
||||
patch.reviewNote = note ?? null;
|
||||
} else if (status === ProfileStatus.Active) {
|
||||
patch.reviewNote = null;
|
||||
@@ -1192,23 +1194,50 @@ export class CompaniesService {
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||
|
||||
// Approving any profile promotes a pending company to active, so the
|
||||
// customer can start working as soon as their first profile is cleared.
|
||||
if (status === ProfileStatus.Active) {
|
||||
// Every reviewed transition that changes what the customer can do is told
|
||||
// to them, carrying the staff message so they know why. Approval has no
|
||||
// message (the note is cleared); the others require one.
|
||||
const change =
|
||||
status === ProfileStatus.Suspended
|
||||
? "suspended"
|
||||
: status === ProfileStatus.Rejected
|
||||
? "rejected"
|
||||
: status === ProfileStatus.Active
|
||||
? existing.status === ProfileStatus.Suspended
|
||||
? "reactivated"
|
||||
: "approved"
|
||||
: null;
|
||||
if (change) {
|
||||
const company = await this.companiesRepo.findById(updated.companyId);
|
||||
if (company && company.status === CompanyStatus.Pending) {
|
||||
await this.companiesRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.profileStatusChanged(
|
||||
company,
|
||||
updated.type,
|
||||
change,
|
||||
note ?? "",
|
||||
);
|
||||
// The first approved role promotes a pending company to active — a
|
||||
// bigger event (the account itself goes live), so tell them that too.
|
||||
if (
|
||||
status === ProfileStatus.Active &&
|
||||
company.status === CompanyStatus.Pending
|
||||
) {
|
||||
await this.companiesRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
this.companyNotifier.companyApproved(company);
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer reapplies for a rejected operational role (after fixing whatever the
|
||||
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
|
||||
* clear the rejection note so it re-enters the approval queue.
|
||||
* Customer reapplies for a rejected or suspended operational role (after
|
||||
* fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
|
||||
* back to Pending and clear the review note so it re-enters the approval
|
||||
* queue. Suspension is a staff lockout, so resubmitting is an appeal — the
|
||||
* backoffice still has to approve before the role goes live again.
|
||||
*/
|
||||
async reapplyCompanyProfile(
|
||||
userId: string,
|
||||
@@ -1223,9 +1252,12 @@ export class CompaniesService {
|
||||
if (!target || target.companyId !== companyId) {
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
}
|
||||
if (target.status !== ProfileStatus.Rejected) {
|
||||
if (
|
||||
target.status !== ProfileStatus.Rejected &&
|
||||
target.status !== ProfileStatus.Suspended
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Only a rejected role can be resubmitted for approval",
|
||||
"Only a rejected or suspended role can be resubmitted for approval",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1349,10 +1381,9 @@ export class CompaniesService {
|
||||
|
||||
/**
|
||||
* Create a single operational profile for the current user's company. The new
|
||||
* role starts Pending, so it deliberately does NOT become the active mode:
|
||||
* switching onto an unapproved profile would strip the user of `canBook` and
|
||||
* block them from creating contracts under the role they already had approved.
|
||||
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
|
||||
* role starts Pending and carries no reference until a backoffice reviewer
|
||||
* approves it; a booking/contract resolves its profile from the trade
|
||||
* direction at creation time, so no "active mode" is stored.
|
||||
*/
|
||||
async createCompanyProfileForUser(
|
||||
userId: string,
|
||||
@@ -1387,40 +1418,6 @@ export class CompaniesService {
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the user's active operational mode. The target profile must already
|
||||
* exist — clients create it first via createCompanyProfileForUser.
|
||||
*/
|
||||
async setActiveMode(
|
||||
userId: string,
|
||||
type: ProfileType,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.findCompanyById(companyId);
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
if (!allowedTypes.includes(type)) {
|
||||
throw new BadRequestException(
|
||||
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (!existing) {
|
||||
throw new ConflictException(
|
||||
`No ${type} profile exists yet — create it before switching`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
||||
|
||||
return this.getCompanyInfoByUserId(userId);
|
||||
}
|
||||
|
||||
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
@@ -1611,21 +1608,68 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a customer from booking under a profile that isn't approved yet.
|
||||
* Called from the booking-create path for self-service bookings; staff- and
|
||||
* government-initiated bookings bypass this. No-op when the profile can't be
|
||||
* found (defensive — resolution is best-effort upstream).
|
||||
* Block a self-service action when the company account isn't active, naming
|
||||
* the actual status — a suspended customer told "awaiting approval" has no
|
||||
* idea what happened or who to call.
|
||||
*/
|
||||
assertCompanyActiveFor(company: Company, action: string): void {
|
||||
if (company.status === CompanyStatus.Active) return;
|
||||
switch (company.status) {
|
||||
case CompanyStatus.Suspended:
|
||||
throw new ForbiddenException(
|
||||
`Your company account is suspended — you can't create ${action} right now. ` +
|
||||
`Please contact EDR support for details.`,
|
||||
);
|
||||
case CompanyStatus.Blacklisted:
|
||||
throw new ForbiddenException(
|
||||
`Your company account is blacklisted — you can't create ${action}. ` +
|
||||
`Please contact EDR support.`,
|
||||
);
|
||||
default:
|
||||
throw new ForbiddenException(
|
||||
`Your company is awaiting approval — you can't create ${action} yet.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a customer from booking under a profile that isn't approved yet — or
|
||||
* that a reviewer has since suspended. Called from the booking/contract
|
||||
* create path for self-service actions; staff- and government-initiated ones
|
||||
* bypass this. No-op when the profile can't be found (defensive — resolution
|
||||
* is best-effort upstream). The message names the profile's real status:
|
||||
* suspension in particular is per-role, so the customer must learn which
|
||||
* operation is blocked (their other roles still work).
|
||||
*/
|
||||
async assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId: string,
|
||||
): Promise<void> {
|
||||
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
||||
if (!profile) return;
|
||||
if (profile.status !== ProfileStatus.Active) {
|
||||
const role = profile.type.replace(/_/g, " ");
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
|
||||
);
|
||||
if (profile.status === ProfileStatus.Active) return;
|
||||
|
||||
const role = profile.type.replace(/_/g, " ");
|
||||
switch (profile.status) {
|
||||
case ProfileStatus.Suspended:
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} role is suspended${
|
||||
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
||||
);
|
||||
case ProfileStatus.Blacklisted:
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} role is blacklisted. Please contact EDR support.`,
|
||||
);
|
||||
case ProfileStatus.Rejected:
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} role was rejected${
|
||||
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||
}. Amend and resubmit it from your settings page.`,
|
||||
);
|
||||
default:
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2244,15 +2288,14 @@ export class CompaniesService {
|
||||
/**
|
||||
* Resolve which company_profile a new booking belongs to, from the company
|
||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
|
||||
* when the natural profile doesn't exist) it falls back to the user's active
|
||||
* profile, then the company's first profile. Returns null when the company
|
||||
* has no profiles at all.
|
||||
* exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
|
||||
* e.g. a freight forwarder) it falls back to the company's first profile.
|
||||
* Callers that need a specific role (a forwarder) pass an explicit
|
||||
* companyProfileId instead. Returns null when the company has no profiles.
|
||||
*/
|
||||
async resolveCompanyProfileIdForBooking(
|
||||
companyId: string,
|
||||
tradeDirection: string,
|
||||
fallbackType?: ProfileType | null,
|
||||
): Promise<string | null> {
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
if (profiles.length === 0) return null;
|
||||
@@ -2264,30 +2307,12 @@ export class CompaniesService {
|
||||
? ProfileType.exporter
|
||||
: null;
|
||||
|
||||
const byType = (type?: ProfileType | null) =>
|
||||
type ? profiles.find((p) => p.type === type) : undefined;
|
||||
|
||||
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
|
||||
const match =
|
||||
(naturalType && profiles.find((p) => p.type === naturalType)) ??
|
||||
profiles[0];
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company_profile a customer's data should be scoped to, from
|
||||
* their persisted active mode. Returns null when nothing can be resolved
|
||||
* (not onboarded yet) so callers can fall back to company-level scoping.
|
||||
*/
|
||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
const type = profile.activeProfileType;
|
||||
if (!type) return null;
|
||||
const match = company.companyProfiles?.find((p) => p.type === type);
|
||||
return match?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string) {
|
||||
const { businessInfo, companyInfo } =
|
||||
await this.etradeService.resolveCompanyData(tin);
|
||||
|
||||
@@ -59,23 +59,106 @@ export class CompanyNotifierService {
|
||||
}
|
||||
}
|
||||
|
||||
/** SMS + email + in-app account-status item to the company contact. */
|
||||
private notifyAccount(
|
||||
company: Company,
|
||||
title: string,
|
||||
body: string,
|
||||
link = "/settings",
|
||||
): void {
|
||||
void this.notifyContact(company, `${title}. ${body}`);
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: company.id },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.ACCOUNT_STATUS,
|
||||
title,
|
||||
body,
|
||||
link,
|
||||
data: { companyId: company.id, status: company.status },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer their account was suspended or blacklisted. Called only on
|
||||
* a real transition into one of those statuses; other status writes are silent.
|
||||
* Tell the customer their account changed status. Fires on the transitions
|
||||
* that change what they can do: suspended/blacklisted (locked out) and
|
||||
* reactivated (back to Active from a lockout). Silent otherwise.
|
||||
*/
|
||||
statusChanged(company: Company, previous: CompanyStatus): void {
|
||||
const status = company.status;
|
||||
if (status === previous) return;
|
||||
|
||||
if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) {
|
||||
this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`);
|
||||
this.notifyAccount(
|
||||
company,
|
||||
"Account reactivated",
|
||||
"Your company account has been reactivated. " +
|
||||
"You can submit new contracts and bookings again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PUNITIVE_STATUSES.includes(status)) return;
|
||||
|
||||
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
||||
const title = `Account ${label}`;
|
||||
const body =
|
||||
`Your company account has been ${label}. ` +
|
||||
`You will not be able to submit new contracts or bookings. ` +
|
||||
`Please contact EDR support for assistance.`;
|
||||
|
||||
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
||||
this.notifyAccount(
|
||||
company,
|
||||
`Account ${label}`,
|
||||
`Your company account has been ${label}. ` +
|
||||
`You will not be able to submit new contracts or bookings. ` +
|
||||
`Please contact EDR support for assistance.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer their company account was approved and is now live — the
|
||||
* first operational role clearing review promotes a pending company to Active.
|
||||
*/
|
||||
companyApproved(company: Company): void {
|
||||
this.logger.log(`ACCOUNT_APPROVED — ${company.id}`);
|
||||
this.notifyAccount(
|
||||
company,
|
||||
"Account approved",
|
||||
"Your company account has been approved and is now active. " +
|
||||
"You can start submitting bookings and contracts.",
|
||||
"/dashboard",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer one of their operational roles changed review status —
|
||||
* approved, rejected, suspended, or reactivated — quoting the staff message
|
||||
* when one was given (rejection/suspension/reactivation require one; approval
|
||||
* carries none).
|
||||
*/
|
||||
profileStatusChanged(
|
||||
company: Company,
|
||||
profileType: string,
|
||||
change: "approved" | "rejected" | "suspended" | "reactivated",
|
||||
staffMessage: string,
|
||||
): void {
|
||||
const title = `${profileType} role ${change}`;
|
||||
const consequence: Record<typeof change, string> = {
|
||||
approved: "You can now operate under this role.",
|
||||
rejected:
|
||||
"You will not be able to operate under this role. Amend the required " +
|
||||
"documents and resubmit it for approval from your settings page.",
|
||||
suspended:
|
||||
"You will not be able to operate under this role until it is " +
|
||||
"reactivated; your other roles are unaffected.",
|
||||
reactivated: "You can operate under this role again.",
|
||||
};
|
||||
const message = staffMessage.trim();
|
||||
const body =
|
||||
`Your company's ${profileType} role has been ${change}. ` +
|
||||
`${consequence[change]}` +
|
||||
(message ? ` Message from EDR staff: ${message}` : "");
|
||||
|
||||
this.logger.log(
|
||||
`PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`,
|
||||
);
|
||||
void this.notifyContact(company, `${title}. ${body}`);
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: company.id },
|
||||
@@ -84,7 +167,7 @@ export class CompanyNotifierService {
|
||||
title,
|
||||
body,
|
||||
link: "/settings",
|
||||
data: { companyId: company.id, status },
|
||||
data: { companyId: company.id, profileType, change, staffMessage: message },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export class CompanyInfoResponseDto {
|
||||
company: Company,
|
||||
changeRequest?: CompanyChangeRequest | null,
|
||||
) {
|
||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
||||
this.profile = new ResponseExternalProfileDto(profile);
|
||||
this.company = new ResponseCompanyDto(company);
|
||||
|
||||
const open =
|
||||
|
||||
@@ -60,15 +60,19 @@ export class ListCompaniesQueryDto {
|
||||
hasPendingChangeRequest?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["name", "createdAt", "updatedAt"],
|
||||
default: "name",
|
||||
description: "Column to order by. Defaults to name for backwards compatibility.",
|
||||
enum: ["review", "name", "createdAt", "updatedAt"],
|
||||
default: "review",
|
||||
description:
|
||||
"Column to order by. The default `review` is a review-queue ordering: " +
|
||||
"companies awaiting first approval, then those with a pending change " +
|
||||
"request, then everyone else — newest first within each group. The " +
|
||||
"other values are plain column sorts.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["name", "createdAt", "updatedAt"])
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
@IsIn(["review", "name", "createdAt", "updatedAt"])
|
||||
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
|
||||
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||
@IsIn(["ASC", "DESC"])
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import {
|
||||
ExternalProfile,
|
||||
} from '../entities/external-profile.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class ResponseExternalProfileDto {
|
||||
id: string;
|
||||
@@ -13,20 +11,12 @@ export class ResponseExternalProfileDto {
|
||||
nationalId?: string | null;
|
||||
jobTitle?: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
/** The active operational mode (importer/exporter/forwarder). */
|
||||
activeProfileType?: ProfileType | null;
|
||||
/**
|
||||
* The id of the company_profile matching activeProfileType, resolved
|
||||
* server-side so the client never re-derives it. Null until a company
|
||||
* (with profiles) is loaded and a matching profile exists.
|
||||
*/
|
||||
activeCompanyProfileId?: string | null;
|
||||
onboardingStep?: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(profile: ExternalProfile, company?: Company) {
|
||||
constructor(profile: ExternalProfile) {
|
||||
this.id = profile.id;
|
||||
this.userId = profile.userId;
|
||||
this.companyId = profile.companyId;
|
||||
@@ -35,13 +25,8 @@ export class ResponseExternalProfileDto {
|
||||
this.nationalId = profile.nationalId;
|
||||
this.jobTitle = profile.jobTitle;
|
||||
this.isPrimaryContact = profile.isPrimaryContact;
|
||||
this.activeProfileType = profile.activeProfileType ?? null;
|
||||
this.onboardingStep = profile.onboardingStep ?? null;
|
||||
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
||||
this.activeCompanyProfileId =
|
||||
company?.companyProfiles?.find(
|
||||
(p) => p.type === profile.activeProfileType,
|
||||
)?.id ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class SetActiveModeDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
import { ProfileType } from './company-profile.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||
@Index(['userId'])
|
||||
@@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity {
|
||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||
isPrimaryContact!: boolean;
|
||||
|
||||
/**
|
||||
* The operational profile the user is currently "in" (importer vs exporter,
|
||||
* or the single forwarder profile). Drives header switching and scopes the
|
||||
* customer's bookings / dashboard to that company_profile. Nullable for
|
||||
* users who haven't picked a role yet.
|
||||
*/
|
||||
@Column({
|
||||
name: 'active_profile_type',
|
||||
type: 'varchar',
|
||||
length: 32,
|
||||
nullable: true,
|
||||
enum: ProfileType,
|
||||
})
|
||||
activeProfileType?: ProfileType | null;
|
||||
|
||||
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
|
||||
@Column({
|
||||
name: 'onboarding_step',
|
||||
|
||||
@@ -12,8 +12,6 @@ import { YardCountry } from '@edr/types';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -181,11 +179,7 @@ export class ContractsService {
|
||||
);
|
||||
}
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
throw new ForbiddenException(
|
||||
"Your company is awaiting approval — you can't create contracts yet.",
|
||||
);
|
||||
}
|
||||
this.companiesService.assertCompanyActiveFor(company, 'contracts');
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
@@ -193,31 +187,31 @@ export class ContractsService {
|
||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||
|
||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
||||
// Stamp the operational profile for portal scoping. A forwarder contract
|
||||
// pins its profile explicitly (trade direction can't tell it apart from a
|
||||
// direct import/export); everything else resolves from the trade direction.
|
||||
let companyProfileId: string | null = null;
|
||||
if (!isGovernment && companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
const { profile } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
fallbackType = profile.activeProfileType ?? null;
|
||||
} catch {
|
||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
||||
}
|
||||
}
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
dto.tradeDirection,
|
||||
fallbackType,
|
||||
);
|
||||
if (dto.companyProfileId) {
|
||||
const profile =
|
||||
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||
companyId,
|
||||
dto.companyProfileId,
|
||||
);
|
||||
companyProfileId = profile.id;
|
||||
} else {
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const customerSelfBooking = !dto.companyId && !!userId;
|
||||
if (customerSelfBooking && companyProfileId) {
|
||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId,
|
||||
);
|
||||
const customerSelfBooking = !dto.companyId && !!userId;
|
||||
if (customerSelfBooking && companyProfileId) {
|
||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,16 @@ export class CreateContractDto {
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Explicit company profile to stamp the contract to (a forwarder contract); ' +
|
||||
'commercial contracts otherwise auto-resolve from trade direction.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyProfileId?: string;
|
||||
|
||||
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
||||
@IsIn([...CONTRACT_KINDS])
|
||||
contractKind!: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { OtpController } from './otp.controller';
|
||||
import { OtpService } from './otp.service';
|
||||
|
||||
describe('OtpController', () => {
|
||||
let controller: OtpController;
|
||||
@@ -7,6 +8,9 @@ describe('OtpController', () => {
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [OtpController],
|
||||
providers: [
|
||||
{ provide: OtpService, useValue: { send: jest.fn(), verify: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<OtpController>(OtpController);
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
import { OtpService, normalizeOtpTarget } from './otp.service';
|
||||
import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service";
|
||||
|
||||
describe('normalizeOtpTarget', () => {
|
||||
it('canonicalises Ethiopian forms to one E.164 key', () => {
|
||||
const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099'];
|
||||
describe("normalizeOtpTarget", () => {
|
||||
it("canonicalises Ethiopian forms to one E.164 key", () => {
|
||||
const forms = [
|
||||
"+251986680099",
|
||||
"251986680099",
|
||||
"0986680099",
|
||||
"+251 98 668 0099",
|
||||
];
|
||||
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
|
||||
expect(new Set(keys)).toEqual(new Set(['+251986680099']));
|
||||
expect(new Set(keys)).toEqual(new Set(["+251986680099"]));
|
||||
});
|
||||
|
||||
it('maps local 07… mobile to +2517…', () => {
|
||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
||||
});
|
||||
|
||||
it('canonicalises email case and surrounding whitespace to one key', () => {
|
||||
const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM'];
|
||||
it("canonicalises email case and surrounding whitespace to one key", () => {
|
||||
const forms = ["a@b.com", "A@B.com", " a@B.COM ", "A@b.COM"];
|
||||
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
|
||||
expect(new Set(keys)).toEqual(new Set(['a@b.com']));
|
||||
expect(new Set(keys)).toEqual(new Set(["a@b.com"]));
|
||||
});
|
||||
|
||||
it('keeps an already-normalised email stable (idempotent)', () => {
|
||||
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!;
|
||||
it("keeps an already-normalised email stable (idempotent)", () => {
|
||||
const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!;
|
||||
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
||||
});
|
||||
|
||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
||||
const once = normalizeOtpTarget({ phone: '0986680099' }).phone!;
|
||||
it("keeps an already-normalised number stable (idempotent)", () => {
|
||||
const once = normalizeOtpTarget({ phone: "0986680099" }).phone!;
|
||||
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDomesticPhone", () => {
|
||||
it.each(["+251986680099", "0986680099", "251986680099"])(
|
||||
"accepts Ethiopian mobile form %s",
|
||||
(phone) => expect(isDomesticPhone(phone)).toBe(true),
|
||||
);
|
||||
|
||||
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
|
||||
"rejects non-domestic or malformed %s",
|
||||
(phone) => expect(isDomesticPhone(phone)).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
interface FakeRow {
|
||||
id: string;
|
||||
phone?: string;
|
||||
@@ -51,7 +64,8 @@ function makeService(
|
||||
let nextId = 1;
|
||||
|
||||
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
|
||||
(!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone);
|
||||
(!!t.email && row.email === t.email) ||
|
||||
(!!t.phone && row.phone === t.phone);
|
||||
|
||||
const repo = {
|
||||
findByTarget: jest.fn(
|
||||
@@ -89,30 +103,30 @@ function makeService(
|
||||
return { service, sms, email, rows: () => rows };
|
||||
}
|
||||
|
||||
describe('OtpService — send/verify agree across phone formats', () => {
|
||||
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
||||
describe("OtpService — send/verify agree across phone formats", () => {
|
||||
it("verifies a code sent to +251… when verify is called with 09…", async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp({ phone: '+251986680099' });
|
||||
await service.sendOtp({ phone: "+251986680099" });
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp),
|
||||
service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
|
||||
it("verifies a code sent to User@X.com when verify is called with user@x.com", async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp({ email: ' User@Example.COM ' });
|
||||
await service.sendOtp({ email: " User@Example.COM " });
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('OtpService — dual-channel send', () => {
|
||||
const both = { phone: '0986680099', email: 'User@Example.COM' };
|
||||
describe("OtpService — dual-channel send", () => {
|
||||
const both = { phone: "0986680099", email: "User@Example.COM" };
|
||||
|
||||
it('sends ONE code to both transports', async () => {
|
||||
it("sends ONE code to both transports", async () => {
|
||||
const { service, sms, email, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
|
||||
@@ -122,72 +136,95 @@ describe('OtpService — dual-channel send', () => {
|
||||
// Same secret on both messages — the user types whichever arrives first.
|
||||
expect(sms.sendSms).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: '+251986680099',
|
||||
to: "+251986680099",
|
||||
message: expect.stringContaining(otp),
|
||||
}),
|
||||
);
|
||||
expect(email.sendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'user@example.com',
|
||||
to: "user@example.com",
|
||||
text: expect.stringContaining(otp),
|
||||
}),
|
||||
);
|
||||
// One row, both channels canonicalised.
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0]).toMatchObject({
|
||||
phone: '+251986680099',
|
||||
email: 'user@example.com',
|
||||
phone: "+251986680099",
|
||||
email: "user@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['phone alone', { phone: '0986680099' }],
|
||||
['email alone', { email: 'user@example.com' }],
|
||||
['both', both],
|
||||
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
["phone alone", { phone: "0986680099" }],
|
||||
["email alone", { email: "user@example.com" }],
|
||||
["both", both],
|
||||
])(
|
||||
"verifies a dual-channel code when quoted back by %s",
|
||||
async (_label, target) => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
await expect(
|
||||
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
it('consuming the code via one channel kills the other', async () => {
|
||||
it("consuming the code via one channel kills the other", async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
const otp = rows()[0]!.otp;
|
||||
|
||||
await service.verifyOtpForAction({ email: 'user@example.com' }, otp);
|
||||
await service.verifyOtpForAction({ email: "user@example.com" }, otp);
|
||||
|
||||
// Single-use is per-code, not per-channel: the phone half must be dead too.
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, otp),
|
||||
service.verifyOtpForAction({ phone: "0986680099" }, otp),
|
||||
).rejects.toThrow(/No verification code was requested/);
|
||||
});
|
||||
|
||||
it('replaces an overlapping single-channel row instead of colliding with it', async () => {
|
||||
it("replaces an overlapping single-channel row instead of colliding with it", async () => {
|
||||
const { service, rows } = makeService();
|
||||
// A pending signup code on the phone only, then a dual-channel send.
|
||||
await service.sendOtp({ phone: '0986680099' });
|
||||
await service.sendOtp({ phone: "0986680099" });
|
||||
await service.sendOtp(both);
|
||||
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0]).toMatchObject({ email: 'user@example.com' });
|
||||
expect(rows()[0]).toMatchObject({ email: "user@example.com" });
|
||||
});
|
||||
|
||||
it('degrades to one channel when the account has only one contact', async () => {
|
||||
it("degrades to one channel when the account has only one contact", async () => {
|
||||
const { service, sms, email } = makeService();
|
||||
await service.sendOtp({ phone: '0986680099' });
|
||||
await service.sendOtp({ phone: "0986680099" });
|
||||
|
||||
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||
expect(email.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still succeeds when one transport throws', async () => {
|
||||
it("skips SMS for a foreign number when email is available", async () => {
|
||||
const { service, sms, email, rows } = makeService();
|
||||
await service.sendOtp({ phone: "+14155550123", email: "user@example.com" });
|
||||
|
||||
// The gateway is domestic-only — email is the delivery route, but the
|
||||
// foreign phone stays on the row so verify still matches either channel.
|
||||
expect(sms.sendSms).not.toHaveBeenCalled();
|
||||
expect(email.sendEmail).toHaveBeenCalledTimes(1);
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: "+14155550123" }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("still attempts SMS for a foreign number when it is the only channel", async () => {
|
||||
const { service, sms } = makeService();
|
||||
await service.sendOtp({ phone: "+14155550123" });
|
||||
|
||||
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still succeeds when one transport throws", async () => {
|
||||
const { service, rows } = makeService({
|
||||
sms: async () => {
|
||||
throw new Error('broker down');
|
||||
throw new Error("broker down");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -197,24 +234,24 @@ describe('OtpService — dual-channel send', () => {
|
||||
});
|
||||
// The code is live and verifiable on the channel that worked.
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('fails the request when every transport throws', async () => {
|
||||
it("fails the request when every transport throws", async () => {
|
||||
const { service } = makeService({
|
||||
sms: async () => {
|
||||
throw new Error('broker down');
|
||||
throw new Error("broker down");
|
||||
},
|
||||
email: async () => {
|
||||
throw new Error('broker down');
|
||||
throw new Error("broker down");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP');
|
||||
await expect(service.sendOtp(both)).rejects.toThrow("Failed to send OTP");
|
||||
});
|
||||
|
||||
it('shares one brute-force budget across both channels', async () => {
|
||||
it("shares one brute-force budget across both channels", async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
const otp = rows()[0]!.otp;
|
||||
@@ -222,17 +259,17 @@ describe('OtpService — dual-channel send', () => {
|
||||
// Alternating channels must not hand the attacker two independent budgets:
|
||||
// 5 wrong guesses in total burn the code regardless of how they are split.
|
||||
for (const target of [
|
||||
{ phone: '0986680099' },
|
||||
{ email: 'user@example.com' },
|
||||
{ phone: '0986680099' },
|
||||
{ email: 'user@example.com' },
|
||||
{ phone: "0986680099" },
|
||||
{ email: "user@example.com" },
|
||||
{ phone: "0986680099" },
|
||||
{ email: "user@example.com" },
|
||||
]) {
|
||||
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow(
|
||||
'Invalid verification code',
|
||||
);
|
||||
await expect(
|
||||
service.verifyOtpForAction(target, "000000"),
|
||||
).rejects.toThrow("Invalid verification code");
|
||||
}
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'),
|
||||
service.verifyOtpForAction({ email: "user@example.com" }, "000000"),
|
||||
).rejects.toThrow(/Too many incorrect attempts/);
|
||||
|
||||
// Burned: even the correct code no longer works.
|
||||
|
||||
@@ -36,9 +36,9 @@ function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
|
||||
*/
|
||||
function normalizePhone(rawPhone: string): string {
|
||||
const raw = rawPhone.trim();
|
||||
const digits = raw.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+')) return digits;
|
||||
const bare = digits.replace(/^0+/, '');
|
||||
const digits = raw.replace(/[^\d+]/g, "");
|
||||
if (digits.startsWith("+")) return digits;
|
||||
const bare = digits.replace(/^0+/, "");
|
||||
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||
@@ -46,6 +46,16 @@ function normalizePhone(rawPhone: string): string {
|
||||
return digits.length >= 11 ? `+${digits}` : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
|
||||
* the carrier integration is domestic-only, so a send to anything else is
|
||||
* queued and silently lost. Callers use this to fall back to email instead of
|
||||
* pretending an SMS is on its way.
|
||||
*/
|
||||
export function isDomesticPhone(rawPhone: string): boolean {
|
||||
return /^\+2519\d{8}$/.test(normalizePhone(rawPhone));
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalise every channel present on the target. Each field is normalised
|
||||
* independently — a dual-channel target must end up with both halves in their
|
||||
@@ -143,6 +153,20 @@ export class OtpService {
|
||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||
// in the codebase yet.
|
||||
|
||||
// A foreign number is unreachable by the domestic-only SMS gateway; when
|
||||
// email is also on the target, go email-only rather than queueing an SMS
|
||||
// that will never arrive. With no email the SMS attempt stays — it is the
|
||||
// only route there is.
|
||||
const smsPhone =
|
||||
target.phone && (!target.email || isDomesticPhone(target.phone))
|
||||
? target.phone
|
||||
: null;
|
||||
if (target.phone && !smsPhone) {
|
||||
this.logger.warn(
|
||||
`otp.dispatch.sms-skipped target=${label} — non-domestic phone, delivering via email only`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fan out to every channel the target has, independently: one transport
|
||||
// being down must not suppress the other, which is the whole point of
|
||||
// sending to both. Each helper swallows its own failure so a rejected
|
||||
@@ -150,16 +174,14 @@ export class OtpService {
|
||||
const outcomes = (
|
||||
await Promise.all([
|
||||
target.email ? this.dispatchEmail(target.email, otp) : null,
|
||||
target.phone ? this.dispatchSms(target.phone, otp) : null,
|
||||
smsPhone ? this.dispatchSms(smsPhone, otp) : null,
|
||||
])
|
||||
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
this.logger.log(
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
||||
outcome.queued
|
||||
} latencyMs=${Date.now() - startedAt}${
|
||||
outcome.error ? ` error=${outcome.error}` : ""
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
|
||||
} latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
@@ -183,8 +205,7 @@ export class OtpService {
|
||||
// user who never receives a code — indistinguishable from carrier loss,
|
||||
// and the misleading success response makes it look like our side worked.
|
||||
this.logger.error(
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
|
||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
} — no transport reported hand-off; no code will arrive for this send`,
|
||||
);
|
||||
}
|
||||
@@ -209,8 +230,7 @@ export class OtpService {
|
||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||
this.logger.error(
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
||||
Date.now() - startedAt
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
|
||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
@@ -270,9 +290,7 @@ export class OtpService {
|
||||
* address while printing the credential next to it would buy nothing.
|
||||
*/
|
||||
private targetLabel(target: OtpTarget): string {
|
||||
return (
|
||||
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
|
||||
);
|
||||
return [target.email, target.phone].filter(Boolean).join("+") || "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,9 +306,8 @@ export class OtpService {
|
||||
) {
|
||||
const line = `otp.verify channels=${channelsOf(target).join(
|
||||
"+",
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
||||
detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
if (result === "ok") this.logger.log(line);
|
||||
else this.logger.warn(line);
|
||||
}
|
||||
@@ -439,7 +456,12 @@ export class OtpService {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`);
|
||||
this.logVerify(
|
||||
target,
|
||||
"action",
|
||||
"expired",
|
||||
`ageMs=${ageMs} ttlMs=${ttlMs}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
|
||||
@@ -80,7 +80,12 @@ const makeBooking = (
|
||||
|
||||
describe('TrainSchedulingService', () => {
|
||||
let service: TrainSchedulingService;
|
||||
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock };
|
||||
let dataSource: {
|
||||
getRepository: jest.Mock;
|
||||
transaction: jest.Mock;
|
||||
query: jest.Mock;
|
||||
manager: { getRepository: jest.Mock };
|
||||
};
|
||||
let bookingsRepository: Record<string, jest.Mock>;
|
||||
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let wagonTypesRepository: { findAll: jest.Mock };
|
||||
@@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => {
|
||||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
// findGroupSiblings runs a query builder off dataSource.manager; default it
|
||||
// to "no sibling schedules" so isolated unit tests don't need to wire it.
|
||||
const emptySiblingQb = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
dataSource = {
|
||||
getRepository: jest.fn(),
|
||||
transaction: jest.fn(),
|
||||
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
manager: {
|
||||
getRepository: jest.fn(() => ({
|
||||
createQueryBuilder: jest.fn(() => emptySiblingQb),
|
||||
})),
|
||||
},
|
||||
};
|
||||
bookingsRepository = {
|
||||
findEligibleForScheduling: jest.fn(),
|
||||
@@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => {
|
||||
findByIdWithFullGraph: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
maxReferenceSequence: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
trainScheduleBookingsRepository = {
|
||||
findByBookingIds: jest.fn(),
|
||||
findByScheduleId: jest.fn().mockResolvedValue([]),
|
||||
createMany: jest.fn(),
|
||||
deleteByScheduleAndBooking: jest.fn(),
|
||||
};
|
||||
@@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
it('allows preview when bookings are already on the target schedule', async () => {
|
||||
const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)];
|
||||
// A booking already pinned to the target schedule is exempt from the
|
||||
// corridor/day/status gates — mark it so on the entity, matching the link row.
|
||||
const bookings = [
|
||||
{ ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' },
|
||||
];
|
||||
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||
@@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => {
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('allows preview when selected bookings are on different schedule dates', async () => {
|
||||
it('flags a booking scheduled for a different day than the train departure', async () => {
|
||||
// The old cross-booking "must share the same schedule date" rule is gone;
|
||||
// the live rule is that every booking must match the departure day. b2
|
||||
// departs a day later, so it's the one flagged.
|
||||
const bookings = [
|
||||
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
|
||||
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
|
||||
@@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => {
|
||||
expect(result.violations).not.toContain(
|
||||
'Selected bookings must share the same schedule date',
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.violations.some((v) => v.includes('different day'))).toBe(true);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects bookings that are not in schedulable status', async () => {
|
||||
@@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => {
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-destination',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
direction: 'IMPORT',
|
||||
};
|
||||
|
||||
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
||||
@@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => {
|
||||
const trainScheduleRepo = {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
|
||||
// findGroupWindowAnchor looks for same-day sibling schedules; none here.
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
const trainSetRepo = {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
@@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => {
|
||||
callback(manager),
|
||||
);
|
||||
|
||||
// Departure must clear the import lead window (≥ importWindowLeadDays ahead
|
||||
// of now), so use a comfortably-future date rather than a hardcoded one.
|
||||
const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const result = await service.createContainerTrainSchedule({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
scheduleDate: futureDeparture,
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
});
|
||||
|
||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
|
||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
|
||||
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
|
||||
{ status: 'ASSIGNED' },
|
||||
);
|
||||
// Advance scheduling locks locomotives but does NOT flip them to ASSIGNED —
|
||||
// one locomotive may sit on several future schedules.
|
||||
expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled();
|
||||
expect(result.id).toBe('schedule-1');
|
||||
});
|
||||
|
||||
@@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => {
|
||||
destinationYardId: 'yard-destination',
|
||||
status: 'PAID',
|
||||
bookingContainers: [],
|
||||
cargoType: { code: 'COFFEE' },
|
||||
cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
|
||||
};
|
||||
|
||||
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
|
||||
@@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.summary.wagonType).toBe('MIXED');
|
||||
// Mixed freight now labels the summary by the concrete wagon type codes it uses.
|
||||
expect(result.summary.wagonType).toContain('NW5');
|
||||
expect(result.summary.wagonType).toContain('CW3');
|
||||
expect(result.wagonPlan.length).toBeGreaterThan(2);
|
||||
expect(result.containerUnits).toHaveLength(2);
|
||||
});
|
||||
@@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
it('rejects create when the locked locomotive is no longer available', async () => {
|
||||
// Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other
|
||||
// non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning.
|
||||
const manager = {
|
||||
getRepository: jest.fn(() => ({
|
||||
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
|
||||
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -551,6 +592,8 @@ describe('TrainSchedulingService', () => {
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-destination',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
direction: 'IMPORT',
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
describe('getAvailableLocomotivesForRoute', () => {
|
||||
it('returns locomotives at the route origin yard', async () => {
|
||||
it('returns every in-service locomotive, annotated with origin-yard presence', async () => {
|
||||
const routeId = 'route-export';
|
||||
const originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
@@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: routeId,
|
||||
name: 'Addis → Djibouti',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Djibouti' },
|
||||
@@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
// Advance-scheduling picker: nothing is filtered by yard — every in-service
|
||||
// locomotive is returned and annotated with whether it's at the origin yet.
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
{ id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({
|
||||
where: { status: 'AVAILABLE', currentYardId: originYardId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].code).toBe('EXP');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true);
|
||||
expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false);
|
||||
});
|
||||
|
||||
it('returns all locomotives returned by the repository for domestic routes', async () => {
|
||||
it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => {
|
||||
const routeId = 'route-domestic';
|
||||
const originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
@@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: routeId,
|
||||
name: 'Addis → Dire Dawa',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Ethiopia' },
|
||||
@@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
await expect(
|
||||
service.getAvailableLocomotivesForRoute(routeId),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
|
||||
// One driver ⇒ one truck: create/update must refuse a driver already assigned
|
||||
// to another (non-deleted) vehicle until they are detached.
|
||||
describe('VehiclesService driver assignment guard', () => {
|
||||
const otherTruck = { id: 'v2', plateNumber: '3-11111', assignedDriverId: 'd1' };
|
||||
|
||||
const makeService = (findOne: jest.Mock) =>
|
||||
new VehiclesService(
|
||||
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
|
||||
{ record: jest.fn() } as any,
|
||||
);
|
||||
|
||||
it('rejects create when the driver is on another truck', async () => {
|
||||
// First findOne = plate uniqueness (null), second = driver holder.
|
||||
const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck);
|
||||
const svc = makeService(findOne);
|
||||
await expect(
|
||||
svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects update when reassigning a driver still attached elsewhere', async () => {
|
||||
const findOne = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: null }) // findById
|
||||
.mockResolvedValueOnce(otherTruck); // driver holder
|
||||
const svc = makeService(findOne);
|
||||
await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows update that keeps the same driver on the same truck', async () => {
|
||||
const findOne = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: 'd1' });
|
||||
const svc = makeService(findOne);
|
||||
await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).resolves.toBeDefined();
|
||||
expect(findOne).toHaveBeenCalledTimes(1); // guard skipped — no holder lookup
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,25 @@ export class VehiclesService {
|
||||
private readonly history: FleetHistoryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A driver holds one truck at a time — reassignment requires detaching them
|
||||
* from their current truck first.
|
||||
* ponytail: app-level guard only (race window); add a partial unique index on
|
||||
* assigned_driver_id if concurrent fleet edits ever become real.
|
||||
*/
|
||||
private async assertDriverUnassigned(driverId: string, exceptVehicleId?: string): Promise<void> {
|
||||
const holder = await this.vehicleRepo.findOne({
|
||||
where: exceptVehicleId
|
||||
? { assignedDriverId: driverId, id: Not(exceptVehicleId) }
|
||||
: { assignedDriverId: driverId },
|
||||
});
|
||||
if (holder) {
|
||||
throw new ConflictException(
|
||||
`This driver is already assigned to truck ${holder.plateNumber ?? holder.code ?? holder.id} — detach the driver from that truck first`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
||||
const existing = await this.vehicleRepo.findOne({
|
||||
where: { plateNumber: dto.plateNumber },
|
||||
@@ -31,6 +50,10 @@ export class VehiclesService {
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.assignedDriverId) {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId);
|
||||
}
|
||||
|
||||
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||
const vehicle = this.vehicleRepo.create({
|
||||
...dto,
|
||||
@@ -121,6 +144,10 @@ export class VehiclesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.assignedDriverId && dto.assignedDriverId !== vehicle.assignedDriverId) {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId, id);
|
||||
}
|
||||
|
||||
const prev = {
|
||||
assignedDriverId: vehicle.assignedDriverId,
|
||||
assignedDriverName: vehicle.assignedDriverName,
|
||||
|
||||
@@ -556,6 +556,12 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/location')
|
||||
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
|
||||
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.bookingLocation(bookingId);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
|
||||
@@ -1030,6 +1030,28 @@ export class WarehouseInventoryService {
|
||||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Warehouse location rows for one booking, trimmed for the customer portal:
|
||||
* no staff guard on the route, so only location fields leave the API —
|
||||
* never notes, fees or inspection internals.
|
||||
*/
|
||||
async bookingLocation(bookingId: string) {
|
||||
const items = await this.inventoryRepository.findAll({
|
||||
where: { bookingId },
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return items.map((i) => ({
|
||||
id: i.id,
|
||||
bookingId: i.bookingId,
|
||||
status: i.status,
|
||||
arrivedAt: i.arrivedAt ?? null,
|
||||
warehouse: i.warehouse ? { id: i.warehouse.id, name: i.warehouse.name, code: i.warehouse.code } : null,
|
||||
yard: i.yard ? { id: i.yard.id, name: i.yard.name, code: i.yard.code } : null,
|
||||
zone: i.zone ? { id: i.zone.id, name: i.zone.name, code: i.zone.code } : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseInventory> {
|
||||
const item = await this.inventoryRepository.findById(id, {
|
||||
relations: { warehouse: { facility: true }, yard: true, zone: true },
|
||||
@@ -2989,6 +3011,29 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
// A truck leaves with the containers ASSIGNED to it — never another
|
||||
// truck's. Enforced whenever the truck has an assigned load on file
|
||||
// (customer self-haul or EDR last-mile).
|
||||
if (dto.containerNumber && dto.truckPlateNumber?.trim()) {
|
||||
const selectedNumbers = dto.containerNumber
|
||||
.split(/[,;\n]+/)
|
||||
.map((n) => n.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
const assigned = await this.truckAssignedContainers(
|
||||
item.bookingId,
|
||||
dto.truckPlateNumber.trim(),
|
||||
);
|
||||
if (assigned.length && selectedNumbers.length) {
|
||||
const foreign = selectedNumbers.filter((n) => !assigned.includes(n));
|
||||
if (foreign.length) {
|
||||
throw new BadRequestException(
|
||||
`Container${foreign.length > 1 ? 's' : ''} ${foreign.join(', ')} ` +
|
||||
`not assigned to truck ${dto.truckPlateNumber.trim()} — each truck may only carry out its own assigned containers`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||
// total VGM cargo weight of the containers selected as loaded on it.
|
||||
// Skipped when the operator chose not to weigh (containers only).
|
||||
@@ -3325,6 +3370,17 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* customer_truck_assignments.gross_weight_kg holds TONNES for gate-out
|
||||
* recorded exits but real KG for legacy departTruck rows. Exit papers always
|
||||
* print tonnes — normalise on read.
|
||||
*/
|
||||
// ponytail: >1000 heuristic (no truck hauls 1000+ t, no weighbridge reads <1000 kg);
|
||||
// migrate the column to tonnes if it ever bites.
|
||||
private grossAsTons(value: number): number {
|
||||
return value > 1000 ? Math.round(value) / 1000 : value;
|
||||
}
|
||||
|
||||
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
@@ -3384,7 +3440,40 @@ export class WarehouseInventoryService {
|
||||
grossWeightKg: string | number | null;
|
||||
departedAt: string | null;
|
||||
} | null = null;
|
||||
if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) {
|
||||
// The exit-inspection note written at gate-out names the truck doing THIS
|
||||
// exit — resolve by its plate first. The item's own container may not be on
|
||||
// the departing truck at all (trucks pick containers freely per trip).
|
||||
const notePlates = [...String(row?.notes ?? '').matchAll(/Truck Plate:\s*(\S+)/gi)];
|
||||
const exitPlate = notePlates.length ? notePlates[notePlates.length - 1][1] : null;
|
||||
if (row?.tradeDirection === 'IMPORT' && row?.bookingId && exitPlate) {
|
||||
const [truckRow] = await this.dataSource.query(
|
||||
`SELECT a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
a.truck_type AS "truckType",
|
||||
a.gross_weight_kg AS "grossWeightKg",
|
||||
a.departed_at AS "departedAt",
|
||||
string_agg(DISTINCT c.container_number, ', ' ORDER BY c.container_number) AS "containerNumbers",
|
||||
COALESCE((
|
||||
SELECT SUM(bcu.vgm_tons)
|
||||
FROM freight.customer_truck_containers cc
|
||||
JOIN freight.booking_container_units bcu
|
||||
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
AND bc.booking_id = a.booking_id
|
||||
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
||||
), 0) AS "truckWeightTons"
|
||||
FROM freight.customer_truck_assignments a
|
||||
LEFT JOIN freight.customer_truck_containers c
|
||||
ON c.assignment_id = a.id AND c.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL
|
||||
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.gross_weight_kg, a.departed_at
|
||||
LIMIT 1`,
|
||||
[row.bookingId, exitPlate],
|
||||
);
|
||||
truck = truckRow ?? null;
|
||||
}
|
||||
if (!truck && row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) {
|
||||
const [truckRow] = await this.dataSource.query(
|
||||
`SELECT a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
@@ -3414,6 +3503,26 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
truck = truckRow ?? null;
|
||||
}
|
||||
// Bulk self-haul (no container to match) or an unmatched container: the exit
|
||||
// paper is still PER TRUCK — use the latest departed customer truck and its
|
||||
// weighed gross, never the booking's declared total.
|
||||
if (!truck && row?.tradeDirection === 'IMPORT' && row?.bookingId) {
|
||||
const [truckRow] = await this.dataSource.query(
|
||||
`SELECT a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
a.truck_type AS "truckType",
|
||||
a.gross_weight_kg AS "grossWeightKg",
|
||||
a.departed_at AS "departedAt",
|
||||
NULL AS "containerNumbers",
|
||||
a.net_weight_tons AS "truckWeightTons"
|
||||
FROM freight.customer_truck_assignments a
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.departed_at IS NOT NULL
|
||||
ORDER BY a.departed_at DESC
|
||||
LIMIT 1`,
|
||||
[row.bookingId],
|
||||
);
|
||||
truck = truckRow ?? null;
|
||||
}
|
||||
|
||||
const bookingReference = row?.bookingReference || 'N/A';
|
||||
const reference =
|
||||
@@ -3428,7 +3537,9 @@ export class WarehouseInventoryService {
|
||||
customerName: row?.customerName ?? null,
|
||||
freightType: row?.freightType ?? null,
|
||||
tradeDirection: row?.tradeDirection ?? null,
|
||||
containerNumber: row?.containerNumber ?? null,
|
||||
// Per-truck exit: list every container leaving on THIS truck, not just
|
||||
// the inventory item's own container.
|
||||
containerNumber: truck?.containerNumbers ?? row?.containerNumber ?? null,
|
||||
cargoDescription: row?.cargoDescription ?? null,
|
||||
quantity: Number(row?.quantity ?? 0),
|
||||
weight: Number(row?.weight ?? 0),
|
||||
@@ -3442,12 +3553,12 @@ export class WarehouseInventoryService {
|
||||
truckDriverName: truck?.driverName ?? null,
|
||||
truckType: truck?.truckType ?? null,
|
||||
truckGateOut: truck?.departedAt ?? null,
|
||||
// Prefer the weighed gross captured on departure; fall back to the summed
|
||||
// container VGM when the truck hasn't been weighed yet.
|
||||
truckWeightKg: truck
|
||||
? Number(truck.grossWeightKg ?? 0) > 0
|
||||
? Number(truck.grossWeightKg)
|
||||
: Number(truck.truckWeightTons ?? 0) * 1000
|
||||
// Per-truck load in tonnes: the summed VGM of the containers on this truck
|
||||
// (recorded net for bulk); the weighed gross only as fallback.
|
||||
truckWeightTons: truck
|
||||
? Number(truck.truckWeightTons ?? 0) > 0
|
||||
? Number(truck.truckWeightTons)
|
||||
: this.grossAsTons(Number(truck.grossWeightKg ?? 0))
|
||||
: null,
|
||||
});
|
||||
|
||||
@@ -3565,6 +3676,32 @@ export class WarehouseInventoryService {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Container numbers assigned to a truck (by plate) on this booking, from both
|
||||
* haulage paths: customer self-haul (customer_truck_containers) and EDR
|
||||
* last-mile (last_mile_vehicle_containers / legacy scalar). Uppercased.
|
||||
*/
|
||||
private async truckAssignedContainers(bookingId: string, plate: string): Promise<string[]> {
|
||||
const rows: Array<{ cn: string | null }> = await this.dataSource.query(
|
||||
`SELECT UPPER(cc.container_number) AS cn
|
||||
FROM freight.customer_truck_assignments a
|
||||
JOIN freight.customer_truck_containers cc
|
||||
ON cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT UPPER(COALESCE(vc.container_number, va.container_number)) AS cn
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))`,
|
||||
[bookingId, plate],
|
||||
);
|
||||
return rows.map((r) => r.cn).filter((n): n is string => Boolean(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's containers with their VGM cargo weight (tonnes), keyed by
|
||||
* container number. Drives the truck-leaving exit weighing: the selected
|
||||
@@ -3620,10 +3757,17 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
|
||||
|
||||
const containers: Array<{ containerNumber: string; goods: string | null }> =
|
||||
const containers: Array<{ containerNumber: string; goods: string | null; vgmTons: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT c.container_number AS "containerNumber",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
|
||||
(SELECT SUM(u.vgm_tons)
|
||||
FROM freight.booking_container_units u
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE u.container_number = c.container_number
|
||||
AND bc.booking_id = c.booking_id
|
||||
AND u.deleted_at IS NULL) AS "vgmTons"
|
||||
FROM freight.customer_truck_containers c
|
||||
JOIN freight.bookings b ON b.id = c.booking_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
@@ -3632,6 +3776,9 @@ export class WarehouseInventoryService {
|
||||
[assignmentId],
|
||||
);
|
||||
|
||||
// Truck load in tonnes: summed container VGM; the weighed gross only as
|
||||
// fallback (bulk trucks carry no containers).
|
||||
const vgmSum = containers.reduce((s, c) => s + (Number(c.vgmTons) || 0), 0);
|
||||
const html = this.buildTruckExitPaperHtml({
|
||||
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
|
||||
bookingReference: truck.bookingReference,
|
||||
@@ -3639,7 +3786,7 @@ export class WarehouseInventoryService {
|
||||
plateNumber: truck.plateNumber,
|
||||
driverName: truck.driverName,
|
||||
truckType: truck.truckType,
|
||||
grossWeightKg: Number(truck.grossWeightKg ?? 0),
|
||||
grossWeightKg: vgmSum > 0 ? vgmSum : this.grossAsTons(Number(truck.grossWeightKg ?? 0)),
|
||||
gateOut: truck.departedAt,
|
||||
containers,
|
||||
});
|
||||
@@ -5146,7 +5293,7 @@ export class WarehouseInventoryService {
|
||||
truckDriverName?: string | null;
|
||||
truckType?: string | null;
|
||||
truckGateOut?: string | null;
|
||||
truckWeightKg?: number | null;
|
||||
truckWeightTons?: number | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -5173,8 +5320,8 @@ export class WarehouseInventoryService {
|
||||
['Quantity', data.quantity],
|
||||
[
|
||||
data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight',
|
||||
`${(data.truckPlateNumber && data.truckWeightKg
|
||||
? data.truckWeightKg
|
||||
`${(data.truckPlateNumber && data.truckWeightTons
|
||||
? data.truckWeightTons
|
||||
: data.weight
|
||||
).toLocaleString()} t`,
|
||||
],
|
||||
|
||||
@@ -304,4 +304,6 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
|
||||
{ key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] },
|
||||
{ key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] },
|
||||
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
|
||||
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] },
|
||||
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] },
|
||||
];
|
||||
|
||||
@@ -804,6 +804,48 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
||||
FREIGHT_PERMS.allocation.manage,
|
||||
]),
|
||||
// Operations Chief: full operational authority — the entire freight
|
||||
// permission catalog (all CRUD across bookings, contracts, scheduling,
|
||||
// fleet, warehouse, mile, finance, settings, staff).
|
||||
operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]),
|
||||
// Dispatcher: warehouse floor operations — receive/GRN, move, load/unload,
|
||||
// inspect, dispatch, gate, release/deliver, interchange docs, fee invoices,
|
||||
// plus truck dispatch on the mile legs and read-only operational context.
|
||||
// Allocation & fee rules are VIEW-ONLY — never create/update/delete.
|
||||
dispatcher: dedupe([
|
||||
FREIGHT_PERMS.warehouseDashboard.view,
|
||||
FREIGHT_PERMS.warehouses.view,
|
||||
FREIGHT_PERMS.warehouseYards.view,
|
||||
FREIGHT_PERMS.warehouseZones.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.warehouseInventory.receive,
|
||||
FREIGHT_PERMS.warehouseInventory.move,
|
||||
FREIGHT_PERMS.warehouseInventory.load,
|
||||
FREIGHT_PERMS.warehouseInventory.unload,
|
||||
FREIGHT_PERMS.warehouseInventory.dispatch,
|
||||
FREIGHT_PERMS.warehouseInventory.gatePass,
|
||||
FREIGHT_PERMS.warehouseInventory.release,
|
||||
FREIGHT_PERMS.warehouseInventory.deliver,
|
||||
FREIGHT_PERMS.warehouseInventory.inspect,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.view,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.create,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.update,
|
||||
FREIGHT_PERMS.interchangeDocuments.view,
|
||||
FREIGHT_PERMS.interchangeDocuments.generate,
|
||||
FREIGHT_PERMS.interchangeDocuments.acknowledge,
|
||||
FREIGHT_PERMS.warehouseFeeInvoices.view,
|
||||
FREIGHT_PERMS.warehouseFeeInvoices.generate,
|
||||
// View-only on the rules that govern allocation and fees.
|
||||
FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||
FREIGHT_PERMS.warehouseFeeRules.view,
|
||||
// Truck dispatch on the EDR mile legs + operational context.
|
||||
FREIGHT_PERMS.firstMile.view,
|
||||
FREIGHT_PERMS.firstMile.assignVehicles,
|
||||
FREIGHT_PERMS.lastMile.view,
|
||||
FREIGHT_PERMS.lastMile.assignVehicles,
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
]),
|
||||
} as const;
|
||||
|
||||
/** Derive the module bucket from the resource segment of a permission key. */
|
||||
|
||||
@@ -385,26 +385,31 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Import Overview",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Arrival Queue",
|
||||
href: "/dashboard/arrival-queue",
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||
icon: <Package />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
icon: <Boxes />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -418,36 +423,43 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Export Overview",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Loading Queue",
|
||||
href: "/dashboard/loading-queue",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Loaded Inventory",
|
||||
href: "/dashboard/loaded-inventory",
|
||||
icon: <PackageCheck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Djibouti Unloading",
|
||||
href: "/dashboard/export-djibouti-unloading",
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Interchange Documents",
|
||||
href: "/dashboard/interchange-documents",
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.interchangeDocuments.view,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory?direction=EXPORT",
|
||||
icon: <Package />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -461,6 +473,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Intercity Cargo",
|
||||
href: "/dashboard/intercity",
|
||||
icon: <TrainFront />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -492,7 +505,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
icon: <SlidersHorizontal />,
|
||||
permission: FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||
permission: [
|
||||
FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||
FREIGHT_PERMS.warehouseFeeRules.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Fee Invoices",
|
||||
@@ -555,7 +571,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Staff",
|
||||
href: "/user-management",
|
||||
icon: <Users />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
permission: [
|
||||
FREIGHT_PERMS.admin,
|
||||
FREIGHT_PERMS.staff.roles.view,
|
||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -600,16 +621,27 @@ const filterSidebarByPermission = (
|
||||
return keys.some((key) => hasFreightPermission(user, key));
|
||||
};
|
||||
|
||||
const itemAllowed = (item: SidebarItem): boolean => {
|
||||
// GL positions are locked to their single clearance page.
|
||||
if (etGl) return isEtClearanceItem(item);
|
||||
if (djGl) return isDjClearanceItem(item);
|
||||
|
||||
// Everyone else: hide the GL-only clearance pages entirely.
|
||||
if (isClearanceItem(item)) return false;
|
||||
|
||||
return permissionAllowed(item);
|
||||
};
|
||||
// Recursive: children are filtered first; a group (item with children) stays
|
||||
// only while it still has visible children — so parents without their own
|
||||
// permission key never leak a whole subtree the user cannot open.
|
||||
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
|
||||
items
|
||||
.map((item) =>
|
||||
item.children ? { ...item, children: filterItems(item.children) } : item,
|
||||
)
|
||||
.filter((item) => {
|
||||
if (etGl || djGl) {
|
||||
// GL positions are locked to their single clearance page (parents
|
||||
// survive only as the path to that page).
|
||||
const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem;
|
||||
return isTarget(item) || (item.children?.length ?? 0) > 0;
|
||||
}
|
||||
// Everyone else: hide the GL-only clearance pages entirely.
|
||||
if (isClearanceItem(item)) return false;
|
||||
if (!permissionAllowed(item)) return false;
|
||||
if (item.children) return item.children.length > 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Recursive: a group's own permission gates the whole subtree, leaves are
|
||||
// checked individually, and a group with no surviving children disappears.
|
||||
|
||||
@@ -61,8 +61,11 @@ export default function ResetPasswordAction({
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
// SMS is domestic-only: a foreign number counts as unavailable, same as a
|
||||
// missing one, so staff can't send a link that will never arrive.
|
||||
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
|
||||
const channelMissing =
|
||||
!!target && (channel === "email" ? !target.email : !target.phone);
|
||||
!!target && (channel === "email" ? !target.email : !phoneUsable);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -106,9 +109,13 @@ export default function ResetPasswordAction({
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
disabled={!target.phone}
|
||||
disabled={!phoneUsable}
|
||||
description={
|
||||
target.phone ?? "No phone number on this account"
|
||||
!target.phone
|
||||
? "No phone number on this account"
|
||||
: target.phoneIsDomestic === false
|
||||
? `${target.phone} — foreign number, SMS unavailable; use email`
|
||||
: target.phone
|
||||
}
|
||||
/>
|
||||
<Radio
|
||||
|
||||
@@ -298,34 +298,82 @@ export function ProfileApprovalActions({
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.setProfileStatus.mutationOptions(),
|
||||
);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [decision, setDecision] = useState<
|
||||
"reject" | "suspend" | "reactivate" | null
|
||||
>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||
|
||||
const confirmReject = () => {
|
||||
// Decisions the customer must be given a reason for. Reject/suspend/reactivate
|
||||
// all capture a required message through the same modal; the API refuses
|
||||
// suspend/reactivate without one.
|
||||
const DECISIONS = {
|
||||
reject: {
|
||||
title: "Reject profile",
|
||||
intro:
|
||||
"Tell the customer what needs fixing. They'll see this note and can " +
|
||||
"amend and resubmit the role for approval.",
|
||||
label: "Reason for rejection",
|
||||
placeholder: "e.g. The uploaded business license is expired.",
|
||||
confirmLabel: "Reject profile",
|
||||
color: "red",
|
||||
status: "rejected" as ProfileStatus,
|
||||
},
|
||||
suspend: {
|
||||
title: "Suspend role",
|
||||
intro:
|
||||
"Explain why this role is being suspended. The customer will see this " +
|
||||
"message and cannot operate under the role until it is reactivated.",
|
||||
label: "Reason for suspension",
|
||||
placeholder: "e.g. Outstanding invoices unpaid for over 90 days.",
|
||||
confirmLabel: "Suspend role",
|
||||
color: "orange",
|
||||
status: "suspended" as ProfileStatus,
|
||||
},
|
||||
reactivate: {
|
||||
title: "Reactivate role",
|
||||
intro:
|
||||
"Explain why this role is being reactivated. The customer will see " +
|
||||
"this message and can operate under the role again.",
|
||||
label: "Reactivation message",
|
||||
placeholder: "e.g. Outstanding payments have been settled.",
|
||||
confirmLabel: "Reactivate role",
|
||||
color: "edr-green",
|
||||
status: "active" as ProfileStatus,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const openDecision = (kind: keyof typeof DECISIONS) => {
|
||||
setNote("");
|
||||
setDecision(kind);
|
||||
};
|
||||
|
||||
const active = decision ? DECISIONS[decision] : null;
|
||||
|
||||
const confirmDecision = () => {
|
||||
if (!active) return;
|
||||
mutate(
|
||||
{ profileId, status: "rejected", note: note.trim() },
|
||||
{ onSuccess: () => setRejectOpen(false) },
|
||||
{ profileId, status: active.status, note: note.trim() },
|
||||
{ onSuccess: () => setDecision(null) },
|
||||
);
|
||||
};
|
||||
|
||||
const rejectModal = (
|
||||
const decisionModal = active && (
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
title="Reject profile"
|
||||
opened
|
||||
onClose={() => setDecision(null)}
|
||||
title={active.title}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Tell the customer what needs fixing. They'll see this note and can
|
||||
amend and resubmit the role for approval.
|
||||
{active.intro}
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
placeholder="e.g. The uploaded business license is expired."
|
||||
label={active.label}
|
||||
placeholder={active.placeholder}
|
||||
autosize
|
||||
minRows={3}
|
||||
value={note}
|
||||
@@ -335,18 +383,18 @@ export function ProfileApprovalActions({
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectOpen(false)}
|
||||
onClick={() => setDecision(null)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
color={active.color}
|
||||
loading={isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
onClick={confirmDecision}
|
||||
>
|
||||
Reject profile
|
||||
{active.confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -368,7 +416,7 @@ export function ProfileApprovalActions({
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<>
|
||||
{rejectModal}
|
||||
{decisionModal}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -385,7 +433,7 @@ export function ProfileApprovalActions({
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => setRejectOpen(true)}
|
||||
onClick={() => openDecision("reject")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
@@ -411,29 +459,33 @@ export function ProfileApprovalActions({
|
||||
|
||||
if (status === "active") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("suspended")}
|
||||
>
|
||||
Suspend
|
||||
</Button>
|
||||
<>
|
||||
{decisionModal}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => openDecision("suspend")}
|
||||
>
|
||||
Suspend
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "suspended") {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{decisionModal}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
onClick={() => openDecision("reactivate")}
|
||||
>
|
||||
Reactivate
|
||||
</Button>
|
||||
|
||||
@@ -2,41 +2,59 @@ import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, FileText, Train, Users } from "lucide-react";
|
||||
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
const links = [
|
||||
{
|
||||
title: "Booking requests",
|
||||
description: "Review and action incoming freight bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: FileText,
|
||||
permission: [FREIGHT_PERMS.bookings.view],
|
||||
},
|
||||
{
|
||||
title: "Train scheduling v2",
|
||||
description: "Full allocation workflow — assign, pin wagons, finalize",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: Train,
|
||||
permission: [FREIGHT_PERMS.trainScheduling.view],
|
||||
},
|
||||
{
|
||||
title: "Trains",
|
||||
description: "Manage train master data and fleet status",
|
||||
href: "/dashboard/trains",
|
||||
icon: Train,
|
||||
permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view],
|
||||
},
|
||||
{
|
||||
title: "User management",
|
||||
description: "Employees, roles, and permissions",
|
||||
href: "/user-management",
|
||||
icon: Users,
|
||||
permission: [
|
||||
FREIGHT_PERMS.admin,
|
||||
FREIGHT_PERMS.staff.roles.view,
|
||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function OverviewQuickLinks() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const visible = links.filter((link) =>
|
||||
link.permission.some((key) => hasPermission(user, key)),
|
||||
);
|
||||
if (!visible.length) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="md" h="100%">
|
||||
<Text fw={600}>Quick links</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{links.map((link) => {
|
||||
{visible.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -378,6 +378,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const containerWeightByNumber = new Map(
|
||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||
);
|
||||
// A truck may only carry out its OWN assigned containers — when the selected
|
||||
// truck has an assigned load, other trucks' containers are not offered.
|
||||
const assignedLoad = (selectedOption?.containerNumbers ?? []).map((n) => n.toUpperCase());
|
||||
// Mantine Selects throw on duplicate option values — legacy bookings can carry
|
||||
// the same container number on two lines, so dedupe defensively.
|
||||
const containerSelectData = [
|
||||
@@ -390,7 +393,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
].filter(
|
||||
(option) =>
|
||||
assignedLoad.length === 0 ||
|
||||
assignedLoad.includes(option.value.toUpperCase()) ||
|
||||
containerNumbers.some((n) => n.trim().toUpperCase() === option.value.toUpperCase()),
|
||||
);
|
||||
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||
const selectedCargoWeight = Number(
|
||||
selectedContainerNumbers
|
||||
|
||||
@@ -81,6 +81,10 @@ const VIEW_FILTERS: Record<
|
||||
};
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
// Queue ordering: awaiting first approval → pending profile changes → the
|
||||
// rest, newest first within each group. The default, so whatever marketing
|
||||
// must act on is always on top of the list.
|
||||
{ value: "review:DESC", label: "Needs review first" },
|
||||
{ value: "createdAt:DESC", label: "Newest first" },
|
||||
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||
{ value: "name:ASC", label: "Name (A–Z)" },
|
||||
@@ -93,11 +97,11 @@ export default function CustomersPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [view, setView] = useState<CustomerView>("all");
|
||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
||||
const [sort, setSort] = useState<string>("review:DESC");
|
||||
|
||||
const filter = useMemo(() => {
|
||||
const [sortBy, sortOrder] = sort.split(":") as [
|
||||
"name" | "createdAt" | "updatedAt",
|
||||
"review" | "name" | "createdAt" | "updatedAt",
|
||||
"ASC" | "DESC",
|
||||
];
|
||||
return {
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
|
||||
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
|
||||
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
|
||||
import "@/components/overview/overview.css";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useOverview } from "@/hooks/useOverview";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||
|
||||
const TAB_ITEMS: Array<{
|
||||
@@ -40,6 +42,8 @@ const TAB_ITEMS: Array<{
|
||||
| "customers"
|
||||
| "staff";
|
||||
metricKey: string;
|
||||
/** Any of these keys grants the tab. */
|
||||
permission: string[];
|
||||
}> = [
|
||||
{
|
||||
value: "bookings",
|
||||
@@ -47,6 +51,7 @@ const TAB_ITEMS: Array<{
|
||||
icon: FileText,
|
||||
kpiKey: "bookings",
|
||||
metricKey: "totalActive",
|
||||
permission: [FREIGHT_PERMS.bookings.view],
|
||||
},
|
||||
{
|
||||
value: "contracts",
|
||||
@@ -54,6 +59,7 @@ const TAB_ITEMS: Array<{
|
||||
icon: FileSignature,
|
||||
kpiKey: "contracts",
|
||||
metricKey: "totalActive",
|
||||
permission: [FREIGHT_PERMS.contracts.view],
|
||||
},
|
||||
{
|
||||
value: "billing",
|
||||
@@ -61,6 +67,7 @@ const TAB_ITEMS: Array<{
|
||||
icon: Banknote,
|
||||
kpiKey: "billing",
|
||||
metricKey: "successfulPaymentsMtd",
|
||||
permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view],
|
||||
},
|
||||
{
|
||||
value: "operations",
|
||||
@@ -68,6 +75,12 @@ const TAB_ITEMS: Array<{
|
||||
icon: Train,
|
||||
kpiKey: "operations",
|
||||
metricKey: "trainsActive",
|
||||
permission: [
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.firstMile.view,
|
||||
FREIGHT_PERMS.lastMile.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "customers",
|
||||
@@ -75,6 +88,7 @@ const TAB_ITEMS: Array<{
|
||||
icon: Users,
|
||||
kpiKey: "customers",
|
||||
metricKey: "totalCustomers",
|
||||
permission: [FREIGHT_PERMS.customers.view],
|
||||
},
|
||||
{
|
||||
value: "staff",
|
||||
@@ -82,6 +96,12 @@ const TAB_ITEMS: Array<{
|
||||
icon: UserCheck,
|
||||
kpiKey: "staff",
|
||||
metricKey: "activeEmployees",
|
||||
permission: [
|
||||
FREIGHT_PERMS.admin,
|
||||
FREIGHT_PERMS.staff.roles.view,
|
||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -98,7 +118,19 @@ const OverviewPage = () => {
|
||||
const [range, setRange] = useState<OverviewRange>("30d");
|
||||
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
|
||||
const queryClient = useQueryClient();
|
||||
const { data: summary, isLoading, isError, refetch, isFetching } = useOverview(range);
|
||||
const { user } = useAuth();
|
||||
const { data: summary, isLoading, isError, error, refetch, isFetching } = useOverview(range);
|
||||
|
||||
// Permission-scoped view: only tabs the user may see; a restricted role
|
||||
// (e.g. operations) gets a summary 403 — that is not a connection problem.
|
||||
const visibleTabs = TAB_ITEMS.filter((tab) =>
|
||||
tab.permission.some((key) => hasPermission(user, key)),
|
||||
);
|
||||
const currentTab = visibleTabs.some((t) => t.value === activeTab)
|
||||
? activeTab
|
||||
: visibleTabs[0]?.value;
|
||||
const accessDenied =
|
||||
(error as { response?: { status?: number } } | null)?.response?.status === 403;
|
||||
|
||||
const handleRefresh = () => {
|
||||
void refetch();
|
||||
@@ -126,7 +158,7 @@ const OverviewPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
{isError && !accessDenied && (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
@@ -142,48 +174,52 @@ const OverviewPage = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
{TAB_ITEMS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.value;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
leftSection={<Icon size={17} />}
|
||||
rightSection={
|
||||
summary ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "edr-green" : "gray"}
|
||||
>
|
||||
{getTabBadge(tab)}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
{visibleTabs.length > 0 && (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(value) =>
|
||||
setActiveTab((value as OverviewTabKey) ?? visibleTabs[0].value)
|
||||
}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
{visibleTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = currentTab === tab.value;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
leftSection={<Icon size={17} />}
|
||||
rightSection={
|
||||
summary ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "edr-green" : "gray"}
|
||||
>
|
||||
{getTabBadge(tab)}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
|
||||
{TAB_ITEMS.map((tab) => (
|
||||
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
|
||||
<OverviewTabContent tab={tab.value} range={range} />
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
{visibleTabs.map((tab) => (
|
||||
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
|
||||
<OverviewTabContent tab={tab.value} range={range} />
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<OverviewQuickLinks />
|
||||
|
||||
@@ -936,6 +936,12 @@ const FirstMilePage = () => {
|
||||
};
|
||||
|
||||
const openBulkAssign = () => {
|
||||
// A single selection has full booking context (details, container list) —
|
||||
// use the richer single-record flow instead of the blank bulk form.
|
||||
if (selectedIds.length === 1) {
|
||||
openAssign(selectedIds[0]);
|
||||
return;
|
||||
}
|
||||
setBulkMode(true);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
|
||||
@@ -1066,6 +1066,12 @@ const LastMilePage = () => {
|
||||
};
|
||||
|
||||
const openBulkAssign = () => {
|
||||
// A single selection has full booking context (details, container list) —
|
||||
// use the richer single-record flow instead of the blank bulk form.
|
||||
if (selectedIds.length === 1) {
|
||||
openAssign(selectedIds[0]);
|
||||
return;
|
||||
}
|
||||
setBulkMode(true);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
||||
|
||||
@@ -131,6 +131,8 @@ export interface CustomerResetTarget {
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
/** SMS gateway is domestic-only; `false` means SMS can't reach this phone. `null` = no phone. */
|
||||
phoneIsDomestic: boolean | null;
|
||||
}
|
||||
|
||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||
@@ -207,7 +209,8 @@ export interface CompanyListFilter {
|
||||
* already `active`, so `status` alone can never surface them.
|
||||
*/
|
||||
hasPendingChangeRequest?: boolean;
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
/** `review` = queue ordering: awaiting first approval → pending changes → rest, newest first within each. */
|
||||
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
|
||||
@@ -220,8 +220,14 @@ const sidebarItems: SidebarItem[] = [
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, company, companyType, createProfile, isAuthenticated } =
|
||||
useAuth();
|
||||
const {
|
||||
user,
|
||||
company,
|
||||
companyType,
|
||||
createProfile,
|
||||
reapplyProfile,
|
||||
isAuthenticated,
|
||||
} = useAuth();
|
||||
|
||||
// Attribute replays and exceptions to the signed-in user (id/org only).
|
||||
useIdentify(user, company);
|
||||
@@ -290,6 +296,7 @@ const App = () => {
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfile}
|
||||
onReapplyProfile={reapplyProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
Alert,
|
||||
AppShell,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
@@ -19,12 +21,14 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
Ban,
|
||||
ChevronDown,
|
||||
FileSignature,
|
||||
LogOut,
|
||||
Menu as MenuIcon,
|
||||
Moon,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
@@ -59,7 +63,13 @@ export interface AppLayoutProps {
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
|
||||
companyProfiles?: { type: string; reference: string; status?: string }[];
|
||||
companyProfiles?: {
|
||||
id?: string;
|
||||
type: string;
|
||||
reference: string;
|
||||
status?: string;
|
||||
reviewNote?: string | null;
|
||||
}[];
|
||||
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
|
||||
companyType?: string | null;
|
||||
/** Create a new service profile of the given type (with business license). */
|
||||
@@ -67,6 +77,11 @@ export interface AppLayoutProps {
|
||||
type: ServiceType,
|
||||
licenseFiles: File[],
|
||||
) => Promise<SwitchResult> | void;
|
||||
/** Resubmit a rejected service for approval, optionally replacing its license. */
|
||||
onReapplyProfile?: (
|
||||
profileId: string,
|
||||
licenseFiles: File[],
|
||||
) => Promise<SwitchResult> | void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -145,6 +160,7 @@ export function AppLayout({
|
||||
companyProfiles = [],
|
||||
companyType,
|
||||
onCreateProfile,
|
||||
onReapplyProfile,
|
||||
children,
|
||||
}: AppLayoutProps) {
|
||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||
@@ -178,42 +194,86 @@ export function AppLayout({
|
||||
const profileExists = (type: ServiceType) =>
|
||||
companyProfiles.some((p) => p.type === type);
|
||||
const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t));
|
||||
const canAddService = isCustomer && addableServices.length > 0;
|
||||
// Rejected services can't be re-added (they exist), so they'd otherwise be
|
||||
// invisible here — surface them for resubmission alongside addable ones.
|
||||
const rejectedServices = isCustomer
|
||||
? companyProfiles.filter(
|
||||
(p) =>
|
||||
p.status === "rejected" &&
|
||||
p.id &&
|
||||
CUSTOMER_SERVICES.includes(p.type as ServiceType),
|
||||
)
|
||||
: [];
|
||||
// Suspended services are also hidden by default (the profile exists) — surface
|
||||
// them so the customer can appeal by resubmitting a fresh business license.
|
||||
const suspendedServices = isCustomer
|
||||
? companyProfiles.filter(
|
||||
(p) =>
|
||||
p.status === "suspended" &&
|
||||
p.id &&
|
||||
CUSTOMER_SERVICES.includes(p.type as ServiceType),
|
||||
)
|
||||
: [];
|
||||
const canManageServices =
|
||||
isCustomer &&
|
||||
(addableServices.length > 0 ||
|
||||
rejectedServices.length > 0 ||
|
||||
suspendedServices.length > 0);
|
||||
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
|
||||
// Non-null while resubmitting a rejected/suspended service; null while creating a new one.
|
||||
const [reapplyId, setReapplyId] = useState<string | null>(null);
|
||||
// Status of the profile being resubmitted ("rejected" | "suspended") — drives
|
||||
// the modal copy; null for a brand-new profile.
|
||||
const [reapplyStatus, setReapplyStatus] = useState<string | null>(null);
|
||||
// Reason the profile was suspended/rejected, surfaced in the modal.
|
||||
const [reapplyNote, setReapplyNote] = useState<string | null>(null);
|
||||
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const handleAddService = (type: ServiceType) => {
|
||||
// Collect a business license, then create the profile.
|
||||
const openServiceModal = (
|
||||
type: ServiceType,
|
||||
profile?: { id?: string; status?: string; reviewNote?: string | null },
|
||||
) => {
|
||||
setCreateTarget(type);
|
||||
setReapplyId(profile?.id ?? null);
|
||||
setReapplyStatus(profile?.status ?? null);
|
||||
setReapplyNote(profile?.reviewNote ?? null);
|
||||
setLicenseFiles([]);
|
||||
setCreateError(null);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleAddService = (type: ServiceType) => openServiceModal(type);
|
||||
|
||||
const handleCreateConfirm = async () => {
|
||||
if (licenseFiles.length === 0) {
|
||||
const isReapply = reapplyId !== null;
|
||||
// A new profile needs its license up front; a resubmit may reuse the old one.
|
||||
if (!isReapply && licenseFiles.length === 0) {
|
||||
setCreateError("Please upload at least one business license file.");
|
||||
return;
|
||||
}
|
||||
setSwitching(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const res = await onCreateProfile?.(createTarget, licenseFiles);
|
||||
const res = isReapply
|
||||
? await onReapplyProfile?.(reapplyId, licenseFiles)
|
||||
: await onCreateProfile?.(createTarget, licenseFiles);
|
||||
if (res && !res.success) {
|
||||
setCreateError(res.error?.message ?? "Failed to create profile");
|
||||
setCreateError(res.error?.message ?? "Failed to submit service");
|
||||
return;
|
||||
}
|
||||
setCreateOpen(false);
|
||||
setReapplyId(null);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
||||
const isSuspendedAppeal = reapplyStatus === "suspended";
|
||||
|
||||
const isItemActive = (item: SidebarItem) =>
|
||||
activePath === item.href.toLowerCase() ||
|
||||
@@ -287,10 +347,10 @@ export function AppLayout({
|
||||
|
||||
{/* Right: switch + search + bell + avatar */}
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
{/* Add a service (customer companies that don't yet have all three) */}
|
||||
{canAddService && (
|
||||
{/* Add a service, or resubmit a rejected one (customer companies) */}
|
||||
{canManageServices && (
|
||||
<Menu
|
||||
width={220}
|
||||
width={240}
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
@@ -313,16 +373,62 @@ export function AppLayout({
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Add a service</Menu.Label>
|
||||
{addableServices.map((type) => (
|
||||
<Menu.Item
|
||||
key={type}
|
||||
onClick={() => handleAddService(type)}
|
||||
leftSection={<Plus size={15} strokeWidth={1.8} />}
|
||||
>
|
||||
{serviceLabel(type)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
{addableServices.length > 0 && (
|
||||
<>
|
||||
<Menu.Label>Add a service</Menu.Label>
|
||||
{addableServices.map((type) => (
|
||||
<Menu.Item
|
||||
key={type}
|
||||
onClick={() => handleAddService(type)}
|
||||
leftSection={<Plus size={15} strokeWidth={1.8} />}
|
||||
>
|
||||
{serviceLabel(type)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{rejectedServices.length > 0 && (
|
||||
<>
|
||||
{addableServices.length > 0 && <Menu.Divider />}
|
||||
<Menu.Label>Rejected — resubmit</Menu.Label>
|
||||
{rejectedServices.map((p) => (
|
||||
<Menu.Item
|
||||
key={p.id}
|
||||
color="red"
|
||||
onClick={() =>
|
||||
openServiceModal(p.type as ServiceType, p)
|
||||
}
|
||||
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
|
||||
>
|
||||
{serviceLabel(p.type as ServiceType)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{suspendedServices.length > 0 && (
|
||||
<>
|
||||
{(addableServices.length > 0 ||
|
||||
rejectedServices.length > 0) && <Menu.Divider />}
|
||||
<Menu.Label>Suspended — appeal</Menu.Label>
|
||||
{suspendedServices.map((p) => (
|
||||
<Menu.Item
|
||||
key={p.id}
|
||||
color="orange"
|
||||
onClick={() =>
|
||||
openServiceModal(p.type as ServiceType, p)
|
||||
}
|
||||
leftSection={<Ban size={15} strokeWidth={1.8} />}
|
||||
rightSection={
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
Suspended
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{serviceLabel(p.type as ServiceType)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
@@ -790,18 +896,42 @@ export function AppLayout({
|
||||
<Modal
|
||||
opened={createOpen}
|
||||
onClose={() => (switching ? undefined : setCreateOpen(false))}
|
||||
title={`Set up your ${serviceLabel(createTarget)} profile`}
|
||||
title={
|
||||
isSuspendedAppeal
|
||||
? `Appeal suspension — ${serviceLabel(createTarget)}`
|
||||
: reapplyId
|
||||
? `Resubmit your ${serviceLabel(createTarget)} service`
|
||||
: `Set up your ${serviceLabel(createTarget)} profile`
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
You don't have a {serviceLabel(createTarget).toLowerCase()} profile
|
||||
yet. Add your business license to create one and switch to{" "}
|
||||
{serviceLabel(createTarget).toLowerCase()}.
|
||||
{isSuspendedAppeal
|
||||
? `Your ${serviceLabel(
|
||||
createTarget,
|
||||
).toLowerCase()} service is currently suspended. Replace the business license if needed and resubmit — this sends your appeal back to EDR for review.`
|
||||
: reapplyId
|
||||
? `Your ${serviceLabel(
|
||||
createTarget,
|
||||
).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.`
|
||||
: `You don't have a ${serviceLabel(
|
||||
createTarget,
|
||||
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
|
||||
</Text>
|
||||
{isSuspendedAppeal && reapplyNote && (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
icon={<Ban size={16} />}
|
||||
title="Reason for suspension"
|
||||
>
|
||||
{reapplyNote}
|
||||
</Alert>
|
||||
)}
|
||||
<FileInput
|
||||
label="Business license"
|
||||
label={reapplyId ? "Business license (optional)" : "Business license"}
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
@@ -824,7 +954,11 @@ export function AppLayout({
|
||||
onClick={handleCreateConfirm}
|
||||
loading={switching}
|
||||
>
|
||||
Create & switch
|
||||
{isSuspendedAppeal
|
||||
? "Submit appeal"
|
||||
: reapplyId
|
||||
? "Resubmit"
|
||||
: "Create"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -11,22 +11,21 @@ interface NewBookingButtonProps {
|
||||
|
||||
/**
|
||||
* New-booking entry point that respects approval status: a customer can only
|
||||
* create bookings under a profile once the backoffice has approved it. While the
|
||||
* active profile is pending the button is disabled with an explanation, so the
|
||||
* gate is communicated rather than silently failing at submit time.
|
||||
* create bookings once the backoffice has approved at least one operational
|
||||
* role. While every role is still pending the button is disabled with an
|
||||
* explanation, so the gate is communicated rather than failing at submit time.
|
||||
*/
|
||||
export function NewBookingButton({
|
||||
label = "New booking",
|
||||
size,
|
||||
mt,
|
||||
}: NewBookingButtonProps) {
|
||||
const { canBook, activeProfileStatus } = useAuth();
|
||||
const { canBook, hasPendingProfile } = useAuth();
|
||||
|
||||
if (!canBook) {
|
||||
const message =
|
||||
activeProfileStatus === "pending"
|
||||
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved."
|
||||
: "Bookings aren't available for this profile yet.";
|
||||
const message = hasPendingProfile
|
||||
? "Your role is awaiting approval. You'll be able to create bookings as soon as it's approved."
|
||||
: "Bookings aren't available until one of your roles is approved.";
|
||||
return (
|
||||
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
|
||||
<Box mt={mt}>
|
||||
|
||||
@@ -99,7 +99,6 @@ export const URL_CONSTANTS = {
|
||||
PROFILE: "/api/companies/profile",
|
||||
COMPANY_PROFILES: "/api/companies/company-profiles",
|
||||
COMPANY_PROFILE: "/api/companies/company-profile",
|
||||
ACTIVE_MODE: "/api/companies/active-mode",
|
||||
ONBOARDING_START: "/api/companies/onboarding/start",
|
||||
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
||||
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
||||
|
||||
@@ -158,11 +158,7 @@ const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Active-mode (importer/exporter) state, sourced from the persisted profile.
|
||||
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
||||
const activeProfileType = companyInfo?.profile?.activeProfileType ?? null;
|
||||
const activeCompanyProfileId =
|
||||
companyInfo?.profile?.activeCompanyProfileId ?? null;
|
||||
const companyType = companyInfo?.company?.type ?? null;
|
||||
const companyStatus = companyInfo?.company?.status ?? null;
|
||||
// A company can create bookings only once an admin has approved it (active).
|
||||
@@ -171,14 +167,13 @@ const useAuth = () => {
|
||||
companyInfo?.profile?.onboardingCompleted ?? false;
|
||||
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
||||
|
||||
// Booking is gated on backoffice approval of the active operational profile:
|
||||
// a customer can only book under a profile once its status is "active".
|
||||
const activeProfile =
|
||||
companyInfo?.company?.companyProfiles?.find(
|
||||
(p) => p.id === activeCompanyProfileId,
|
||||
) ?? null;
|
||||
const activeProfileStatus = activeProfile?.status ?? null;
|
||||
const canBook = activeProfileStatus === "active";
|
||||
// A booking/contract stamps its operational profile from the trade direction
|
||||
// at creation time, so there's no "active mode": the customer can create work
|
||||
// as long as they have at least one backoffice-approved operational role.
|
||||
const companyProfiles = companyInfo?.company?.companyProfiles ?? [];
|
||||
const hasActiveProfile = companyProfiles.some((p) => p.status === "active");
|
||||
const hasPendingProfile = companyProfiles.some((p) => p.status === "pending");
|
||||
const canBook = hasActiveProfile;
|
||||
|
||||
// Profile-edit review: while a change request is pending the customer is
|
||||
// locked out of editing and of creating new contracts/bookings; a rejected
|
||||
@@ -188,7 +183,7 @@ const useAuth = () => {
|
||||
const reviewNote = review?.note ?? null;
|
||||
const isUnderReview = reviewStatus === "pending";
|
||||
|
||||
/** Refetch everything scoped to the active operational profile. */
|
||||
/** Refetch company info, dashboard, and bookings after a profile change. */
|
||||
const invalidateScopedData = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
@@ -201,16 +196,6 @@ const useAuth = () => {
|
||||
]);
|
||||
};
|
||||
|
||||
const switchMode = async (type: ProfileTypeValue): Promise<Result<void>> => {
|
||||
try {
|
||||
await api.companies.setActiveMode.call({ type });
|
||||
await invalidateScopedData();
|
||||
return { success: true, data: undefined };
|
||||
} catch (err) {
|
||||
return { success: false, error: extractApiError(err) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add an operational role. The new role starts pending review, so the active
|
||||
* mode is left untouched — the user keeps working under their approved role.
|
||||
@@ -231,11 +216,18 @@ const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Resubmit a rejected operational role for approval, then refresh. */
|
||||
/**
|
||||
* Resubmit a rejected operational role for approval — optionally replacing its
|
||||
* business license first (the common reason a role is rejected) — then refresh.
|
||||
*/
|
||||
const reapplyProfile = async (
|
||||
profileId: string,
|
||||
licenseFiles: File[] = [],
|
||||
): Promise<Result<void>> => {
|
||||
try {
|
||||
if (licenseFiles.length > 0) {
|
||||
await companiesService.uploadProfileLicense(profileId, licenseFiles);
|
||||
}
|
||||
await api.companies.reapplyProfile.call({ profileId });
|
||||
await invalidateScopedData();
|
||||
return { success: true, data: undefined };
|
||||
@@ -269,10 +261,9 @@ const useAuth = () => {
|
||||
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
||||
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
activeProfileType,
|
||||
activeCompanyProfileId,
|
||||
activeProfileStatus,
|
||||
canBook,
|
||||
hasActiveProfile,
|
||||
hasPendingProfile,
|
||||
companyType,
|
||||
companyStatus,
|
||||
isCompanyApproved,
|
||||
@@ -281,7 +272,6 @@ const useAuth = () => {
|
||||
isUnderReview,
|
||||
onboardingCompleted,
|
||||
onboardingStep,
|
||||
switchMode,
|
||||
createProfile,
|
||||
reapplyProfile,
|
||||
login,
|
||||
|
||||
@@ -106,7 +106,6 @@ export function captureApiError(error: unknown): void {
|
||||
/** Company context, as returned by `useAuth().company`. */
|
||||
interface IdentifyCompany {
|
||||
company?: { id?: string; type?: string | null; status?: string | null } | null;
|
||||
profile?: { activeProfileType?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +135,6 @@ export function useIdentify(
|
||||
company_id: company?.company?.id,
|
||||
company_type: company?.company?.type,
|
||||
company_status: company?.company?.status,
|
||||
active_profile_type: company?.profile?.activeProfileType,
|
||||
});
|
||||
}, [
|
||||
user?.id,
|
||||
@@ -146,6 +144,5 @@ export function useIdentify(
|
||||
company?.company?.id,
|
||||
company?.company?.type,
|
||||
company?.company?.status,
|
||||
company?.profile?.activeProfileType,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ import { api } from "@/services/api";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Fieldset,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
@@ -16,7 +19,7 @@ import {
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@@ -25,13 +28,20 @@ import {
|
||||
Building2,
|
||||
Clock,
|
||||
FileCheck,
|
||||
FileText,
|
||||
Globe,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
UploadCloud,
|
||||
User,
|
||||
UserCheck,
|
||||
UserCog,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { rolesForCompanyType } from "./settings/companyRoles";
|
||||
@@ -42,13 +52,16 @@ import TabDocuments from "./settings/TabDocuments";
|
||||
import TabGeneralManager from "./settings/TabGeneralManager";
|
||||
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
|
||||
|
||||
type SettingsTab = "account" | "company" | "contact" | "gm" | "poa" | "documents";
|
||||
type SettingsTab =
|
||||
| "account"
|
||||
| "company"
|
||||
| "contact"
|
||||
| "gm"
|
||||
| "poa"
|
||||
| "documents";
|
||||
|
||||
/** A section is "incomplete" when its required fields aren't filled in yet. */
|
||||
function tabIncomplete(
|
||||
tabId: SettingsTab,
|
||||
profile: ProfileResponse,
|
||||
): boolean {
|
||||
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
|
||||
switch (tabId) {
|
||||
case "company":
|
||||
return (
|
||||
@@ -66,8 +79,8 @@ function tabIncomplete(
|
||||
!profile.generalManagerPhone
|
||||
);
|
||||
case "account":
|
||||
// Account fields live on the IAM user, not the company profile, and are
|
||||
// always populated (signup requires them) — nothing to nag about here.
|
||||
// Account fields live on the IAM user, not the company profile, and are
|
||||
// always populated (signup requires them) — nothing to nag about here.
|
||||
case "poa":
|
||||
case "documents":
|
||||
return false;
|
||||
@@ -343,6 +356,7 @@ export default function SettingsPage() {
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabCompanyProfile mode="edit" profile={profile} />
|
||||
</Fieldset>
|
||||
<OperationalServicesCard profile={profile} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="contact">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
@@ -369,3 +383,203 @@ export default function SettingsPage() {
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
importer: "Importer",
|
||||
exporter: "Exporter",
|
||||
freight_forwarder: "Freight Forwarder",
|
||||
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||
transporter: "Transporter",
|
||||
};
|
||||
|
||||
const ROLE_STATUS: Record<string, { color: string; label: string }> = {
|
||||
active: { color: "edr-green", label: "Approved" },
|
||||
pending: { color: "yellow", label: "Awaiting approval" },
|
||||
rejected: { color: "red", label: "Rejected" },
|
||||
suspended: { color: "orange", label: "Suspended" },
|
||||
blacklisted: { color: "red", label: "Blocked" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists the company's operational services with approval status, and lets the
|
||||
* customer resubmit a rejected one — replacing its license first if the reviewer
|
||||
* flagged the document.
|
||||
*/
|
||||
function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const roles = profile.companyProfiles;
|
||||
|
||||
const refresh = () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Resubmit: upload any freshly-picked license files first, then flip the role
|
||||
// back to pending so it re-enters the approval queue.
|
||||
const resubmit = useMutation({
|
||||
mutationFn: async (args: { profileId: string; files: File[] }) => {
|
||||
if (args.files.length > 0) {
|
||||
await companiesService.uploadProfileLicense(args.profileId, args.files);
|
||||
}
|
||||
await api.companies.reapplyProfile.call({ profileId: args.profileId });
|
||||
},
|
||||
onSuccess: refresh,
|
||||
});
|
||||
|
||||
if (roles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card padding="lg" radius="lg" mt="lg">
|
||||
<Group gap="sm" mb="md">
|
||||
<Layers size={20} />
|
||||
<Title order={3}>Operational Services</Title>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
{roles.map((r) => {
|
||||
const status = ROLE_STATUS[r.status] ?? {
|
||||
color: "gray",
|
||||
label: r.status,
|
||||
};
|
||||
return (
|
||||
<Group
|
||||
key={r.id}
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
py="xs"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{ROLE_LABELS[r.type] ?? r.type}</Text>
|
||||
<Badge color={status.color} variant="light" radius="sm">
|
||||
{status.label}
|
||||
</Badge>
|
||||
{r.reference && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{r.reference}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{(r.status === "rejected" || r.status === "suspended") &&
|
||||
r.reviewNote && (
|
||||
<Text
|
||||
size="sm"
|
||||
c={r.status === "suspended" ? "orange.7" : "red.7"}
|
||||
>
|
||||
<strong>
|
||||
{r.status === "suspended"
|
||||
? "Suspension reason:"
|
||||
: "Reviewer note:"}
|
||||
</strong>{" "}
|
||||
{r.reviewNote}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{r.licenseFiles.length === 0 ? (
|
||||
<Text size="xs" c="edr-muted">
|
||||
No license document
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
{r.licenseFiles.map((f) => (
|
||||
<Group key={f.id} gap="xs" wrap="nowrap">
|
||||
<FileText
|
||||
size={14}
|
||||
className="text-edr-muted"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
{f.status !== "live" && (
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
f.status === "pending_remove" ? "red" : "yellow"
|
||||
}
|
||||
>
|
||||
{f.status === "pending_remove"
|
||||
? "Removal pending"
|
||||
: "Pending"}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{r.status === "rejected" && (
|
||||
<ResubmitService
|
||||
pending={resubmit.isPending}
|
||||
onResubmit={(files) =>
|
||||
resubmit.mutate({ profileId: r.id, files })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
{viewer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rejected-role actions: optionally replace the license, then resubmit. */
|
||||
function ResubmitService({
|
||||
pending,
|
||||
onResubmit,
|
||||
}: {
|
||||
pending: boolean;
|
||||
onResubmit: (files: File[]) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<FileButton onChange={setFiles} accept="application/pdf,image/*" multiple>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<UploadCloud size={14} />}
|
||||
>
|
||||
{files.length > 0 ? `${files.length} file(s)` : "Replace license"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Button
|
||||
size="xs"
|
||||
color="edr-green"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={pending}
|
||||
onClick={() => onResubmit(files)}
|
||||
>
|
||||
Resubmit
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,10 +211,6 @@ export function BookingPaymentPanel({
|
||||
queryKey: ["booking-invoices", booking.id],
|
||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||
});
|
||||
// The invoice worth a prominent "Download" — the first issued one, else any.
|
||||
const primary =
|
||||
invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0];
|
||||
const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false;
|
||||
|
||||
const downloadInvoice = async (inv: PortalInvoice) => {
|
||||
try {
|
||||
@@ -380,50 +376,67 @@ export function BookingPaymentPanel({
|
||||
</Box>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<InvoiceStatusBadge status={inv.status} />
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => downloadInvoice(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{invoices.length > 1 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => downloadInvoice(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{Number(inv.paidAmount) > 0 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download receipt"
|
||||
onClick={() => downloadReceipt(inv)}
|
||||
>
|
||||
<Receipt size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{primary && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
variant="default"
|
||||
radius={10}
|
||||
leftSection={<FileText size={17} color="#475569" />}
|
||||
onClick={() => downloadInvoice(primary)}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||
}}
|
||||
>
|
||||
Download invoice
|
||||
</Button>
|
||||
)}
|
||||
{primary && primaryPaid && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={8}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius={10}
|
||||
leftSection={<Receipt size={17} />}
|
||||
onClick={() => downloadReceipt(primary)}
|
||||
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||
>
|
||||
Download receipt
|
||||
</Button>
|
||||
{/* Single invoice: a prominent download instead of a lone row icon. */}
|
||||
{invoices.length === 1 && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
variant="default"
|
||||
radius={10}
|
||||
leftSection={<FileText size={17} color="#475569" />}
|
||||
onClick={() => downloadInvoice(invoices[0])}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||
}}
|
||||
>
|
||||
Download invoice
|
||||
</Button>
|
||||
{Number(invoices[0].paidAmount) > 0 && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={8}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius={10}
|
||||
leftSection={<Receipt size={17} />}
|
||||
onClick={() => downloadReceipt(invoices[0])}
|
||||
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||
>
|
||||
Download receipt
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -23,15 +23,15 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
|
||||
export function WarehouseLocationCard({ bookingId }: WarehouseLocationCardProps) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["warehouse-inventory", bookingId],
|
||||
queryFn: () => warehouseService.listInventory({ bookingId }),
|
||||
queryKey: ["warehouse-inventory", "booking-location", bookingId],
|
||||
queryFn: () => warehouseService.bookingLocation(bookingId),
|
||||
});
|
||||
|
||||
const items = data ?? [];
|
||||
const latest = items[0];
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" padding="lg">
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<WarehouseIcon size={18} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard, Download, Receipt } from "lucide-react";
|
||||
import { CreditCard, Download, FileText, Receipt } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -184,23 +184,27 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => download(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{Number(inv.paidAmount) > 0 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download receipt"
|
||||
onClick={() => downloadReceipt(inv)}
|
||||
>
|
||||
<Receipt size={16} />
|
||||
</ActionIcon>
|
||||
{invoices.length > 1 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => download(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{Number(inv.paidAmount) > 0 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download receipt"
|
||||
onClick={() => downloadReceipt(inv)}
|
||||
>
|
||||
<Receipt size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -208,6 +212,40 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* Single invoice: a prominent download instead of a lone row icon. */}
|
||||
{invoices.length === 1 && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
mt="md"
|
||||
variant="default"
|
||||
radius={10}
|
||||
leftSection={<FileText size={17} color="#475569" />}
|
||||
onClick={() => download(invoices[0])}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||
}}
|
||||
>
|
||||
Download invoice
|
||||
</Button>
|
||||
{Number(invoices[0].paidAmount) > 0 && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={8}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius={10}
|
||||
leftSection={<Receipt size={17} />}
|
||||
onClick={() => downloadReceipt(invoices[0])}
|
||||
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||
>
|
||||
Download receipt
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payInvoice !== null}
|
||||
onClose={closePayModal}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
bookingFormSchema,
|
||||
getRouteDirection,
|
||||
initialBookingFormValues,
|
||||
isForwarderOperation,
|
||||
operationToProfileType,
|
||||
operationToTradeDirection,
|
||||
stepFields,
|
||||
@@ -384,25 +385,23 @@ export default function NewBookingPage() {
|
||||
[profileTypes],
|
||||
);
|
||||
|
||||
// Stamp the booking to the right operational profile. Import/Export (and their
|
||||
// "as FF" variants) switch the active mode so the matching onboarding documents
|
||||
// are attached; Intercity uses whatever profile is already active.
|
||||
const handleOperationSelect = (op: OperationType) => {
|
||||
if (op === "intercity") return;
|
||||
const target = operationToProfileType(op, profileTypes);
|
||||
if (auth.activeProfileType !== target) {
|
||||
void auth.switchMode(target as never);
|
||||
}
|
||||
};
|
||||
|
||||
// Onboarding documents for the active profile — shown read-only in the
|
||||
// Documents step and attached to the booking on submit by the backend.
|
||||
const onboardingDocs = useMemo(() => {
|
||||
// The company_profile this booking belongs to, derived from the selected
|
||||
// operation: import→importer, export→exporter, and the "as FF" variants →
|
||||
// freight_forwarder. A forwarder booking is pinned explicitly on submit
|
||||
// (companyProfileId) because trade direction alone can't distinguish it.
|
||||
const selectedProfile = useMemo(() => {
|
||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||
const active =
|
||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
||||
return active?.licenseFiles ?? [];
|
||||
}, [auth.company, auth.activeCompanyProfileId]);
|
||||
if (!operationType) return profiles[0] ?? null;
|
||||
const targetType = operationToProfileType(operationType, profileTypes);
|
||||
return profiles.find((p) => p.type === targetType) ?? profiles[0] ?? null;
|
||||
}, [auth.company, operationType, profileTypes]);
|
||||
|
||||
// Onboarding documents for the resolved profile — shown read-only in the
|
||||
// Documents step and attached to the booking on submit by the backend.
|
||||
const onboardingDocs = useMemo(
|
||||
() => selectedProfile?.licenseFiles ?? [],
|
||||
[selectedProfile],
|
||||
);
|
||||
|
||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
||||
null,
|
||||
@@ -483,7 +482,16 @@ export default function NewBookingPage() {
|
||||
(s) => s.id === data.serviceTypeId,
|
||||
)!;
|
||||
|
||||
// Pin the profile only for a forwarder booking — trade direction resolves
|
||||
// importer/exporter on its own, but can't tell a forwarder apart.
|
||||
const forwarderProfileId =
|
||||
data.operationType &&
|
||||
isForwarderOperation(data.operationType, profileTypes)
|
||||
? selectedProfile?.id
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}),
|
||||
bookingType: isContract
|
||||
? Freight.BookingType.GeneralContract
|
||||
: Freight.BookingType.OneTime,
|
||||
@@ -711,7 +719,6 @@ export default function NewBookingPage() {
|
||||
<Step0OperationType
|
||||
form={form}
|
||||
allowedOperations={allowedOperations}
|
||||
onSelect={handleOperationSelect}
|
||||
/>
|
||||
)}
|
||||
{step === 1 && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type UseFormReturn } from "react-hook-form";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
operationToProfileType,
|
||||
type BookingDocuments,
|
||||
type BookingFormInputValues,
|
||||
type BookingFormValues,
|
||||
@@ -58,11 +59,20 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Documents already on file from onboarding (read-only reference).
|
||||
// Documents already on file from onboarding (read-only reference), for the
|
||||
// profile this booking's operation resolves to (importer/exporter/forwarder).
|
||||
const onboardingDocs = (() => {
|
||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||
const operationType = form.watch("operationType");
|
||||
const targetType = operationType
|
||||
? operationToProfileType(
|
||||
operationType,
|
||||
profiles.map((p) => p.type),
|
||||
)
|
||||
: null;
|
||||
const active =
|
||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
||||
(targetType ? profiles.find((p) => p.type === targetType) : undefined) ??
|
||||
profiles[0];
|
||||
return active?.licenseFiles ?? [];
|
||||
})();
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "./new-contract-form/schema";
|
||||
import {
|
||||
getRouteDirection,
|
||||
isForwarderOperation,
|
||||
operationToProfileType,
|
||||
operationToTradeDirection,
|
||||
} from "./new-contract-form/helpers";
|
||||
@@ -124,6 +125,30 @@ export default function NewContractPage({
|
||||
);
|
||||
}
|
||||
|
||||
// A suspended/blacklisted account must be told its real status — falling
|
||||
// through to the wizard here only to fail at submit read as "pending" before.
|
||||
if (
|
||||
!auth.isPending &&
|
||||
(auth.companyStatus === "suspended" || auth.companyStatus === "blacklisted")
|
||||
) {
|
||||
return (
|
||||
<GateNotice
|
||||
title={
|
||||
auth.companyStatus === "suspended"
|
||||
? "Account Suspended"
|
||||
: "Account Blacklisted"
|
||||
}
|
||||
body={
|
||||
auth.companyStatus === "suspended"
|
||||
? "Your company account has been suspended by EDR staff, so new contracts are disabled. Please contact EDR support for details."
|
||||
: "Your company account has been blacklisted, so new contracts are disabled. Please contact EDR support."
|
||||
}
|
||||
actionLabel="Back to Contracts"
|
||||
onAction={() => navigate("/contracts")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Profile changes pending review lock out new contract creation too.
|
||||
if (!auth.isPending && auth.isUnderReview) {
|
||||
return (
|
||||
@@ -349,13 +374,19 @@ export default function NewContractPage({
|
||||
// badges. Intercity rides any customer profile, so always "approved".
|
||||
const operationStatus = useMemo(
|
||||
() =>
|
||||
(op: OperationType): "approved" | "pending" | "rejected" | "missing" => {
|
||||
(
|
||||
op: OperationType,
|
||||
): "approved" | "pending" | "rejected" | "suspended" | "missing" => {
|
||||
if (op === "intercity") return "approved";
|
||||
const target = operationToProfileType(op, profileTypes);
|
||||
const status = profileStatusByType.get(target);
|
||||
if (!status) return "missing";
|
||||
if (status === "active") return "approved";
|
||||
if (status === "rejected") return "rejected";
|
||||
// Suspension is per-role: the customer keeps working under their other
|
||||
// roles, so this operation must say "suspended", not "pending".
|
||||
if (status === "suspended" || status === "blacklisted")
|
||||
return "suspended";
|
||||
return "pending";
|
||||
},
|
||||
[profileStatusByType, profileTypes],
|
||||
@@ -411,7 +442,9 @@ export default function NewContractPage({
|
||||
mutationFn: async (profileId: string) => {
|
||||
const res = await auth.reapplyProfile(profileId);
|
||||
if (!res.success) {
|
||||
throw new Error(res.error?.message ?? "Failed to resubmit for approval");
|
||||
throw new Error(
|
||||
res.error?.message ?? "Failed to resubmit for approval",
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => setPendingApprovalProfile(null),
|
||||
@@ -439,10 +472,8 @@ export default function NewContractPage({
|
||||
form.setValue("operationType", undefined as never, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
// Case 1 — approved: proceed, switching the active profile if needed.
|
||||
if (auth.activeProfileType !== target) {
|
||||
void auth.switchMode(target as never);
|
||||
}
|
||||
// Case 1 — approved: proceed. The profile is resolved from the operation at
|
||||
// submit time (a forwarder operation pins it explicitly), so nothing to set.
|
||||
};
|
||||
|
||||
const handleCreateProfileConfirm = () => {
|
||||
@@ -512,16 +543,16 @@ export default function NewContractPage({
|
||||
// GENERAL contract until its validity expires.
|
||||
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
||||
? data.enabledContainerSizes.map((size) => ({
|
||||
containerSize: size,
|
||||
// Required cargo description — what the containers carry.
|
||||
cargoFreeText: data.cargoFreeText.trim() || undefined,
|
||||
}))
|
||||
containerSize: size,
|
||||
// Required cargo description — what the containers carry.
|
||||
cargoFreeText: data.cargoFreeText.trim() || undefined,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
||||
cargoFreeText: data.cargoFreeText || undefined,
|
||||
},
|
||||
];
|
||||
{
|
||||
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
||||
cargoFreeText: data.cargoFreeText || undefined,
|
||||
},
|
||||
];
|
||||
|
||||
// Route — a single origin→destination lane, general contracts included.
|
||||
const routes: Freight.CreateContractRouteInputDto[] = [
|
||||
@@ -532,7 +563,20 @@ export default function NewContractPage({
|
||||
},
|
||||
];
|
||||
|
||||
// Pin the profile only for a forwarder contract (trade direction can't tell
|
||||
// a forwarder apart from a direct import/export).
|
||||
const forwarderProfileId =
|
||||
data.operationType &&
|
||||
isForwarderOperation(data.operationType, profileTypes)
|
||||
? (auth.company?.company?.companyProfiles ?? []).find(
|
||||
(p) =>
|
||||
p.type ===
|
||||
operationToProfileType(data.operationType!, profileTypes),
|
||||
)?.id
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}),
|
||||
contractKind: isGeneral
|
||||
? Freight.ContractKind.General
|
||||
: Freight.ContractKind.OneTime,
|
||||
@@ -547,11 +591,11 @@ export default function NewContractPage({
|
||||
// booking time, but only on contracts created WITH_RETURN.
|
||||
...(isContainer
|
||||
? {
|
||||
equipmentReturn:
|
||||
data.equipmentReturn === "with_return"
|
||||
? "WITH_RETURN"
|
||||
: "WITHOUT_RETURN",
|
||||
}
|
||||
equipmentReturn:
|
||||
data.equipmentReturn === "with_return"
|
||||
? "WITH_RETURN"
|
||||
: "WITHOUT_RETURN",
|
||||
}
|
||||
: {}),
|
||||
isHazardous: data.isHazardous,
|
||||
// Reefer is a contract-level flag for both container and bulk.
|
||||
@@ -580,7 +624,8 @@ export default function NewContractPage({
|
||||
? { customsClearingEnabled: true }
|
||||
: {
|
||||
customsClearingEnabled: false,
|
||||
customsClearingAgent: data.customsClearingAgent?.trim() || undefined,
|
||||
customsClearingAgent:
|
||||
data.customsClearingAgent?.trim() || undefined,
|
||||
}),
|
||||
cargoScope,
|
||||
routes,
|
||||
@@ -695,10 +740,7 @@ export default function NewContractPage({
|
||||
<Text size="sm" fw={600}>
|
||||
What the reviewer asked for:
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{editContract.latestChangeRequestNote}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
@@ -1166,6 +1208,8 @@ export default function NewContractPage({
|
||||
? profileByType.get(pendingApprovalProfile)
|
||||
: undefined;
|
||||
const isRejected = target?.status === "rejected";
|
||||
const isSuspended =
|
||||
target?.status === "suspended" || target?.status === "blacklisted";
|
||||
const label = pendingApprovalProfile
|
||||
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
||||
pendingApprovalProfile)
|
||||
@@ -1174,12 +1218,42 @@ export default function NewContractPage({
|
||||
<Modal
|
||||
opened={pendingApprovalProfile !== null}
|
||||
onClose={() => setPendingApprovalProfile(null)}
|
||||
title={isRejected ? "Profile not approved" : "Awaiting approval"}
|
||||
title={
|
||||
isRejected
|
||||
? "Profile not approved"
|
||||
: isSuspended
|
||||
? "Role suspended"
|
||||
: "Awaiting approval"
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{isRejected ? (
|
||||
{isSuspended ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Your {label} role has been suspended by EDR staff, so you
|
||||
can't start a contract under it. Your other roles are
|
||||
unaffected. Contact EDR support to resolve this.
|
||||
</Text>
|
||||
{target?.reviewNote && (
|
||||
<Alert color="orange" variant="light" radius="md">
|
||||
<Text size="sm">
|
||||
<strong>Message from EDR staff:</strong>{" "}
|
||||
{target.reviewNote}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={() => setPendingApprovalProfile(null)}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : isRejected ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Your {label} profile was not approved. Fix the issue below
|
||||
@@ -1215,7 +1289,8 @@ export default function NewContractPage({
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Your {label} profile was submitted and is under staff
|
||||
review. You can start a contract under it once it's approved.
|
||||
review. You can start a contract under it once it's
|
||||
approved.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ContractFormInputValues,
|
||||
type ContractFormValues,
|
||||
} from "./schema";
|
||||
import { operationToProfileType } from "./helpers";
|
||||
import { StepCard, StepHeader } from "./shared";
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
@@ -73,8 +74,16 @@ export function StepDocuments({
|
||||
|
||||
const onboardingDocs = (() => {
|
||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||
const operationType = form.watch("operationType");
|
||||
const targetType = operationType
|
||||
? operationToProfileType(
|
||||
operationType,
|
||||
profiles.map((p) => p.type),
|
||||
)
|
||||
: null;
|
||||
const active =
|
||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
||||
(targetType ? profiles.find((p) => p.type === targetType) : undefined) ??
|
||||
profiles[0];
|
||||
return active?.licenseFiles ?? [];
|
||||
})();
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export function Step1ContractType({
|
||||
/** Approval state of the profile each operation maps to (for the badges). */
|
||||
operationStatus?: (
|
||||
op: OperationType,
|
||||
) => "approved" | "pending" | "rejected" | "missing";
|
||||
) => "approved" | "pending" | "rejected" | "suspended" | "missing";
|
||||
}) {
|
||||
const contractType = form.watch("contractType");
|
||||
|
||||
@@ -229,6 +229,11 @@ export function Step1ContractType({
|
||||
Rejected
|
||||
</Badge>
|
||||
)}
|
||||
{status === "suspended" && (
|
||||
<Badge size="xs" color="orange" variant="light" radius="sm">
|
||||
Suspended
|
||||
</Badge>
|
||||
)}
|
||||
{status === "missing" && (
|
||||
<Badge size="xs" color="gray" variant="light" radius="sm">
|
||||
Add license
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import { Building2, CheckCircle2, RefreshCw, Save, XCircle } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -87,6 +87,22 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// Resubmit a rejected role for review. Flips it back to Pending server-side
|
||||
// and pings the backoffice, so the fix-and-resubmit loop can happen entirely
|
||||
// from settings instead of only from the contract page's rejection banner.
|
||||
const reapplyMutation = useMutation({
|
||||
mutationFn: (profileId: string) =>
|
||||
api.companies.reapplyProfile.call({ profileId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (selected.size === 0) return;
|
||||
mutation.mutate(Array.from(selected));
|
||||
@@ -113,6 +129,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
{options.map((opt) => {
|
||||
const existing = profileByType.get(opt.type);
|
||||
const view = existing ? roleStatusView(existing) : undefined;
|
||||
const rejected = existing?.status === "rejected";
|
||||
return (
|
||||
<RoleCard
|
||||
key={opt.type}
|
||||
@@ -124,6 +141,28 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
approved={view?.approved}
|
||||
lockedNote={view?.note}
|
||||
lockedNoteColor={view?.color}
|
||||
detail={
|
||||
(rejected || existing?.status === "suspended") &&
|
||||
existing?.reviewNote
|
||||
? `Reviewer note: ${existing.reviewNote}`
|
||||
: undefined
|
||||
}
|
||||
action={
|
||||
rejected ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={
|
||||
reapplyMutation.isPending &&
|
||||
reapplyMutation.variables === existing.id
|
||||
}
|
||||
onClick={() => reapplyMutation.mutate(existing.id)}
|
||||
>
|
||||
Resubmit for approval
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => toggle(opt.type)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,14 @@ export interface RoleCardProps {
|
||||
lockedNote?: string;
|
||||
/** Mantine color for {@link lockedNote}; matches the role's status. */
|
||||
lockedNoteColor?: string;
|
||||
/** Extra muted line under {@link lockedNote}, e.g. the reviewer's note. */
|
||||
detail?: string;
|
||||
/**
|
||||
* Interactive content (e.g. a resubmit button) rendered inside the card.
|
||||
* Only honoured on a locked card — the interactive variant is itself a
|
||||
* button, and buttons cannot nest.
|
||||
*/
|
||||
action?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -35,54 +43,70 @@ export default function RoleCard({
|
||||
approved = false,
|
||||
lockedNote,
|
||||
lockedNoteColor = "edr-green",
|
||||
detail,
|
||||
action,
|
||||
onClick,
|
||||
}: RoleCardProps) {
|
||||
const highlighted = selected || approved;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
onClick={locked ? undefined : onClick}
|
||||
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
||||
highlighted
|
||||
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
||||
: "border-edr-border! bg-edr-card!"
|
||||
} ${
|
||||
locked
|
||||
? "cursor-default"
|
||||
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
||||
}`}
|
||||
>
|
||||
<Group gap="md" wrap="nowrap" align="start">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant={highlighted ? "filled" : "light"}
|
||||
color="edr-green"
|
||||
className="shrink-0"
|
||||
>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Box className="min-w-0 flex-1">
|
||||
<Text fw={700} c="edr-text" fz={15}>
|
||||
{label}
|
||||
const className = `group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
||||
highlighted
|
||||
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
||||
: "border-edr-border! bg-edr-card!"
|
||||
} ${
|
||||
locked
|
||||
? "cursor-default"
|
||||
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
||||
}`;
|
||||
|
||||
const content = (
|
||||
<Group gap="md" wrap="nowrap" align="start">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant={highlighted ? "filled" : "light"}
|
||||
color="edr-green"
|
||||
className="shrink-0"
|
||||
>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Box className="min-w-0 flex-1">
|
||||
<Text fw={700} c="edr-text" fz={15}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
|
||||
{description}
|
||||
</Text>
|
||||
{lockedNote && (
|
||||
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
|
||||
{lockedNote}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
|
||||
{description}
|
||||
</Text>
|
||||
{lockedNote && (
|
||||
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
|
||||
{lockedNote}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{highlighted && (
|
||||
<Check
|
||||
size={18}
|
||||
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{locked && detail && (
|
||||
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
|
||||
{detail}
|
||||
</Text>
|
||||
)}
|
||||
{locked && action && <Box mt="sm">{action}</Box>}
|
||||
</Box>
|
||||
{highlighted && (
|
||||
<Check
|
||||
size={18}
|
||||
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
// A locked card is display-only, so it renders as a plain box — which also
|
||||
// lets `action` hold real buttons without nesting them inside a button.
|
||||
if (locked) {
|
||||
return <Box className={className}>{content}</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledButton type="button" onClick={onClick} className={className}>
|
||||
{content}
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,12 +240,6 @@ export const api = {
|
||||
CompanyInfoResponse
|
||||
>("companies", "startOnboarding", companiesService.startOnboarding),
|
||||
|
||||
setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>(
|
||||
"companies",
|
||||
"setActiveMode",
|
||||
companiesService.setActiveMode,
|
||||
),
|
||||
|
||||
setOnboardingStep: endpoint<{ step: string }, void>(
|
||||
"companies",
|
||||
"setOnboardingStep",
|
||||
|
||||
@@ -45,10 +45,6 @@ export interface ExternalProfileResponse {
|
||||
nationalId: string | null;
|
||||
jobTitle: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
/** The active operational mode (importer/exporter/forwarder). */
|
||||
activeProfileType: ProfileTypeValue | null;
|
||||
/** Id of the company_profile matching activeProfileType (server-resolved). */
|
||||
activeCompanyProfileId: string | null;
|
||||
onboardingStep: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
createdAt: string;
|
||||
@@ -323,17 +319,6 @@ export const companiesService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Switch the active operational mode (target profile must already exist). */
|
||||
setActiveMode: async (payload: {
|
||||
type: ProfileTypeValue;
|
||||
}): Promise<CompanyInfoResponse> => {
|
||||
const response = await client.patch<ApiResponse<CompanyInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
|
||||
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
|
||||
},
|
||||
|
||||
@@ -49,6 +49,12 @@ export const warehouseService = {
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Customer-safe location endpoint — the plain list is staff-only (403 for portal users). */
|
||||
bookingLocation: async (bookingId: string): Promise<WarehouseInventoryItem[]> => {
|
||||
const { data } = await client.get(`/warehouse-inventory/bookings/${bookingId}/location`);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
bookingSchedule: async (bookingId: string): Promise<BookingScheduleView> => {
|
||||
const { data } = await client.get(`/warehouse-inventory/booking-schedule/${bookingId}`);
|
||||
return data?.data ?? data ?? { schedule: null, wagon: null };
|
||||
|
||||
@@ -9,9 +9,10 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { TicketsService } from '../tickets/tickets.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
@@ -101,6 +102,7 @@ export class BookingsService {
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly seatsService: SeatsService,
|
||||
private readonly ticketsService: TicketsService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly verifaydaService: VerifaydaService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
@@ -1903,6 +1905,34 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-heal: if booking is CONFIRMED, payment SUCCEEDED, but tickets are missing
|
||||
// (ticket generation failed silently after payment — see finalizePaymentSuccess in
|
||||
// payments.service.ts), attempt to generate them now so the confirmation page
|
||||
// doesn't show "Not yet issued".
|
||||
if (
|
||||
booking.status === 'CONFIRMED' &&
|
||||
(booking as any).tickets?.length === 0 &&
|
||||
(booking as any).paymentIntent?.status === 'SUCCEEDED'
|
||||
) {
|
||||
try {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
// Re-fetch to include the newly created tickets
|
||||
const refreshed = await this.prisma.booking.findUnique({
|
||||
where: { id: booking.id },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
paymentIntent: true, tickets: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
});
|
||||
if (refreshed) Object.assign(booking, refreshed);
|
||||
} catch (err) {
|
||||
this.logger.warn(`getByRef: auto-generate tickets failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const outboundSegment = this.resolveSegmentStations(
|
||||
(booking as any).schedule,
|
||||
(booking as any).originStationId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from "@nestjs/common";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
@@ -834,9 +835,17 @@ export class PaymentsService {
|
||||
try {
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { alreadyFinalized: true };
|
||||
@@ -889,9 +898,25 @@ export class PaymentsService {
|
||||
try {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error generating ticket for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Only reassign seats when a *different* booking genuinely holds the seat
|
||||
// (ConflictException). Any other error (transient DB issue, etc.) is logged
|
||||
// and swallowed — the passenger keeps their original seat and the ticket can
|
||||
// be retried via "Generate Missing" in the backoffice.
|
||||
if (err instanceof ConflictException) {
|
||||
this.logger.warn(
|
||||
`Seat conflict for booking ${booking.id}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(booking.id);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1004,6 +1029,11 @@ export class PaymentsService {
|
||||
intentId: intent.id,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
}).catch((err) => {
|
||||
this.logger.error(
|
||||
`finalizePaymentSuccess failed for booking ${event.referenceId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return { alreadyFinalized: false };
|
||||
});
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ export class ReportsController {
|
||||
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
|
||||
}
|
||||
|
||||
@Get("seat-status")
|
||||
@ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" })
|
||||
getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getSeatStatusReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payments")
|
||||
@ApiOperation({ summary: "Payments collected for a schedule" })
|
||||
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
|
||||
|
||||
@@ -263,138 +263,85 @@ export class ReportsService {
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
select: {
|
||||
id: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
},
|
||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
select: { id: true, originStationId: true, destinationStationId: true },
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) return null;
|
||||
|
||||
const totalSeats = (schedule as any).coachAssignments.reduce(
|
||||
(s: number, a: any) => s + a.coach.seats.length,
|
||||
0,
|
||||
);
|
||||
const allBookingSeats = (schedule as any).bookings.flatMap(
|
||||
(b: any) => b.seats,
|
||||
);
|
||||
// Fetch booking seats for this schedule — covers:
|
||||
// • outbound seats (leg=1, scheduleId=scheduleId)
|
||||
// • return seats (leg=2, booking.returnScheduleId=scheduleId)
|
||||
// • legacy rows where scheduleId is null but booking.scheduleId matches
|
||||
const allBookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
bookingId: true,
|
||||
seat: { select: { coachId: true, coach: { select: { coachType: { select: { name: true } } } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const totalSeats = schedule.coachAssignments.reduce((s, a) => s + a.coach.seats.length, 0);
|
||||
const totalPassengers = allBookingSeats.length;
|
||||
const occupancyRate =
|
||||
totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
|
||||
const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
|
||||
|
||||
// Per-coach breakdown
|
||||
const coachMap = new Map<
|
||||
string,
|
||||
{
|
||||
coachNumber: string;
|
||||
coachType: string;
|
||||
totalSeats: number;
|
||||
booked: number;
|
||||
}
|
||||
>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const coachMap = new Map<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const c = assignment.coach;
|
||||
coachMap.set(c.id, {
|
||||
coachNumber: c.number,
|
||||
coachType: (c as any).coachType?.name ?? "Unknown",
|
||||
totalSeats: c.seats.length,
|
||||
booked: 0,
|
||||
});
|
||||
coachMap.set(c.id, { coachNumber: c.number, coachType: (c as any).coachType?.name ?? 'Unknown', totalSeats: c.seats.length, booked: 0 });
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const coachId = bs.seat?.coachId;
|
||||
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
|
||||
}
|
||||
const byCoach = [...coachMap.values()].map((c) => ({
|
||||
...c,
|
||||
occupancyRate:
|
||||
c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
// Per-origin station breakdown (using booking's originStationId)
|
||||
const originMap = new Map<
|
||||
string,
|
||||
{ stationName: string; passengers: number }
|
||||
>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId = booking.originStationId ?? schedule.originStationId;
|
||||
const stationName =
|
||||
(schedule as any).stopTimes.find(
|
||||
(st: any) => st.stationId === stationId,
|
||||
)?.station?.name ??
|
||||
(schedule as any).originStation?.name ??
|
||||
stationId;
|
||||
if (!originMap.has(stationId))
|
||||
originMap.set(stationId, { stationName, passengers: 0 });
|
||||
originMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byOrigin = [...originMap.values()].sort(
|
||||
(a, b) => b.passengers - a.passengers,
|
||||
);
|
||||
|
||||
// Per-destination station breakdown
|
||||
const destMap = new Map<
|
||||
string,
|
||||
{ stationName: string; passengers: number }
|
||||
>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId =
|
||||
booking.destinationStationId ?? schedule.destinationStationId;
|
||||
const stationName =
|
||||
(schedule as any).stopTimes.find(
|
||||
(st: any) => st.stationId === stationId,
|
||||
)?.station?.name ??
|
||||
(schedule as any).destinationStation?.name ??
|
||||
stationId;
|
||||
if (!destMap.has(stationId))
|
||||
destMap.set(stationId, { stationName, passengers: 0 });
|
||||
destMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byDestination = [...destMap.values()].sort(
|
||||
(a, b) => b.passengers - a.passengers,
|
||||
);
|
||||
const byCoach = [...coachMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 }));
|
||||
|
||||
// Per-class breakdown
|
||||
const classMap = new Map<
|
||||
string,
|
||||
{ className: string; totalSeats: number; booked: number }
|
||||
>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const typeName = (assignment.coach as any).coachType?.name ?? "Unknown";
|
||||
if (!classMap.has(typeName))
|
||||
classMap.set(typeName, {
|
||||
className: typeName,
|
||||
totalSeats: 0,
|
||||
booked: 0,
|
||||
});
|
||||
const classMap = new Map<string, { className: string; totalSeats: number; booked: number }>();
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
classMap.get(typeName)!.totalSeats += assignment.coach.seats.length;
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const typeName = bs.seat?.coach?.coachType?.name ?? "Unknown";
|
||||
if (!classMap.has(typeName))
|
||||
classMap.set(typeName, {
|
||||
className: typeName,
|
||||
totalSeats: 0,
|
||||
booked: 0,
|
||||
});
|
||||
const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
classMap.get(typeName)!.booked++;
|
||||
}
|
||||
const byClass = [...classMap.values()].map((c) => ({
|
||||
...c,
|
||||
occupancyRate:
|
||||
c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
const byClass = [...classMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 }));
|
||||
|
||||
// Per-origin / per-destination — count actual seats per booking from allBookingSeats
|
||||
const seatCountByBooking = allBookingSeats.reduce((acc, bs) => { acc[bs.bookingId] = (acc[bs.bookingId] ?? 0) + 1; return acc; }, {} as Record<string, number>);
|
||||
const originMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
const destMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
for (const booking of schedule.bookings) {
|
||||
const count = seatCountByBooking[booking.id] ?? 0;
|
||||
const oId = booking.originStationId ?? schedule.originStationId;
|
||||
const dId = booking.destinationStationId ?? schedule.destinationStationId;
|
||||
const oName = schedule.stopTimes.find(st => st.stationId === oId)?.station?.name ?? (schedule as any).originStation?.name ?? oId;
|
||||
const dName = schedule.stopTimes.find(st => st.stationId === dId)?.station?.name ?? (schedule as any).destinationStation?.name ?? dId;
|
||||
if (!originMap.has(oId)) originMap.set(oId, { stationName: oName, passengers: 0 });
|
||||
originMap.get(oId)!.passengers += count;
|
||||
if (!destMap.has(dId)) destMap.set(dId, { stationName: dName, passengers: 0 });
|
||||
destMap.get(dId)!.passengers += count;
|
||||
}
|
||||
const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
const byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
|
||||
return {
|
||||
schedule: {
|
||||
id: schedule.id,
|
||||
trainName:
|
||||
(schedule as any).train?.name ?? (schedule as any).train?.number,
|
||||
trainName: (schedule as any).train?.name ?? (schedule as any).train?.number,
|
||||
origin: (schedule as any).originStation?.name,
|
||||
destination: (schedule as any).destinationStation?.name,
|
||||
departureAt: schedule.departureAt,
|
||||
@@ -436,9 +383,11 @@ export class ReportsService {
|
||||
async getPassengerList(scheduleId: string) {
|
||||
const seats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
leg: 1,
|
||||
booking: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
OR: [
|
||||
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
booking: {
|
||||
@@ -502,12 +451,14 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
async getSeatStatusReport(scheduleId: string) {
|
||||
// Booked seats — exclude dining coaches
|
||||
// Confirmed/boarded seats — exclude dining coaches
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
leg: 1,
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } },
|
||||
OR: [
|
||||
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||
],
|
||||
seat: { coach: { coachType: { type: { not: 'dining' } } } },
|
||||
},
|
||||
include: {
|
||||
@@ -537,11 +488,34 @@ export class ReportsService {
|
||||
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
|
||||
});
|
||||
|
||||
// Manually blocked seats for this schedule — exclude MAINTENANCE entries
|
||||
const blocks = await this.prisma.seatBlock.findMany({
|
||||
// Active seat holds for this schedule
|
||||
const activeHolds = await this.prisma.seatHold.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Expired holds (last 24h) — held but never converted to a booking
|
||||
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const expiredHolds = await this.prisma.seatHold.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
|
||||
expiresAt: { lt: new Date(), gte: since24h },
|
||||
},
|
||||
orderBy: { expiresAt: 'desc' },
|
||||
});
|
||||
|
||||
// Manually blocked seats — schedule-scoped blocks for this schedule OR global blocks (scheduleId null)
|
||||
// Exclude MAINTENANCE and booking-system-created blocks
|
||||
const blocks = await this.prisma.seatBlock.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId },
|
||||
{ scheduleId: null },
|
||||
],
|
||||
NOT: [
|
||||
{ reason: { startsWith: 'MAINTENANCE:' } },
|
||||
{ reason: { startsWith: 'Booked in tickets' } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
seat: {
|
||||
@@ -568,19 +542,41 @@ export class ReportsService {
|
||||
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null;
|
||||
};
|
||||
|
||||
const paidSeats = bookingSeats.filter(bs =>
|
||||
bs.booking.status === 'CONFIRMED' || bs.booking.status === 'BOARDED'
|
||||
);
|
||||
const unpaidSeats = bookingSeats.filter(bs =>
|
||||
bs.booking.status === 'PENDING_PAYMENT'
|
||||
);
|
||||
|
||||
const mapSeat = (bs: any) => ({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
passengerName: bs.passengerName,
|
||||
passengerCategory: bs.passengerCategory,
|
||||
coachNumber: bs.seat?.coach?.number ?? null,
|
||||
seatNumber: bs.seat?.seatNumber ?? null,
|
||||
seatClassName: resolveSeatClass(bs.seat),
|
||||
fareMinor: bs.fareMinor,
|
||||
currency: bs.booking.currency ?? 'ETB',
|
||||
bookingStatus: bs.booking.status,
|
||||
paymentStatus: bs.booking.paymentIntent?.status ?? 'PENDING',
|
||||
bookedAt: bs.booking.createdAt,
|
||||
});
|
||||
|
||||
return {
|
||||
bookedSeats: bookingSeats.map(bs => ({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
passengerName: bs.passengerName,
|
||||
passengerCategory: bs.passengerCategory,
|
||||
coachNumber: bs.seat?.coach?.number ?? null,
|
||||
seatNumber: bs.seat?.seatNumber ?? null,
|
||||
seatClassName: resolveSeatClass(bs.seat),
|
||||
fareMinor: bs.fareMinor,
|
||||
currency: bs.booking.currency ?? 'ETB',
|
||||
bookingStatus: bs.booking.status,
|
||||
paymentStatus: bs.booking.paymentIntent?.status ?? 'PENDING',
|
||||
bookedAt: bs.booking.createdAt,
|
||||
summary: {
|
||||
paidCount: paidSeats.length,
|
||||
unpaidCount: unpaidSeats.length,
|
||||
expiredHoldCount: expiredHolds.length,
|
||||
blockedCount: blocks.filter(b => b.seat?.coach?.coachType?.type !== 'dining').length,
|
||||
},
|
||||
paidSeats: paidSeats.map(mapSeat),
|
||||
unpaidSeats: unpaidSeats.map(mapSeat),
|
||||
expiredHolds: expiredHolds.map(h => ({
|
||||
holdId: h.id,
|
||||
seatIds: h.seatIds,
|
||||
expiresAt: h.expiresAt,
|
||||
createdAt: h.createdAt,
|
||||
})),
|
||||
blockedSeats: blocks
|
||||
.filter(b => b.seat?.coach?.coachType?.type !== 'dining')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@@ -10,6 +9,17 @@ import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
export class TicketsController {
|
||||
constructor(private service: TicketsService) {}
|
||||
|
||||
@Post('generate-missing')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Generate tickets for all confirmed bookings that are missing them',
|
||||
description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.',
|
||||
})
|
||||
generateMissing() {
|
||||
return this.service.generateMissing();
|
||||
}
|
||||
|
||||
@Post('smart-assign/:bookingId')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@@ -24,11 +34,10 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('generate/:bookingId')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Generate ticket for booking (confirmation page)',
|
||||
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'
|
||||
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records. Requires payment to be SUCCEEDED and booking to be CONFIRMED.'
|
||||
})
|
||||
generateTicket(@Param('bookingId') bookingId: string) {
|
||||
return this.service.generate(bookingId);
|
||||
@@ -45,8 +54,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@@ -88,8 +97,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get('by-order/:merchantOrderId')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket by merchant order ID',
|
||||
description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.'
|
||||
@@ -106,8 +115,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('scan-board/:qrCodeOrRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
||||
description: 'Scans ticket QR code or booking reference and automatically boards the passenger. Handles errors like expired tickets, already used tickets, etc. Designed for mobile boarding interface.'
|
||||
@@ -131,8 +140,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
|
||||
@@ -162,24 +171,24 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Get validation logs for ticket' })
|
||||
getValidationLogs(@Param('ticketId') ticketId: string) {
|
||||
return this.service.getValidationLogs(ticketId);
|
||||
}
|
||||
|
||||
@Get('offline/export')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Export tickets for offline validation' })
|
||||
exportOfflineData(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.exportOfflineData(scheduleId);
|
||||
}
|
||||
|
||||
@Post('validate/offline')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Batch import offline validations',
|
||||
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
|
||||
@@ -221,8 +230,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Patch(':id/restore')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
||||
restore(@Param('id') id: string) {
|
||||
return this.service.restore(id);
|
||||
|
||||
@@ -352,8 +352,20 @@ export class TicketsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for seat conflicts before deleting existing tickets or issuing new ones
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Remove any SeatBlock rows left over from a previous generate() run for this
|
||||
// booking — they reference the old ticket IDs which are now deleted, and would
|
||||
// otherwise cause the conflict check below to see this booking's own seats as
|
||||
// blocked by another booking.
|
||||
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
|
||||
});
|
||||
|
||||
// Check for seat conflicts — only seats confirmed/boarded by a *different* booking
|
||||
// on the same schedule are a real conflict. SeatBlock rows created by a previous
|
||||
// generate() run for this booking are NOT a conflict; they are cleaned up above.
|
||||
const conflictingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
seatId: { in: seatIds },
|
||||
@@ -371,35 +383,37 @@ export class TicketsService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Generate one ticket per unique passenger (grouped by passengerName)
|
||||
// Generate one ticket per passenger per leg.
|
||||
// Round-trip / transit bookings have seats on multiple legs — each leg needs its own
|
||||
// ticket so the voucher can match by (passengerName, leg) and gate scanners can
|
||||
// validate each leg independently.
|
||||
const tickets = [];
|
||||
|
||||
// Group seats by passenger
|
||||
const passengerSeatsMap = new Map<string, any[]>();
|
||||
|
||||
// Group seats by (passengerName, leg)
|
||||
const passengerLegSeatsMap = new Map<string, any[]>();
|
||||
for (const bookingSeat of (booking as any).seats) {
|
||||
const key = bookingSeat.passengerName;
|
||||
if (!passengerSeatsMap.has(key)) {
|
||||
passengerSeatsMap.set(key, []);
|
||||
const key = `${bookingSeat.passengerName}|${bookingSeat.leg ?? 1}`;
|
||||
if (!passengerLegSeatsMap.has(key)) {
|
||||
passengerLegSeatsMap.set(key, []);
|
||||
}
|
||||
passengerSeatsMap.get(key)!.push(bookingSeat);
|
||||
passengerLegSeatsMap.get(key)!.push(bookingSeat);
|
||||
}
|
||||
|
||||
// Create one ticket per passenger
|
||||
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
|
||||
// Use first seat for primary data
|
||||
const primarySeat = passengerSeats[0];
|
||||
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||
// Create one ticket per (passenger, leg)
|
||||
for (const [key, legSeats] of passengerLegSeatsMap.entries()) {
|
||||
const [passengerName] = key.split('|');
|
||||
const primarySeat = legSeats[0];
|
||||
const leg = primarySeat.leg ?? 1;
|
||||
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}L${leg}`;
|
||||
|
||||
// Re-encode QR with ticketNumber included
|
||||
const qrDataWithTicket = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
ticketNumber: barcodePayload,
|
||||
type: booking.bookingType,
|
||||
passenger: passengerName,
|
||||
seats: passengerSeats.map(ps => ({
|
||||
leg,
|
||||
seats: legSeats.map(ps => ({
|
||||
seat: ps.seat?.seatNumber,
|
||||
coach: ps.seat?.coach?.number,
|
||||
leg: ps.leg || 1,
|
||||
@@ -414,7 +428,7 @@ export class TicketsService {
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName,
|
||||
seatId: primarySeat.seatId,
|
||||
leg: primarySeat.leg || 1,
|
||||
leg,
|
||||
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
||||
qrPayload: qrPayloadFinal,
|
||||
barcodePayload,
|
||||
@@ -826,6 +840,34 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> {
|
||||
const confirmedWithNoTickets = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'CONFIRMED',
|
||||
tickets: { none: {} },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
},
|
||||
select: { id: true, bookingRef: true },
|
||||
});
|
||||
|
||||
const details: any[] = [];
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const booking of confirmedWithNoTickets) {
|
||||
try {
|
||||
await this.generate(booking.id);
|
||||
generated++;
|
||||
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' });
|
||||
} catch (err) {
|
||||
failed++;
|
||||
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'failed', error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return { processed: confirmedWithNoTickets.length, generated, failed, details };
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
@@ -9,6 +9,8 @@ import Badge from '@/components/ui/Badge';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { agentsApi, apiClient } from '@/lib/api';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
@@ -90,6 +92,9 @@ export default function AgentsPage() {
|
||||
queryFn: () => agentsApi.getAll(filters),
|
||||
});
|
||||
|
||||
const allAgents = data?.items || [];
|
||||
const { paged: pagedAgents, page, totalPages, setPage } = usePagination(allAgents, 20);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'agentCode',
|
||||
@@ -182,12 +187,13 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
data={pagedAgents}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No agents found"
|
||||
/>
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
|
||||
@@ -81,15 +81,6 @@ function BookingsPageContent() {
|
||||
const forceConfirmMutation = useMutation({
|
||||
mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) =>
|
||||
bookingsApi.forceConfirm(bookingId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bookings'] });
|
||||
setSuccessMessage('Payment confirmed and ticket generated successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 4000);
|
||||
setSelectedBooking(null);
|
||||
setGenerateTicketBooking(null);
|
||||
setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' });
|
||||
setGenerateTicketTouched({ paymentReference: false, paymentMethod: false });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -480,10 +471,6 @@ function BookingsPageContent() {
|
||||
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
|
||||
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
|
||||
</div>
|
||||
<Field label="Method" value={b.paymentIntent?.method || '—'} />
|
||||
<Field label="Paid At" value={b.paidAt ? formatDateTime(b.paidAt) : 'Not paid'} />
|
||||
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
|
||||
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -653,7 +640,13 @@ function BookingsPageContent() {
|
||||
setGenerateTicketTouched({ paymentReference: true, paymentMethod: true });
|
||||
if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return;
|
||||
forceConfirmMutation.reset();
|
||||
smartAssignMutation.mutate(generateTicketBooking.id);
|
||||
smartAssignMutation.reset();
|
||||
forceConfirmMutation.mutate(
|
||||
{ bookingId: generateTicketBooking.id, data: { paymentReference: generateTicketForm.paymentReference, paymentMethod: generateTicketForm.paymentMethod, notes: generateTicketForm.notes } },
|
||||
{
|
||||
onSuccess: () => smartAssignMutation.mutate(generateTicketBooking.id),
|
||||
},
|
||||
);
|
||||
}}
|
||||
disabled={forceConfirmMutation.isPending || smartAssignMutation.isPending}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,8 @@ import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { fleetApi, apiClient } from '@/lib/api';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
|
||||
type Tab = 'types' | 'coaches' | 'utilization';
|
||||
|
||||
@@ -315,6 +317,9 @@ export default function CoachesPage() {
|
||||
);
|
||||
});
|
||||
|
||||
const { paged: pagedCoachTypes, page: typesPage, totalPages: typesTotalPages, setPage: setTypesPage } = usePagination(filteredCoachTypes as any[], 20);
|
||||
const { paged: pagedCoaches, page: coachesPage, totalPages: coachesTotalPages, setPage: setCoachesPage } = usePagination(filteredCoaches as any[], 20);
|
||||
|
||||
const typeColorMap: Record<string, string> = {
|
||||
passenger: 'edr-badge-info',
|
||||
sleeper: 'edr-badge-warning',
|
||||
@@ -551,11 +556,12 @@ export default function CoachesPage() {
|
||||
</div>
|
||||
<DataTable
|
||||
columns={coachTypeColumns}
|
||||
data={filteredCoachTypes}
|
||||
data={pagedCoachTypes}
|
||||
actions={coachTypeActions}
|
||||
loading={typesLoading}
|
||||
emptyMessage={search ? "No coach types match your search" : "No coach types found. Create one to get started."}
|
||||
/>
|
||||
<Pagination currentPage={typesPage} totalPages={typesTotalPages} onPageChange={setTypesPage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -574,11 +580,12 @@ export default function CoachesPage() {
|
||||
</div>
|
||||
<DataTable
|
||||
columns={coachColumns}
|
||||
data={filteredCoaches}
|
||||
data={pagedCoaches}
|
||||
actions={coachActions}
|
||||
loading={coachesLoading}
|
||||
emptyMessage={search ? "No coaches match your search" : "No coaches found. Create one to get started."}
|
||||
/>
|
||||
<Pagination currentPage={coachesPage} totalPages={coachesTotalPages} onPageChange={setCoachesPage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { paymentsApi, apiClient } from '@/lib/api';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import SupplementaryChargesModal from './SupplementaryChargesModal';
|
||||
import {
|
||||
@@ -109,6 +111,9 @@ export default function PaymentsPage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const allPayments = (data as any)?.items || (Array.isArray(data) ? data : []);
|
||||
const { paged: pagedPayments, page: paymentsPage, totalPages: paymentsTotalPages, setPage: setPaymentsPage } = usePagination(allPayments, 20);
|
||||
|
||||
const PAYMENT_COLS = [
|
||||
{ key: 'reference', label: 'Reference' },
|
||||
{ key: 'booking', label: 'Booking Reference' },
|
||||
@@ -311,12 +316,13 @@ export default function PaymentsPage() {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
|
||||
data={pagedPayments}
|
||||
columns={columns}
|
||||
actions={paymentActions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No payments found"
|
||||
/>
|
||||
<Pagination currentPage={paymentsPage} totalPages={paymentsTotalPages} onPageChange={setPaymentsPage} />
|
||||
|
||||
{/* Payment Details Modal */}
|
||||
<Modal isOpen={!!selectedPayment} onClose={() => setSelectedPayment(null)} title="Payment Details" size="xl">
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Users, Armchair, BarChart3, Train, Download } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
import { usePagination } from "@/lib/use-pagination";
|
||||
|
||||
interface ScheduleOption {
|
||||
id: string;
|
||||
@@ -127,6 +129,8 @@ export default function PassengersReportPage() {
|
||||
})
|
||||
.sort((a, b) => a.bookingRef.localeCompare(b.bookingRef));
|
||||
|
||||
const { paged: pagedList, page: listPage, totalPages: listTotalPages, setPage: setListPage, reset: resetListPage } = usePagination(filteredList, 50);
|
||||
|
||||
const downloadCsv = (csv: string, filename: string) => {
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -457,12 +461,12 @@ export default function PassengersReportPage() {
|
||||
className="input max-w-sm flex-1"
|
||||
placeholder="Search by name or booking ref…"
|
||||
value={listSearch}
|
||||
onChange={(e) => setListSearch(e.target.value)}
|
||||
onChange={(e) => { setListSearch(e.target.value); resetListPage(); }}
|
||||
/>
|
||||
<select
|
||||
className="input w-40"
|
||||
value={filterSeatClass}
|
||||
onChange={(e) => setFilterSeatClass(e.target.value)}
|
||||
onChange={(e) => { setFilterSeatClass(e.target.value); resetListPage(); }}
|
||||
>
|
||||
<option value="">All classes</option>
|
||||
{seatClassOptions.map((c) => (
|
||||
@@ -474,7 +478,7 @@ export default function PassengersReportPage() {
|
||||
<select
|
||||
className="input w-36"
|
||||
value={filterCoachNumber}
|
||||
onChange={(e) => setFilterCoachNumber(e.target.value)}
|
||||
onChange={(e) => { setFilterCoachNumber(e.target.value); resetListPage(); }}
|
||||
>
|
||||
<option value="">All coaches</option>
|
||||
{coachNumberOptions.map((c) => (
|
||||
@@ -486,7 +490,7 @@ export default function PassengersReportPage() {
|
||||
<select
|
||||
className="input w-36"
|
||||
value={filterOrigin}
|
||||
onChange={(e) => setFilterOrigin(e.target.value)}
|
||||
onChange={(e) => { setFilterOrigin(e.target.value); resetListPage(); }}
|
||||
>
|
||||
<option value="">All origins</option>
|
||||
{originOptions.map((o) => (
|
||||
@@ -518,7 +522,7 @@ export default function PassengersReportPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredList.map((p, i) => (
|
||||
{pagedList.map((p, i) => (
|
||||
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
|
||||
<td className="py-2 pr-4 font-medium whitespace-nowrap">{p.passengerName}</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
@@ -549,6 +553,7 @@ export default function PassengersReportPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination currentPage={listPage} totalPages={listTotalPages} onPageChange={setListPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import DatePicker from '@/components/ui/DatePicker';
|
||||
import { parse, isValid } from 'date-fns';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -158,6 +160,8 @@ export default function PaymentDiscrepancyPage() {
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
|
||||
const { paged: pagedRows, page: discPage, totalPages: discTotalPages, setPage: setDiscPage } = usePagination(rows.filter(r => r.balanceMinor > 0), 20);
|
||||
|
||||
const parsedFrom = from ? parse(from, 'yyyy-MM-dd', new Date()) : undefined;
|
||||
const fromDate = parsedFrom && isValid(parsedFrom) ? parsedFrom : undefined;
|
||||
|
||||
@@ -316,7 +320,7 @@ export default function PaymentDiscrepancyPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{rows.filter(row => row.balanceMinor > 0).map((row, i) => {
|
||||
{pagedRows.map((row, i) => {
|
||||
const isExpanded = expandedPnr === row.pnr;
|
||||
const hasMultiple = row.passengerCount > 1;
|
||||
return (
|
||||
@@ -447,8 +451,11 @@ export default function PaymentDiscrepancyPage() {
|
||||
</table>
|
||||
</div>
|
||||
{!isSearchMode && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-400 dark:text-gray-500">
|
||||
{rows.filter(r => r.balanceMinor > 0).length} record{rows.filter(r => r.balanceMinor > 0).length !== 1 ? 's' : ''} — click a phone number to call directly, or export CSV for bulk follow-up
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{rows.filter(r => r.balanceMinor > 0).length} record{rows.filter(r => r.balanceMinor > 0).length !== 1 ? 's' : ''} — click a phone number to call directly, or export CSV for bulk follow-up
|
||||
</span>
|
||||
<Pagination currentPage={discPage} totalPages={discTotalPages} onPageChange={setDiscPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,161 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Download,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { bookingsApi, seatsApi } from "@/lib/api";
|
||||
import { CheckCircle, Clock, AlertCircle, Ban, Armchair, Download } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
import { formatDateTime, formatCurrency } from "@/lib/utils";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
import { usePagination } from "@/lib/use-pagination";
|
||||
|
||||
const HOLD_DURATION_MS = 15 * 60 * 1000;
|
||||
|
||||
function isExpired(releaseAt: string | null): boolean {
|
||||
if (!releaseAt) return false;
|
||||
return new Date(releaseAt) < new Date();
|
||||
interface ScheduleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface BookedSeatRow {
|
||||
interface SeatRow {
|
||||
bookingRef: string;
|
||||
passengerName: string;
|
||||
coachNumber: string;
|
||||
seatNumber: string;
|
||||
passengerCategory: string;
|
||||
coachNumber: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClassName: string | null;
|
||||
fareMinor: number;
|
||||
currency: string;
|
||||
bookingStatus: string;
|
||||
paymentStatus: string;
|
||||
bookedAt: string;
|
||||
releaseAt: string | null;
|
||||
scheduleOrigin: string;
|
||||
scheduleDestination: string;
|
||||
scheduleDeparture: string;
|
||||
}
|
||||
|
||||
function getReleaseAt(booking: any, seat: any): string | null {
|
||||
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
||||
if (paymentStatus === "SUCCEEDED" || paymentStatus === "COMPLETED") return null;
|
||||
if (booking.status === "CONFIRMED") return null;
|
||||
if (seat?.holdExpiresAt) return seat.holdExpiresAt;
|
||||
if (booking.createdAt) {
|
||||
return new Date(
|
||||
new Date(booking.createdAt).getTime() + HOLD_DURATION_MS,
|
||||
).toISOString();
|
||||
}
|
||||
return null;
|
||||
interface BlockedRow {
|
||||
id: string;
|
||||
coachNumber: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClassName: string | null;
|
||||
reason: string;
|
||||
blockedBy: string;
|
||||
blockedAt: string;
|
||||
unblockAt: string | null;
|
||||
}
|
||||
|
||||
interface SeatStatusReport {
|
||||
summary: {
|
||||
paidCount: number;
|
||||
unpaidCount: number;
|
||||
expiredHoldCount: number;
|
||||
blockedCount: number;
|
||||
};
|
||||
paidSeats: SeatRow[];
|
||||
unpaidSeats: SeatRow[];
|
||||
expiredHolds: { holdId: string; seatIds: string[]; expiresAt: string; createdAt: string }[];
|
||||
blockedSeats: BlockedRow[];
|
||||
}
|
||||
|
||||
type Tab = "seats" | "blocked";
|
||||
|
||||
export default function SeatStatusReportPage() {
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("seats");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: blockedSeats = [] } = useQuery({
|
||||
queryKey: ["blocked-seats"],
|
||||
queryFn: () =>
|
||||
seatsApi
|
||||
.getBlocked()
|
||||
.then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))),
|
||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
|
||||
queryKey: ["report-schedules-all"],
|
||||
queryFn: () => apiClient.get("/reports/schedules?all=true"),
|
||||
});
|
||||
const schedules = schedulesRaw ?? [];
|
||||
|
||||
const { data, isLoading, isError } = useQuery<SeatStatusReport>({
|
||||
queryKey: ["seat-status-report", scheduleId],
|
||||
queryFn: () => apiClient.get(`/reports/seat-status?scheduleId=${scheduleId}`),
|
||||
enabled: !!scheduleId,
|
||||
});
|
||||
|
||||
const { data: bookingsData, isLoading } = useQuery({
|
||||
queryKey: ["seat-report-bookings"],
|
||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||
});
|
||||
const allSeats: SeatRow[] = [
|
||||
...(data?.paidSeats ?? []),
|
||||
...(data?.unpaidSeats ?? []),
|
||||
];
|
||||
|
||||
const rows = useMemo<BookedSeatRow[]>(() => {
|
||||
const bookings: any[] = (bookingsData as any)?.data ?? (Array.isArray(bookingsData) ? bookingsData : []);
|
||||
const result: BookedSeatRow[] = [];
|
||||
for (const booking of bookings) {
|
||||
if (booking.status === "CANCELLED") continue;
|
||||
const seats: any[] = booking.seats || [];
|
||||
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
||||
for (const seat of seats) {
|
||||
result.push({
|
||||
bookingRef: booking.bookingRef || "—",
|
||||
passengerName:
|
||||
seat.passengerName ||
|
||||
seat.name ||
|
||||
booking.passengerNames?.[0] ||
|
||||
"—",
|
||||
seatNumber: seat.seat?.seatNumber || seat.seatNumber || "—",
|
||||
coachNumber: seat.seat?.coach?.number || seat.coach || "—",
|
||||
fareMinor: seat.fareMinor ?? 0,
|
||||
currency: booking.currency || "ETB",
|
||||
paymentStatus,
|
||||
bookingStatus: booking.status,
|
||||
bookedAt: booking.createdAt,
|
||||
releaseAt: getReleaseAt(booking, seat),
|
||||
scheduleOrigin: booking.schedule?.originStation?.name || "—",
|
||||
scheduleDestination: booking.schedule?.destinationStation?.name || "—",
|
||||
scheduleDeparture: booking.schedule?.departureAt || "",
|
||||
});
|
||||
}
|
||||
const filtered = allSeats.filter((r) => {
|
||||
const isPaid = r.bookingStatus === "CONFIRMED" || r.bookingStatus === "BOARDED";
|
||||
if (statusFilter === "PAID" && !isPaid) return false;
|
||||
if (statusFilter === "UNPAID" && isPaid) return false;
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
r.bookingRef.toLowerCase().includes(q) ||
|
||||
r.passengerName.toLowerCase().includes(q) ||
|
||||
(r.seatNumber ?? "").toLowerCase().includes(q) ||
|
||||
(r.coachNumber ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [bookingsData]);
|
||||
return true;
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return rows.filter((r) => {
|
||||
const isPaid =
|
||||
r.paymentStatus === "SUCCEEDED" || r.paymentStatus === "COMPLETED";
|
||||
if (statusFilter === "PAID" && !isPaid) return false;
|
||||
if (statusFilter === "UNPAID" && isPaid) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
r.bookingRef.toLowerCase().includes(q) ||
|
||||
r.passengerName.toLowerCase().includes(q) ||
|
||||
(r.seatNumber ?? "").toLowerCase().includes(q) ||
|
||||
(r.coachNumber ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [rows, statusFilter, search]);
|
||||
|
||||
const paidCount = rows.filter(
|
||||
(r) => r.paymentStatus === "SUCCEEDED" || r.paymentStatus === "COMPLETED",
|
||||
).length;
|
||||
const unpaidCount = rows.length - paidCount;
|
||||
const expiredCount = rows.filter((r) => isExpired(r.releaseAt)).length;
|
||||
const { paged: pagedSeats, page: seatsPage, totalPages: seatsTotalPages, setPage: setSeatsPage, reset: resetSeatsPage } = usePagination(filtered, 50);
|
||||
const { paged: pagedBlocked, page: blockedPage, totalPages: blockedTotalPages, setPage: setBlockedPage } = usePagination(data?.blockedSeats ?? [], 50);
|
||||
|
||||
const doExport = () => {
|
||||
if (!filtered.length) {
|
||||
alert("No data to export");
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
"Booking Ref", "Passenger", "Seat", "Coach", "Fare",
|
||||
"Payment Status", "Booking Status", "Booked At", "Release At",
|
||||
"Origin", "Destination", "Departure",
|
||||
];
|
||||
const csvRows = filtered.map((r) => [
|
||||
if (!filtered.length) return;
|
||||
const headers = ["Booking Ref", "Passenger", "Category", "Seat Class", "Coach", "Seat", "Fare", "Payment", "Booking Status", "Booked At"];
|
||||
const rows = filtered.map((r) => [
|
||||
r.bookingRef,
|
||||
r.passengerName,
|
||||
r.seatNumber,
|
||||
r.coachNumber,
|
||||
r.passengerCategory,
|
||||
r.seatClassName ?? "—",
|
||||
r.coachNumber ?? "—",
|
||||
r.seatNumber ?? "—",
|
||||
formatCurrency(r.fareMinor, r.currency),
|
||||
r.paymentStatus,
|
||||
r.bookingStatus,
|
||||
r.bookedAt ? formatDateTime(r.bookedAt) : "—",
|
||||
r.releaseAt ? formatDateTime(r.releaseAt) : "—",
|
||||
r.scheduleOrigin,
|
||||
r.scheduleDestination,
|
||||
r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : "—",
|
||||
]);
|
||||
const csv = [
|
||||
headers.map((h) => `"${h}"`).join(","),
|
||||
...csvRows.map((row) => row.map((v) => `"${v}"`).join(",")),
|
||||
...rows.map((row) => row.map((v) => `"${v}"`).join(",")),
|
||||
].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `seat-status-report-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.download = `seat-status-${scheduleId}-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -165,208 +130,258 @@ export default function SeatStatusReportPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Seat Status Report</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Track booked seats — paid vs unpaid, booking times, and hold release times
|
||||
Paid, unpaid, expired holds and blocked seats for a schedule
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Paid Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">{paidCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Payment confirmed</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Unpaid Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">{unpaidCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Awaiting payment</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Expired Holds</p>
|
||||
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">{expiredCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Hold time passed, not paid</p>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
||||
{(blockedSeats as any[]).length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Manually blocked</p>
|
||||
</div>
|
||||
</div>
|
||||
{(blockedSeats as any[]).length > 0 && (
|
||||
<div className="mt-3 border-t border-border pt-3 flex flex-col gap-1 max-h-32 overflow-y-auto">
|
||||
{(blockedSeats as any[]).map((b: any) => (
|
||||
<div key={b.id} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
Seat {b.seatNumber} · Coach {b.coachNumber}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate max-w-24" title={b.reason}>
|
||||
{b.reason}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{/* Schedule selector */}
|
||||
<div className="card">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1 min-w-48">
|
||||
<label className="label">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Booking ref, passenger, seat, coach..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Payment Status</label>
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-72">
|
||||
<label className="label">Schedule</label>
|
||||
<select
|
||||
className="input"
|
||||
value={statusFilter}
|
||||
onChange={(e) =>
|
||||
setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID")
|
||||
}
|
||||
value={scheduleId}
|
||||
onChange={(e) => {
|
||||
setScheduleId(e.target.value);
|
||||
setTab("seats");
|
||||
setStatusFilter("ALL");
|
||||
setSearch("");
|
||||
}}
|
||||
disabled={loadingSchedules}
|
||||
>
|
||||
<option value="ALL">All Seats</option>
|
||||
<option value="PAID">Paid Only</option>
|
||||
<option value="UNPAID">Unpaid Only</option>
|
||||
<option value="">
|
||||
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
|
||||
</option>
|
||||
{schedules.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
onClick={doExport}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
|
||||
)}
|
||||
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
||||
{isError && <p className="text-xs text-red-500 mt-2">Failed to load report.</p>}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{[
|
||||
"Booking Ref", "Passenger", "Seat / Coach", "Fare",
|
||||
"Payment", "Booked At", "Release At", "Route",
|
||||
].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filtered.map((row, i) => {
|
||||
const isPaid =
|
||||
row.paymentStatus === "SUCCEEDED" ||
|
||||
row.paymentStatus === "COMPLETED";
|
||||
const expired = isExpired(row.releaseAt);
|
||||
return (
|
||||
<tr
|
||||
key={i}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm font-mono font-semibold whitespace-nowrap">
|
||||
{row.bookingRef}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
{row.passengerName}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
<span className="font-semibold">{row.seatNumber}</span>
|
||||
{row.coachNumber !== "—" && (
|
||||
<span className="text-muted-foreground">
|
||||
{" · Coach "}
|
||||
{row.coachNumber}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
{formatCurrency(row.fareMinor, row.currency)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<Badge
|
||||
variant="status"
|
||||
status={isPaid ? "PAID" : row.paymentStatus}
|
||||
>
|
||||
{isPaid ? "PAID" : row.paymentStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
|
||||
{row.bookedAt ? formatDateTime(row.bookedAt) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
{isPaid ? (
|
||||
<span className="text-green-600 dark:text-green-400 text-xs font-medium">
|
||||
— Paid
|
||||
</span>
|
||||
) : row.releaseAt ? (
|
||||
<span
|
||||
className={
|
||||
expired
|
||||
? "text-red-600 dark:text-red-400 text-xs font-semibold"
|
||||
: "text-amber-600 dark:text-amber-400 text-xs font-medium"
|
||||
}
|
||||
>
|
||||
{expired ? "⚠ " : "⏱ "}
|
||||
{formatDateTime(row.releaseAt)}
|
||||
{expired && " (expired)"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
|
||||
{row.scheduleOrigin} → {row.scheduleDestination}
|
||||
{row.scheduleDeparture && (
|
||||
<div className="text-xs">
|
||||
{formatDateTime(row.scheduleDeparture)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{!scheduleId && (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Select a schedule above to load the seat status report</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Paid Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
|
||||
{data.summary.paidCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Payment confirmed</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Unpaid Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
|
||||
{data.summary.unpaidCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Awaiting payment</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Expired Holds</p>
|
||||
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">
|
||||
{data.summary.expiredHoldCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Hold time passed</p>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
||||
{data.summary.blockedCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Manually blocked</p>
|
||||
</div>
|
||||
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border flex">
|
||||
<button
|
||||
onClick={() => setTab("seats")}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "seats" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Seat Details{allSeats.length > 0 ? ` (${allSeats.length})` : ""}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("blocked")}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "blocked" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Blocked Seats{data.blockedSeats.length > 0 ? ` (${data.blockedSeats.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Seat Details Tab */}
|
||||
{tab === "seats" && <div className="card p-0">
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3 gap-4 flex-wrap">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Seat Details
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
className="input max-w-xs"
|
||||
placeholder="Booking ref, passenger, seat…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); resetSeatsPage(); }}
|
||||
/>
|
||||
<select
|
||||
className="input w-40"
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID"); resetSeatsPage(); }}
|
||||
>
|
||||
<option value="ALL">All Seats</option>
|
||||
<option value="PAID">Paid Only</option>
|
||||
<option value="UNPAID">Unpaid Only</option>
|
||||
</select>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={!filtered.length}>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{["Booking Ref", "Passenger", "Category", "Seat Class · Coach · Seat", "Fare", "Payment", "Booked At"].map((h) => (
|
||||
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{pagedSeats.map((row, i) => {
|
||||
const isPaid = row.bookingStatus === "CONFIRMED" || row.bookingStatus === "BOARDED";
|
||||
return (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 font-mono font-semibold whitespace-nowrap">{row.bookingRef}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">{row.passengerName}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">{row.passengerCategory}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs">
|
||||
<span className="font-medium">{row.seatClassName ?? "—"}</span>
|
||||
{row.coachNumber && <span className="text-muted-foreground"> · {row.coachNumber}</span>}
|
||||
{row.seatNumber && <span className="text-muted-foreground"> · #{row.seatNumber}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
|
||||
{formatCurrency(row.fareMinor, row.currency)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<Badge variant="status" status={isPaid ? "PAID" : row.paymentStatus}>
|
||||
{isPaid ? "PAID" : row.paymentStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.bookedAt ? formatDateTime(row.bookedAt) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{pagedSeats.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
|
||||
No seats found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination currentPage={seatsPage} totalPages={seatsTotalPages} onPageChange={setSeatsPage} />
|
||||
</div>}
|
||||
|
||||
{/* Blocked Seats Tab */}
|
||||
{tab === "blocked" && (
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Blocked Seats
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{["Seat Class · Coach · Seat", "Reason", "Blocked By", "Blocked At", "Unblock At"].map((h) => (
|
||||
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{pagedBlocked.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-sm text-muted-foreground">No blocked seats</td>
|
||||
</tr>
|
||||
)}
|
||||
{pagedBlocked.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs">
|
||||
<span className="font-medium">{b.seatClassName ?? "—"}</span>
|
||||
{b.coachNumber && <span className="text-muted-foreground"> · {b.coachNumber}</span>}
|
||||
{b.seatNumber && <span className="text-muted-foreground"> · #{b.seatNumber}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-xs truncate" title={b.reason}>
|
||||
{b.reason}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs">{b.blockedBy}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatDateTime(b.blockedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{b.unblockAt ? formatDateTime(b.unblockAt) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination currentPage={blockedPage} totalPages={blockedTotalPages} onPageChange={setBlockedPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!data && !isLoading && scheduleId && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
No data found for this schedule.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { routeCoachTemplatesApi } from '@/lib/api';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import DateTimePicker from '@/components/ui/DateTimePicker';
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -226,6 +229,20 @@ export default function SchedulesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
/** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return an ISO string. */
|
||||
const eatLocalToISO = (local: string): string => {
|
||||
if (!local) return '';
|
||||
return new Date(local + ':00+03:00').toISOString();
|
||||
};
|
||||
|
||||
/** Convert a UTC ISO string to a datetime-local value in EAT (UTC+3). */
|
||||
const isoToEATLocal = (iso: string): string => {
|
||||
if (!iso) return '';
|
||||
const utcMs = new Date(iso).getTime();
|
||||
const eatMs = utcMs + 3 * 60 * 60 * 1000;
|
||||
return new Date(eatMs).toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const handleBulkSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -238,7 +255,7 @@ export default function SchedulesPage() {
|
||||
const payload: any = {
|
||||
trainId: bulkForm.trainId,
|
||||
routeId: bulkForm.routeId,
|
||||
startDateTime: bulkForm.startDateTime,
|
||||
startDateTime: eatLocalToISO(bulkForm.startDateTime),
|
||||
durationHours: parseInt(bulkForm.durationHours),
|
||||
repeatEveryDays: parseInt(bulkForm.repeatEveryDays),
|
||||
forNextDays: parseInt(bulkForm.forNextDays),
|
||||
@@ -255,8 +272,8 @@ export default function SchedulesPage() {
|
||||
const handleAddSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const dep = new Date(addForm.departureAt);
|
||||
const arr = new Date(addForm.arrivalAt);
|
||||
const dep = new Date(eatLocalToISO(addForm.departureAt));
|
||||
const arr = new Date(eatLocalToISO(addForm.arrivalAt));
|
||||
if (arr <= dep) { setError('Arrival must be after departure'); return; }
|
||||
const validCoaches = addCoachRows.filter((r) => r.coachId);
|
||||
await createScheduleMutation.mutateAsync({
|
||||
@@ -274,18 +291,18 @@ export default function SchedulesPage() {
|
||||
|
||||
if (!editingSchedule) return;
|
||||
|
||||
// Convert local datetime-local values to UTC for API
|
||||
const depLocal = new Date(editForm.departureAt);
|
||||
const arrLocal = new Date(editForm.arrivalAt);
|
||||
|
||||
if (arrLocal <= depLocal) {
|
||||
if (!editForm.departureAt || !editForm.arrivalAt) {
|
||||
setError('Departure and arrival times are required');
|
||||
return;
|
||||
}
|
||||
if (new Date(eatLocalToISO(editForm.arrivalAt)) <= new Date(eatLocalToISO(editForm.departureAt))) {
|
||||
setError('Arrival time must be after departure time');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
departureAt: depLocal.toISOString(),
|
||||
arrivalAt: arrLocal.toISOString(),
|
||||
departureAt: eatLocalToISO(editForm.departureAt),
|
||||
arrivalAt: eatLocalToISO(editForm.arrivalAt),
|
||||
status: editForm.status,
|
||||
isPackageOnly: editForm.isPackageOnly,
|
||||
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
|
||||
@@ -326,23 +343,9 @@ export default function SchedulesPage() {
|
||||
|
||||
const handleEditClick = (schedule: Schedule) => {
|
||||
setEditingSchedule(schedule);
|
||||
|
||||
// Convert UTC dates to local time for datetime-local input
|
||||
// datetime-local expects local time (no timezone info)
|
||||
const dep = new Date(schedule.departureAt);
|
||||
const arr = new Date(schedule.arrivalAt);
|
||||
|
||||
// Convert to local time by adding the timezone offset
|
||||
const depLocal = new Date(dep.getTime() + dep.getTimezoneOffset() * 60000);
|
||||
const arrLocal = new Date(arr.getTime() + arr.getTimezoneOffset() * 60000);
|
||||
|
||||
// Format for datetime-local input (YYYY-MM-DDTHH:mm)
|
||||
const depStr = depLocal.toISOString().slice(0, 16);
|
||||
const arrStr = arrLocal.toISOString().slice(0, 16);
|
||||
|
||||
setEditForm({
|
||||
departureAt: depStr,
|
||||
arrivalAt: arrStr,
|
||||
departureAt: isoToEATLocal(schedule.departureAt),
|
||||
arrivalAt: isoToEATLocal(schedule.arrivalAt),
|
||||
status: schedule.status,
|
||||
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
|
||||
isPackageOnly: schedule.isPackageOnly ?? false,
|
||||
@@ -368,6 +371,8 @@ export default function SchedulesPage() {
|
||||
);
|
||||
});
|
||||
|
||||
const { paged: pagedSchedules, page: schedulePage, totalPages: scheduleTotalPages, setPage: setSchedulePage } = usePagination(filteredSchedules as Schedule[], 20);
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
SCHEDULED: 'edr-badge-info',
|
||||
BOARDING: 'edr-badge-warning',
|
||||
@@ -621,13 +626,16 @@ export default function SchedulesPage() {
|
||||
No schedules found. {filters.search && 'Try adjusting your search.'}
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={scheduleColumns}
|
||||
data={filteredSchedules}
|
||||
actions={scheduleActions}
|
||||
loading={false}
|
||||
emptyMessage="No schedules found."
|
||||
/>
|
||||
<>
|
||||
<DataTable
|
||||
columns={scheduleColumns}
|
||||
data={pagedSchedules}
|
||||
actions={scheduleActions}
|
||||
loading={false}
|
||||
emptyMessage="No schedules found."
|
||||
/>
|
||||
<Pagination currentPage={schedulePage} totalPages={scheduleTotalPages} onPageChange={setSchedulePage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -674,7 +682,7 @@ export default function SchedulesPage() {
|
||||
isOpen={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
|
||||
title="Add Schedule"
|
||||
size="lg"
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleAddSubmit} className="space-y-4">
|
||||
{error && <div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
|
||||
@@ -696,15 +704,19 @@ export default function SchedulesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Departure *</label>
|
||||
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Arrival *</label>
|
||||
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<DateTimePicker
|
||||
label="Departure *"
|
||||
value={addForm.departureAt}
|
||||
onChange={(v) => setAddForm({ ...addForm, departureAt: v })}
|
||||
required
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Arrival *"
|
||||
value={addForm.arrivalAt}
|
||||
onChange={(v) => setAddForm({ ...addForm, arrivalAt: v })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
@@ -832,16 +844,12 @@ export default function SchedulesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Departure Date & Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={bulkForm.startDateTime}
|
||||
onChange={(e) => setBulkForm({ ...bulkForm, startDateTime: e.target.value })}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DateTimePicker
|
||||
label="Departure Date & Time *"
|
||||
value={bulkForm.startDateTime}
|
||||
onChange={(v) => setBulkForm({ ...bulkForm, startDateTime: v })}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
@@ -1009,7 +1017,7 @@ export default function SchedulesPage() {
|
||||
setError(null);
|
||||
}}
|
||||
title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`}
|
||||
size="lg"
|
||||
size="xl"
|
||||
>
|
||||
{editingSchedule && (
|
||||
<form onSubmit={handleEditSubmit} className="space-y-4">
|
||||
@@ -1020,27 +1028,18 @@ export default function SchedulesPage() {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Departure Date & Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editForm.departureAt}
|
||||
onChange={(e) => setEditForm({ ...editForm, departureAt: e.target.value })}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Arrival Date & Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editForm.arrivalAt}
|
||||
onChange={(e) => setEditForm({ ...editForm, arrivalAt: e.target.value })}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DateTimePicker
|
||||
label="Departure Date & Time *"
|
||||
value={editForm.departureAt}
|
||||
onChange={(v) => setEditForm({ ...editForm, departureAt: v })}
|
||||
required
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Arrival Date & Time *"
|
||||
value={editForm.arrivalAt}
|
||||
onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -9,6 +9,8 @@ import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { stationsApi } from '@/lib/api';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
|
||||
export default function StationsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
|
||||
@@ -90,6 +92,9 @@ export default function StationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const stationItems = data?.items || [];
|
||||
const { paged: pagedStations, page, totalPages, setPage } = usePagination(stationItems, 20);
|
||||
|
||||
const handleDelete = (station: any) => {
|
||||
setDeleteConfirm({ isOpen: true, station, error: undefined });
|
||||
};
|
||||
@@ -231,12 +236,13 @@ export default function StationsPage() {
|
||||
|
||||
{/* Stations Table */}
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
data={pagedStations}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No stations found"
|
||||
/>
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -11,11 +11,15 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Image from 'next/image';
|
||||
import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
|
||||
const [ticketPage, setTicketPage] = useState(1);
|
||||
const resetTicketPage = () => setTicketPage(1);
|
||||
const PAGE_SIZE = 50;
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
@@ -65,7 +69,7 @@ export default function TicketsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tickets', filters],
|
||||
queryKey: ['tickets', filters, ticketPage],
|
||||
queryFn: () => ticketsApi.getAll({
|
||||
search: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
@@ -76,11 +80,14 @@ export default function TicketsPage() {
|
||||
dateFrom: filters.dateFrom || undefined,
|
||||
dateTo: filters.dateTo || undefined,
|
||||
coachId: filters.coachId || undefined,
|
||||
skip: 0,
|
||||
take: 50,
|
||||
skip: (ticketPage - 1) * PAGE_SIZE,
|
||||
take: PAGE_SIZE,
|
||||
}),
|
||||
});
|
||||
|
||||
const ticketMeta = (data as any)?.meta;
|
||||
const ticketTotalPages = ticketMeta ? ticketMeta.totalPages : Math.max(1, Math.ceil(((data as any)?.total ?? (data?.items?.length ?? 0)) / PAGE_SIZE));
|
||||
|
||||
const { data: stationsData } = useQuery({
|
||||
queryKey: ['stations'],
|
||||
queryFn: () => stationsApi.getAll(),
|
||||
@@ -91,6 +98,21 @@ export default function TicketsPage() {
|
||||
queryFn: () => apiClient.get('/fleet/coaches'),
|
||||
});
|
||||
|
||||
const [generateMissingResult, setGenerateMissingResult] = useState<any>(null);
|
||||
|
||||
const generateMissingMutation = useMutation({
|
||||
mutationFn: () => ticketsApi.generateMissing(),
|
||||
onSuccess: (result: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
setGenerateMissingResult(result);
|
||||
setSuccessMessage(`Generated ${result.generated} ticket(s) for ${result.processed} booking(s)${result.failed ? ` (${result.failed} failed)` : ''}`);
|
||||
setTimeout(() => setSuccessMessage(''), 6000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error?.response?.data?.message || error?.message || 'Failed to generate missing tickets');
|
||||
},
|
||||
});
|
||||
|
||||
const boardMutation = useMutation({
|
||||
mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) =>
|
||||
ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }),
|
||||
@@ -539,7 +561,17 @@ export default function TicketsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
|
||||
<p className="text-muted-foreground">Manage tickets and validations</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
loading={generateMissingMutation.isPending}
|
||||
onClick={() => generateMissingMutation.mutate()}
|
||||
>
|
||||
Generate Missing
|
||||
</ActionButton>
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -562,7 +594,7 @@ export default function TicketsPage() {
|
||||
placeholder="Search by ticket number..."
|
||||
className="input"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
onChange={(e) => { resetTicketPage(); setFilters({ ...filters, search: e.target.value }); }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -570,7 +602,7 @@ export default function TicketsPage() {
|
||||
<select
|
||||
className="input"
|
||||
value={filters.originStationId}
|
||||
onChange={(e) => setFilters({ ...filters, originStationId: e.target.value })}
|
||||
onChange={(e) => { resetTicketPage(); setFilters({ ...filters, originStationId: e.target.value }); }}
|
||||
>
|
||||
<option value="">All Origins</option>
|
||||
{stations.map((station: any) => (
|
||||
@@ -583,7 +615,7 @@ export default function TicketsPage() {
|
||||
<select
|
||||
className="input"
|
||||
value={filters.destinationStationId}
|
||||
onChange={(e) => setFilters({ ...filters, destinationStationId: e.target.value })}
|
||||
onChange={(e) => { resetTicketPage(); setFilters({ ...filters, destinationStationId: e.target.value }); }}
|
||||
>
|
||||
<option value="">All Destinations</option>
|
||||
{stations.map((station: any) => (
|
||||
@@ -597,7 +629,7 @@ export default function TicketsPage() {
|
||||
type="date"
|
||||
className="input"
|
||||
value={filters.departureDate}
|
||||
onChange={(e) => setFilters({ ...filters, departureDate: e.target.value })}
|
||||
onChange={(e) => { resetTicketPage(); setFilters({ ...filters, departureDate: e.target.value }); }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -605,7 +637,7 @@ export default function TicketsPage() {
|
||||
<select
|
||||
className="input"
|
||||
value={filters.coachId}
|
||||
onChange={(e) => setFilters({ ...filters, coachId: e.target.value })}
|
||||
onChange={(e) => { resetTicketPage(); setFilters({ ...filters, coachId: e.target.value }); }}
|
||||
>
|
||||
<option value="">All Coaches</option>
|
||||
{(Array.isArray(coachesData) ? coachesData : (coachesData as any)?.data || []).map((coach: any) => (
|
||||
@@ -627,7 +659,7 @@ export default function TicketsPage() {
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
onChange={(e) => { resetTicketPage(); setFilters({ ...filters, status: e.target.value }); }}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
@@ -638,12 +670,12 @@ export default function TicketsPage() {
|
||||
<div>
|
||||
<label className="label">Issued From</label>
|
||||
<input type="date" className="input" value={filters.dateFrom}
|
||||
onChange={(e) => setFilters({ ...filters, dateFrom: e.target.value })} />
|
||||
onChange={(e) => { resetTicketPage(); setFilters({ ...filters, dateFrom: e.target.value }); }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Issued To</label>
|
||||
<input type="date" className="input" value={filters.dateTo}
|
||||
onChange={(e) => setFilters({ ...filters, dateTo: e.target.value })} />
|
||||
onChange={(e) => { resetTicketPage(); setFilters({ ...filters, dateTo: e.target.value }); }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -657,6 +689,7 @@ export default function TicketsPage() {
|
||||
loading={isLoading}
|
||||
emptyMessage="No tickets found"
|
||||
/>
|
||||
<Pagination currentPage={ticketPage} totalPages={ticketTotalPages} onPageChange={setTicketPage} />
|
||||
|
||||
{/* Board Confirmation Modal */}
|
||||
<Modal
|
||||
|
||||
@@ -9,6 +9,8 @@ import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import { fleetApi } from '@/lib/api';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { Train as TrainType } from '@/types';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
@@ -119,6 +121,8 @@ export default function TrainsPage() {
|
||||
);
|
||||
});
|
||||
|
||||
const { paged: pagedTrains, page, totalPages, setPage } = usePagination(filteredTrains as TrainType[], 20);
|
||||
|
||||
const trainColumns = [
|
||||
{
|
||||
key: 'number',
|
||||
@@ -224,12 +228,13 @@ export default function TrainsPage() {
|
||||
|
||||
{/* Trains Table */}
|
||||
<DataTable
|
||||
data={filteredTrains}
|
||||
data={pagedTrains}
|
||||
columns={trainColumns}
|
||||
actions={actions}
|
||||
loading={trainsLoading}
|
||||
emptyMessage={search ? "No trains match your search" : "No trains found"}
|
||||
/>
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -1,358 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import { ChevronLeft, ChevronRight, Calendar, ChevronUp, ChevronDown, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
/**
|
||||
* DateTimePicker — label + date / hour / minute / AM-PM all on one row.
|
||||
*
|
||||
* Value contract:
|
||||
* value : "YYYY-MM-DDTHH:mm" (24-hr, EAT local)
|
||||
* onChange: called with the same shape whenever any part changes
|
||||
*/
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value: string; // YYYY-MM-DDTHH:mm (datetime-local format)
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
required?: boolean;
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function parseLocalString(s: string) {
|
||||
if (!s) return null;
|
||||
const [datePart, timePart] = s.split('T');
|
||||
if (!datePart || !timePart) return null;
|
||||
const [yyyy, mm, dd] = datePart.split('-').map(Number);
|
||||
const [h, m] = timePart.split(':').map(Number);
|
||||
if (isNaN(yyyy) || isNaN(mm) || isNaN(dd) || isNaN(h) || isNaN(m)) return null;
|
||||
const period: 'AM' | 'PM' = h >= 12 ? 'PM' : 'AM';
|
||||
const hours12 = h % 12 === 0 ? 12 : h % 12;
|
||||
const date = new Date(yyyy, mm - 1, dd);
|
||||
return { date, hours12, minutes: m, period };
|
||||
const HOURS = Array.from({ length: 12 }, (_, i) => String(i === 0 ? 12 : i).padStart(2, '0'));
|
||||
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
|
||||
|
||||
function parse(value: string) {
|
||||
if (!value) return { date: '', h24: 0, min: 0 };
|
||||
const [datePart, timePart] = value.split('T');
|
||||
const [hStr, mStr] = (timePart ?? '00:00').split(':');
|
||||
return { date: datePart ?? '', h24: parseInt(hStr ?? '0'), min: parseInt(mStr ?? '0') };
|
||||
}
|
||||
|
||||
function toLocalString(date: Date, hours12: number, minutes: number, period: 'AM' | 'PM') {
|
||||
let h = hours12 % 12;
|
||||
if (period === 'PM') h += 12;
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(h).padStart(2, '0');
|
||||
const min = String(minutes).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
|
||||
function build(date: string, h24: number, min: number): string {
|
||||
if (!date) return '';
|
||||
return `${date}T${String(h24).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDisplay(parsed: ReturnType<typeof parseLocalString>): string {
|
||||
if (!parsed) return '';
|
||||
const { date, hours12, minutes, period } = parsed;
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const dateStr = `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
|
||||
const timeStr = `${String(hours12).padStart(2, '0')}:${String(minutes).padStart(2, '0')} ${period}`;
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
export default function DateTimePicker({ value, onChange, required, label }: DateTimePickerProps) {
|
||||
const { date, h24, min } = parse(value);
|
||||
|
||||
export default function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
placeholder = 'Select date & time',
|
||||
label,
|
||||
}: DateTimePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const isPM = h24 >= 12;
|
||||
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
|
||||
const minStr = String(min).padStart(2, '0');
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
const emit = (newDate: string, newH24: number, newMin: number) =>
|
||||
onChange(build(newDate, newH24, newMin));
|
||||
|
||||
const parsed = parseLocalString(value);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(parsed?.date);
|
||||
const [hours12, setHours12] = useState<number>(parsed?.hours12 ?? 12);
|
||||
const [minutes, setMinutes] = useState<number>(parsed?.minutes ?? 0);
|
||||
const [period, setPeriod] = useState<'AM' | 'PM'>(parsed?.period ?? 'AM');
|
||||
|
||||
// Sync internal state when value changes externally
|
||||
useEffect(() => {
|
||||
const p = parseLocalString(value);
|
||||
if (p) {
|
||||
setSelectedDate(p.date);
|
||||
setHours12(p.hours12);
|
||||
setMinutes(p.minutes);
|
||||
setPeriod(p.period);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open]);
|
||||
|
||||
const emit = useCallback(
|
||||
(date: Date | undefined, h: number, m: number, p: 'AM' | 'PM') => {
|
||||
if (!date) return;
|
||||
onChange(toLocalString(date, h, m, p));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleDaySelect = (date: Date | undefined) => {
|
||||
setSelectedDate(date);
|
||||
if (date) emit(date, hours12, minutes, period);
|
||||
const handleHour = (v: string) => {
|
||||
const h = parseInt(v);
|
||||
const next24 = isPM ? (h === 12 ? 12 : h + 12) : (h === 12 ? 0 : h);
|
||||
emit(date, next24, min);
|
||||
};
|
||||
|
||||
const cycleHour = (dir: 1 | -1) => {
|
||||
const next = hours12 + dir;
|
||||
const h = next > 12 ? 1 : next < 1 ? 12 : next;
|
||||
setHours12(h);
|
||||
emit(selectedDate, h, minutes, period);
|
||||
const handleAmPm = (v: string) => {
|
||||
const pm = v === 'PM';
|
||||
let next24 = h24;
|
||||
if (pm && h24 < 12) next24 = h24 + 12;
|
||||
if (!pm && h24 >= 12) next24 = h24 - 12;
|
||||
emit(date, next24, min);
|
||||
};
|
||||
|
||||
const cycleMinute = (dir: 1 | -1) => {
|
||||
const next = minutes + dir;
|
||||
const m = next > 59 ? 0 : next < 0 ? 59 : next;
|
||||
setMinutes(m);
|
||||
emit(selectedDate, hours12, m, period);
|
||||
};
|
||||
|
||||
const togglePeriod = (p: 'AM' | 'PM') => {
|
||||
setPeriod(p);
|
||||
emit(selectedDate, hours12, minutes, p);
|
||||
};
|
||||
|
||||
const handleHourInput = (raw: string) => {
|
||||
const h = parseInt(raw);
|
||||
if (isNaN(h)) return;
|
||||
const clamped = Math.max(1, Math.min(12, h));
|
||||
setHours12(clamped);
|
||||
emit(selectedDate, clamped, minutes, period);
|
||||
};
|
||||
|
||||
const handleMinuteInput = (raw: string) => {
|
||||
const m = parseInt(raw);
|
||||
if (isNaN(m)) return;
|
||||
const clamped = Math.max(0, Math.min(59, m));
|
||||
setMinutes(clamped);
|
||||
emit(selectedDate, hours12, clamped, period);
|
||||
};
|
||||
|
||||
const modal = open && mounted ? createPortal(
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center p-4"
|
||||
style={{ zIndex: 10050 }}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative bg-background border border-border rounded-2xl shadow-2xl p-5 w-80 animate-fade-up">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{label ?? placeholder}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<DayPicker
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDaySelect}
|
||||
showOutsideDays
|
||||
classNames={{
|
||||
root: 'w-full',
|
||||
months: 'w-full',
|
||||
month: 'w-full',
|
||||
month_caption: 'flex items-center justify-between mb-3',
|
||||
caption_label: 'text-sm font-semibold text-foreground',
|
||||
nav: 'flex items-center gap-1',
|
||||
button_previous: [
|
||||
'h-7 w-7 rounded-lg flex items-center justify-center',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors',
|
||||
].join(' '),
|
||||
button_next: [
|
||||
'h-7 w-7 rounded-lg flex items-center justify-center',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors',
|
||||
].join(' '),
|
||||
month_grid: 'w-full border-collapse',
|
||||
weekdays: 'flex w-full mb-1',
|
||||
weekday: 'flex-1 text-center text-xs font-medium text-muted-foreground py-1',
|
||||
weeks: '',
|
||||
week: 'flex w-full mt-0.5',
|
||||
day: 'flex-1 flex items-center justify-center p-0',
|
||||
day_button: [
|
||||
'h-8 w-8 text-xs rounded-lg flex items-center justify-center',
|
||||
'transition-colors hover:bg-muted cursor-pointer',
|
||||
].join(' '),
|
||||
selected: '',
|
||||
today: '',
|
||||
outside: 'opacity-30',
|
||||
disabled: 'opacity-20 cursor-not-allowed',
|
||||
hidden: 'invisible',
|
||||
range_start: '',
|
||||
range_end: '',
|
||||
range_middle: '',
|
||||
focused: 'ring-1 ring-primary/50',
|
||||
chevron: '',
|
||||
dropdowns: '',
|
||||
dropdown: '',
|
||||
dropdown_root: '',
|
||||
footer: '',
|
||||
months_dropdown: '',
|
||||
week_number: '',
|
||||
week_number_header: '',
|
||||
years_dropdown: '',
|
||||
weeks_after_enter: '',
|
||||
weeks_after_exit: '',
|
||||
weeks_before_enter: '',
|
||||
weeks_before_exit: '',
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation }) =>
|
||||
orientation === 'left' ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
),
|
||||
DayButton: ({ day, modifiers, className, ...props }) => (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
className,
|
||||
modifiers.selected && 'bg-primary text-primary-foreground font-semibold',
|
||||
modifiers.today && !modifiers.selected && 'text-primary font-bold',
|
||||
)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Time picker */}
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Time</p>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
{/* Hour spinner */}
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cycleHour(-1)}
|
||||
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={String(hours12).padStart(2, '0')}
|
||||
onChange={e => handleHourInput(e.target.value)}
|
||||
onFocus={e => e.target.select()}
|
||||
className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cycleHour(1)}
|
||||
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-2xl font-bold text-foreground leading-none mb-0.5">:</span>
|
||||
|
||||
{/* Minute spinner */}
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cycleMinute(-1)}
|
||||
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={String(minutes).padStart(2, '0')}
|
||||
onChange={e => handleMinuteInput(e.target.value)}
|
||||
onFocus={e => e.target.select()}
|
||||
className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cycleMinute(1)}
|
||||
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AM / PM */}
|
||||
<div className="flex flex-col gap-1.5 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePeriod('AM')}
|
||||
className={cn(
|
||||
'px-4 py-1.5 text-sm font-semibold rounded-lg border transition-colors',
|
||||
period === 'AM'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background text-muted-foreground border-border hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
AM
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePeriod('PM')}
|
||||
className={cn(
|
||||
'px-4 py-1.5 text-sm font-semibold rounded-lg border transition-colors',
|
||||
period === 'PM'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background text-muted-foreground border-border hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
PM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-4 w-full btn btn-primary text-sm py-2"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null;
|
||||
|
||||
const displayText = parsed ? formatDisplay(parsed) : placeholder;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'input flex items-center gap-2 text-left cursor-pointer',
|
||||
!parsed && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 text-sm">{displayText}</span>
|
||||
</button>
|
||||
{modal}
|
||||
<div>
|
||||
{label && (
|
||||
<label className="label">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Date */}
|
||||
<input
|
||||
type="date"
|
||||
className="input text-sm w-36 shrink-0"
|
||||
value={date}
|
||||
required={required}
|
||||
onChange={(e) => emit(e.target.value, h24, min)}
|
||||
/>
|
||||
{/* Hour */}
|
||||
<select
|
||||
className="input text-sm w-16 shrink-0"
|
||||
value={String(h12).padStart(2, '0')}
|
||||
onChange={(e) => handleHour(e.target.value)}
|
||||
>
|
||||
{HOURS.map((h) => <option key={h} value={h}>{h}</option>)}
|
||||
</select>
|
||||
<span className="text-muted-foreground font-bold shrink-0">:</span>
|
||||
{/* Minute */}
|
||||
<select
|
||||
className="input text-sm w-16 shrink-0"
|
||||
value={minStr}
|
||||
onChange={(e) => emit(date, h24, parseInt(e.target.value))}
|
||||
>
|
||||
{MINUTES.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
{/* AM / PM */}
|
||||
<select
|
||||
className="input text-sm w-16 shrink-0"
|
||||
value={isPM ? 'PM' : 'AM'}
|
||||
onChange={(e) => handleAmPm(e.target.value)}
|
||||
>
|
||||
<option value="AM">AM</option>
|
||||
<option value="PM">PM</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,6 +224,7 @@ export const ticketsApi = {
|
||||
return Array.isArray(response) ? { items: response } : response;
|
||||
},
|
||||
getById: (id: string) => apiClient.get<any>(`/tickets/${id}`),
|
||||
generateMissing: () => apiClient.post<any>('/tickets/generate-missing', {}),
|
||||
validate: (ticketId: string, data: any) => apiClient.post<any>(`/tickets/${ticketId}/validate`, data),
|
||||
scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post<any>(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data),
|
||||
regenerate: (ticketId: string) => apiClient.post<any>(`/tickets/${ticketId}/regenerate`),
|
||||
|
||||
18
apps/edr-passenger-web/backoffice/src/lib/use-pagination.ts
Normal file
18
apps/edr-passenger-web/backoffice/src/lib/use-pagination.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
export function usePagination<T>(items: T[], pageSize = 20) {
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
|
||||
const paged = useMemo(
|
||||
() => items.slice((safePage - 1) * pageSize, safePage * pageSize),
|
||||
[items, safePage, pageSize],
|
||||
);
|
||||
|
||||
// Reset to page 1 whenever the source list changes length (e.g. after a filter)
|
||||
const reset = () => setPage(1);
|
||||
|
||||
return { paged, page: safePage, totalPages, setPage, reset };
|
||||
}
|
||||
@@ -70,7 +70,9 @@ export default function ConfirmationPage() {
|
||||
const CONFIRMATION_GRACE_PERIOD_MS = 10_000;
|
||||
const FAST_POLL_INTERVAL_MS = 2_500;
|
||||
const SLOW_POLL_INTERVAL_MS = 10_000;
|
||||
const MAX_TICKET_POLL_ATTEMPTS = 12; // 12 × 2.5s = 30s max wait for tickets
|
||||
const mountTimeRef = useRef(Date.now());
|
||||
const ticketPollAttemptsRef = useRef(0);
|
||||
const [withinGracePeriod, setWithinGracePeriod] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -112,6 +114,24 @@ export default function ConfirmationPage() {
|
||||
// backgrounded tabs, so that can take a very long time.
|
||||
staleTime: 0,
|
||||
enabled: !!bookingId,
|
||||
// Keep polling after CONFIRMED until tickets are issued — ticket generation runs
|
||||
// async after the booking transaction commits (see finalizePaymentSuccess in
|
||||
// payments.service.ts), so the first CONFIRMED fetch often returns an empty
|
||||
// tickets array. Free children have no ticket, so compare against adult count
|
||||
// (at minimum 1 ticket must exist) rather than total passenger count.
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
if (!data || data.status !== "CONFIRMED") return false;
|
||||
const adultCount = searchCriteria?.adultCount ?? passengers.filter((p) => !isChild(p)).length;
|
||||
const expectedTickets = Math.max(1, adultCount);
|
||||
if ((data.tickets?.length ?? 0) >= expectedTickets) {
|
||||
ticketPollAttemptsRef.current = 0;
|
||||
return false;
|
||||
}
|
||||
if (ticketPollAttemptsRef.current >= MAX_TICKET_POLL_ATTEMPTS) return false;
|
||||
ticketPollAttemptsRef.current += 1;
|
||||
return FAST_POLL_INTERVAL_MS;
|
||||
},
|
||||
});
|
||||
|
||||
// Poll the payment intent while the booking is PENDING_PAYMENT — fast during the
|
||||
@@ -153,7 +173,7 @@ export default function ConfirmationPage() {
|
||||
};
|
||||
|
||||
const handleDownloadVoucher = async () => {
|
||||
if (!pnr) {
|
||||
if (!pnr || !bookingId) {
|
||||
alert("Booking data not available. Please try again.");
|
||||
return;
|
||||
}
|
||||
@@ -164,23 +184,28 @@ export default function ConfirmationPage() {
|
||||
|
||||
setIsGeneratingVoucher(true);
|
||||
try {
|
||||
const { generatePassengerVoucherPDF } =
|
||||
await import("@/lib/generate-voucher");
|
||||
const { generatePassengerVoucherPDF } = await import("@/lib/generate-voucher");
|
||||
|
||||
// Always fetch fresh booking data so tickets are present even if the cached
|
||||
// _booking raced ahead of ticket generation (tickets are written async after
|
||||
// the booking is confirmed — see finalizePaymentSuccess in payments.service.ts).
|
||||
const freshBooking: BookingWithTicket = await apiClient.get(`/bookings/${bookingId}`);
|
||||
const bookingData = freshBooking ?? _booking;
|
||||
|
||||
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||
// The server-confirmed settled amount/currency (what was actually charged) is
|
||||
// authoritative — prefer it over the ETB booking fare once available. Shown exactly
|
||||
// as returned by the API (no /100, no per-passenger split) on every passenger's
|
||||
// voucher — see fareIsMajorUnits below.
|
||||
const settledAmountMinor = _booking?.payment?.amountMinor;
|
||||
const settledCurrency = _booking?.payment?.currency;
|
||||
const settledAmountMinor = bookingData?.payment?.amountMinor;
|
||||
const settledCurrency = bookingData?.payment?.currency;
|
||||
const hasSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
||||
// Derive display currency from nationality (same logic as review/payment pages)
|
||||
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
||||
const passengerDisplayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||
const voucherCurrency = hasSettledAmount ? settledCurrency! : passengerDisplayCurrency;
|
||||
const createdAt = _booking?.createdAt || new Date().toISOString();
|
||||
const status = _booking?.status || "CONFIRMED";
|
||||
const createdAt = bookingData?.createdAt || new Date().toISOString();
|
||||
const status = bookingData?.status || "CONFIRMED";
|
||||
|
||||
// Compute per-passenger fares (in ETB) using the same logic as the review/payment
|
||||
// pages. reviewedPassengerFares is the authoritative source; rebuild from package
|
||||
@@ -206,7 +231,7 @@ export default function ConfirmationPage() {
|
||||
return isPkgChild ? pkgChildFare : pkgAdultFare;
|
||||
}
|
||||
const totalFare =
|
||||
reviewedTotalMinor ?? paidAmountMinor ?? _booking?.totalMinor ?? 0;
|
||||
reviewedTotalMinor ?? paidAmountMinor ?? bookingData?.totalMinor ?? 0;
|
||||
return Math.round(totalFare / passengers.length);
|
||||
};
|
||||
|
||||
@@ -252,14 +277,23 @@ export default function ConfirmationPage() {
|
||||
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout)
|
||||
// between them — a setTimeout delay would push later saves outside the click's
|
||||
// synchronous user-activation window and risk iOS Safari silently blocking them.
|
||||
// Build a consumed-ticket set so each ticket is used at most once even when
|
||||
// multiple passengers share the same name (e.g. parent + child same surname).
|
||||
const remainingTickets = [...(bookingData?.tickets ?? [])];
|
||||
const consumeTicket = (name: string, idx: number) => {
|
||||
// 1. exact name match
|
||||
let ti = remainingTickets.findIndex((t) => t.passengerName === name);
|
||||
// 2. case-insensitive match (Verifayda may rewrite casing server-side)
|
||||
if (ti === -1) ti = remainingTickets.findIndex((t) => t.passengerName?.toLowerCase() === name.toLowerCase());
|
||||
// 3. positional fallback
|
||||
if (ti === -1 && idx < remainingTickets.length) ti = idx;
|
||||
if (ti === -1) return null;
|
||||
return remainingTickets.splice(ti, 1)[0];
|
||||
};
|
||||
|
||||
for (let i = 0; i < passengers.length; i++) {
|
||||
const p = passengers[i];
|
||||
// Same match-by-name-then-position as the on-screen ticket list above — no
|
||||
// fabricated placeholder if there's no backend ticket data (see generate-voucher.ts).
|
||||
const matchedTicket =
|
||||
_booking?.tickets?.find((t) => t.passengerName === p.name) ??
|
||||
_booking?.tickets?.[i] ??
|
||||
null;
|
||||
const matchedTicket = consumeTicket(p.name || "", i);
|
||||
const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued";
|
||||
|
||||
await generatePassengerVoucherPDF({
|
||||
|
||||
@@ -117,6 +117,19 @@ function BookingDetailContent() {
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
// When the booking is CONFIRMED but has no tickets (generate failed silently at
|
||||
// payment time), call generate now so tickets are ready before the user clicks Download.
|
||||
useEffect(() => {
|
||||
if (
|
||||
booking?.status === 'CONFIRMED' &&
|
||||
booking?.id &&
|
||||
Array.isArray(booking?.tickets) &&
|
||||
booking.tickets.length === 0
|
||||
) {
|
||||
apiClient.post(`/tickets/generate/${booking.id}`, {}).then(() => refetch()).catch(() => {});
|
||||
}
|
||||
}, [booking?.id, booking?.status, booking?.tickets?.length]);
|
||||
|
||||
const { data: paymentMethods } = useQuery<any[]>({
|
||||
queryKey: ["payment-methods"],
|
||||
queryFn: () => apiClient.get("/payments/methods"),
|
||||
@@ -246,11 +259,13 @@ function BookingDetailContent() {
|
||||
alert("Booking data not available. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingVoucher(true);
|
||||
try {
|
||||
// Always fetch fresh booking data so tickets are included.
|
||||
const fresh = await apiClient.get<any>(`/bookings/${booking.bookingRef}`);
|
||||
const bookingData = (fresh as any)?.data || fresh;
|
||||
const { generateVoucherPDF } = await import("@/lib/generate-voucher");
|
||||
await generateVoucherPDF(booking as any);
|
||||
await generateVoucherPDF(bookingData as any);
|
||||
} catch (error) {
|
||||
alert(
|
||||
`Failed to generate voucher: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDR Booking Checker</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin-bottom: 0.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
input[type="text"], textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus, textarea:focus {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16,185,129,0.15);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 140px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
|
||||
|
||||
button {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.65rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #059669; }
|
||||
button:disabled { background: #a7f3d0; cursor: not-allowed; }
|
||||
.btn-secondary { background: #e2e8f0; color: #475569; }
|
||||
.btn-secondary:hover:not(:disabled) { background: #cbd5e1; }
|
||||
|
||||
.btn-row { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
|
||||
|
||||
#status { font-size: 0.85rem; color: #64748b; }
|
||||
|
||||
.progress-bar-wrap {
|
||||
width: 100%;
|
||||
background: #e2e8f0;
|
||||
border-radius: 9999px;
|
||||
height: 6px;
|
||||
margin-top: 0.75rem;
|
||||
display: none;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: #10b981;
|
||||
border-radius: 9999px;
|
||||
transition: width 0.2s;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #475569;
|
||||
}
|
||||
.summary strong { color: #0f172a; }
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-btn {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 9999px;
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-btn.active { background: #10b981; color: #fff; border-color: #10b981; }
|
||||
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
|
||||
thead tr { background: #f8fafc; border-bottom: 2px solid #e2e8f0; }
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
tr.hidden-row { display: none; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-yes { background: #fef3c7; color: #92400e; }
|
||||
.badge-no { background: #f1f5f9; color: #64748b; }
|
||||
.badge-confirmed { background: #d1fae5; color: #065f46; }
|
||||
.badge-cancelled { background: #fee2e2; color: #991b1b; }
|
||||
.badge-pending { background: #fef9c3; color: #854d0e; }
|
||||
.badge-other { background: #f1f5f9; color: #475569; }
|
||||
.badge-dup { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.error-row td { color: #ef4444; font-style: italic; }
|
||||
.dup-row td { background: #fff5f5; }
|
||||
.dup-cell { background:#fee2e2;color:#991b1b;border-radius:6px;padding:0.4rem 0.6rem;display:inline-block;font-weight:600;font-size:0.82rem; }
|
||||
|
||||
#results-section { display: none; }
|
||||
|
||||
.export-btn { background: #6366f1; }
|
||||
.export-btn:hover:not(:disabled) { background: #4f46e5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>EDR Booking Checker</h1>
|
||||
|
||||
<div class="card">
|
||||
<label for="base-url">API Base URL</label>
|
||||
<input type="text" id="base-url" value="http://localhost:4000" />
|
||||
<p class="hint">No trailing slash. e.g. https://api.edrsc.com</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<label for="refs">Booking References</label>
|
||||
<textarea id="refs" placeholder="ABCDEF GHIJKL MNOPQR One per line, comma-separated, or wrapped in {curly,braces}"></textarea>
|
||||
<p class="hint">Supports any format: one per line, comma-separated, or <code>{REF1,REF2}</code> groups.</p>
|
||||
|
||||
<div class="btn-row">
|
||||
<button id="run-btn" onclick="run()">Check Bookings</button>
|
||||
<button class="btn-secondary" onclick="clearAll()">Clear</button>
|
||||
<button class="btn-secondary export-btn" onclick="exportCSV()" id="export-btn" style="display:none;background:#6366f1;color:#fff;">Export CSV</button>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
<div class="progress-bar-wrap" id="progress-wrap">
|
||||
<div class="progress-bar" id="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="results-section">
|
||||
<div class="summary" id="summary"></div>
|
||||
|
||||
<div class="filter-row">
|
||||
<button class="filter-btn active" onclick="setFilter('all', this)">All</button>
|
||||
<button class="filter-btn" onclick="setFilter('package', this)">Package Only</button>
|
||||
<button class="filter-btn" onclick="setFilter('regular', this)">Regular Only</button>
|
||||
<button class="filter-btn" onclick="setFilter('error', this)">Errors Only</button>
|
||||
<button class="filter-btn" onclick="setFilter('dup', this)">Duplicate Seats</button>
|
||||
</div>
|
||||
<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap">
|
||||
<label for="date-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Date:</label>
|
||||
<input type="text" id="date-filter" placeholder="e.g. 18 Jul 2026" style="width:160px" oninput="applyFilter(currentFilter)" />
|
||||
<label for="route-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Route:</label>
|
||||
<input type="text" id="route-filter" placeholder="e.g. DIR or BISH" style="width:160px" oninput="applyFilter(currentFilter)" />
|
||||
<label for="class-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Seat Class:</label>
|
||||
<input type="text" id="class-filter" placeholder="e.g. VIP" style="width:140px" oninput="applyFilter(currentFilter)" />
|
||||
<span id="row-count" style="font-size:0.82rem;color:#64748b;margin-left:0.5rem"></span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Journey</th>
|
||||
<th>Duplicate Bookings</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allRows = [];
|
||||
let currentFilter = 'all';
|
||||
|
||||
function parseRefs(raw) {
|
||||
// Remove curly braces, split on newlines/commas, deduplicate
|
||||
const cleaned = raw.replace(/[{}]/g, ' ');
|
||||
const refs = cleaned.split(/[\n,\s]+/).map(r => r.trim()).filter(Boolean);
|
||||
return [...new Set(refs)];
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
if (!status) return '<span class="badge badge-other">—</span>';
|
||||
const s = status.toUpperCase();
|
||||
const cls = s === 'CONFIRMED' ? 'confirmed'
|
||||
: s === 'CANCELLED' ? 'cancelled'
|
||||
: s.includes('PENDING') ? 'pending'
|
||||
: 'other';
|
||||
return `<span class="badge badge-${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const baseUrl = document.getElementById('base-url').value.trim().replace(/\/$/, '');
|
||||
const refs = parseRefs(document.getElementById('refs').value);
|
||||
|
||||
if (!refs.length) { setStatus('Enter at least one booking reference.'); return; }
|
||||
|
||||
const btn = document.getElementById('run-btn');
|
||||
btn.disabled = true;
|
||||
document.getElementById('export-btn').style.display = 'none';
|
||||
document.getElementById('results-section').style.display = 'none';
|
||||
document.getElementById('progress-wrap').style.display = 'block';
|
||||
setProgress(0);
|
||||
setStatus(`Checking ${refs.length} booking(s)…`);
|
||||
|
||||
allRows = [];
|
||||
let done = 0;
|
||||
|
||||
// Run in batches of 20 to avoid overwhelming the server
|
||||
const BATCH = 20;
|
||||
for (let i = 0; i < refs.length; i += BATCH) {
|
||||
const batch = refs.slice(i, i + BATCH);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (ref) => {
|
||||
try {
|
||||
const proxyUrl = `http://localhost:8080/proxy?url=${encodeURIComponent(`${baseUrl}/bookings/${ref}`)}`;
|
||||
const res = await fetch(proxyUrl);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
const d = json.data ?? json;
|
||||
return {
|
||||
ref,
|
||||
status: d.status ?? 'UNKNOWN',
|
||||
bookingType: d.bookingType ?? '—',
|
||||
isPackage: d.isPackageBooking ?? !!d.packageId,
|
||||
packageName: d.packageName ?? '—',
|
||||
packageId: d.packageId ?? '—',
|
||||
passengerNames: (d.passengers ?? []).map(p => p.fullName ?? '—').join(', '),
|
||||
phone: d.contactPhone ?? '—',
|
||||
coaches: (d.passengers ?? []).map(p => p.seat?.coach ?? '—').join(', '),
|
||||
seatNumbers: (d.passengers ?? []).map(p => p.seat?.number ?? '—').join(', '),
|
||||
seatClasses: (d.passengers ?? []).map(p => p.seat?.seatClass ?? '—').join(', '),
|
||||
seatIds: (d.passengers ?? []).map(p => p.seat?.id).filter(Boolean),
|
||||
seats: (d.passengers ?? []).map(p => {
|
||||
const legScheduleMap = {
|
||||
1: d.schedule?.id,
|
||||
2: d.bookingType === 'ROUND_TRIP' ? d.returnSchedule?.id : d.leg2Schedule?.id,
|
||||
3: d.returnSchedule?.id,
|
||||
4: d.returnLeg2Schedule?.id,
|
||||
};
|
||||
const legScheduleObjMap = {
|
||||
1: d.schedule,
|
||||
2: d.bookingType === 'ROUND_TRIP' ? d.returnSchedule : d.leg2Schedule,
|
||||
3: d.returnSchedule,
|
||||
4: d.returnLeg2Schedule,
|
||||
};
|
||||
const sched = legScheduleObjMap[p.leg] ?? d.schedule;
|
||||
const scheduleId = sched?.id ?? null;
|
||||
const originSeq = sched?.origin?.sequence ?? sched?.originStation?.sequence ?? 0;
|
||||
const destSeq = sched?.destination?.sequence ?? sched?.destinationStation?.sequence ?? 999;
|
||||
const depMap = {
|
||||
1: d.schedule?.departureAt,
|
||||
2: d.bookingType === 'ROUND_TRIP' ? d.returnSchedule?.departureAt : d.leg2Schedule?.departureAt,
|
||||
3: d.returnSchedule?.departureAt,
|
||||
4: d.returnLeg2Schedule?.departureAt,
|
||||
};
|
||||
const departureAt = depMap[p.leg] ?? d.schedule?.departureAt;
|
||||
const depDate = departureAt ? new Date(departureAt).toLocaleDateString('en-GB', { day:'2-digit', month:'short', year:'numeric' }) : '—';
|
||||
const legLabel = d.bookingType === 'ONE_WAY' ? 'Outbound'
|
||||
: d.bookingType === 'ROUND_TRIP'
|
||||
? (p.leg === 1 ? 'Outbound' : 'Return')
|
||||
: d.bookingType === 'TRANSIT'
|
||||
? (p.leg === 1 ? 'Outbound Leg 1' : 'Outbound Leg 2')
|
||||
: d.bookingType === 'ROUND_TRIP_TRANSIT'
|
||||
? (p.leg === 1 ? 'Outbound Leg 1' : p.leg === 2 ? 'Outbound Leg 2' : p.leg === 3 ? 'Return Leg 1' : 'Return Leg 2')
|
||||
: `Leg ${p.leg}`;
|
||||
return { scheduleId, originSeq, destSeq, depDate, legLabel, coach: p.seat?.coach ?? '—', number: p.seat?.number ?? '—', seatClass: p.seat?.seatClass ?? '—' };
|
||||
}),
|
||||
origin: d.schedule?.origin?.code ?? d.schedule?.origin?.name ?? '—',
|
||||
destination: d.schedule?.destination?.code ?? d.schedule?.destination?.name ?? '—',
|
||||
hasDupSeat: false,
|
||||
dupSeatRefs: [],
|
||||
error: null,
|
||||
};
|
||||
} catch (e) {
|
||||
return { ref, status: 'ERROR', bookingType: '—', isPackage: false, packageName: '—', packageId: '—', passengerNames: '—', phone: '—', seatClasses: '—', seatIds: [], seats: [], origin: '—', destination: '—', hasDupSeat: false, dupSeatRefs: [], dupSeatIds: new Set(), error: e.message };
|
||||
}
|
||||
})
|
||||
);
|
||||
allRows.push(...batchResults);
|
||||
done += batch.length;
|
||||
setProgress(Math.round((done / refs.length) * 100));
|
||||
setStatus(`Checked ${done} / ${refs.length}…`);
|
||||
}
|
||||
|
||||
detectDuplicateSeats();
|
||||
renderTable();
|
||||
renderSummary();
|
||||
|
||||
document.getElementById('results-section').style.display = 'block';
|
||||
document.getElementById('export-btn').style.display = 'inline-block';
|
||||
document.getElementById('progress-wrap').style.display = 'none';
|
||||
setStatus('');
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function detectDuplicateSeats() {
|
||||
// Group seats by scheduleId|coach|seatNumber
|
||||
// Two bookings conflict if they share the same seat on the same schedule
|
||||
// AND their origin→destination sequence ranges overlap
|
||||
const seatMap = new Map(); // key: scheduleId|coach|seat -> [{ref, originSeq, destSeq}]
|
||||
for (const row of allRows) {
|
||||
if (row.error) continue;
|
||||
for (const seat of row.seats) {
|
||||
if (!seat.scheduleId || seat.coach === '—' || seat.number === '—') continue;
|
||||
const key = `${seat.scheduleId}|${seat.coach}|${seat.number}`;
|
||||
if (!seatMap.has(key)) seatMap.set(key, []);
|
||||
seatMap.get(key).push({ ref: row.ref, originSeq: seat.originSeq, destSeq: seat.destSeq });
|
||||
}
|
||||
}
|
||||
|
||||
// Two segments [a1,a2] and [b1,b2] overlap if a1 < b2 && b1 < a2
|
||||
function overlaps(a1, a2, b1, b2) { return a1 < b2 && b1 < a2; }
|
||||
|
||||
for (const row of allRows) {
|
||||
if (row.error) continue;
|
||||
const dupRefs = new Set();
|
||||
const dupSeatKeys = new Set();
|
||||
for (const seat of row.seats) {
|
||||
if (!seat.scheduleId) continue;
|
||||
const key = `${seat.scheduleId}|${seat.coach}|${seat.number}`;
|
||||
const entries = seatMap.get(key) ?? [];
|
||||
for (const other of entries) {
|
||||
if (other.ref === row.ref) continue;
|
||||
if (overlaps(seat.originSeq, seat.destSeq, other.originSeq, other.destSeq)) {
|
||||
dupRefs.add(other.ref);
|
||||
dupSeatKeys.add(`${seat.coach}|${seat.number}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
row.hasDupSeat = dupRefs.size > 0;
|
||||
row.dupSeatRefs = [...dupRefs];
|
||||
row.dupSeatIds = dupSeatKeys;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDupGroups() {
|
||||
const groups = new Map();
|
||||
for (const row of allRows) {
|
||||
if (row.error || !row.hasDupSeat) continue;
|
||||
const names = row.passengerNames.split(', ');
|
||||
for (const seat of row.seats) {
|
||||
if (!row.dupSeatIds.has(`${seat.coach}|${seat.number}`)) continue;
|
||||
const key = `${seat.depDate}|${seat.legLabel}|${row.origin}→${row.destination}|${seat.seatClass}|${seat.coach}|${seat.number}`;
|
||||
if (!groups.has(key)) groups.set(key, { entries: [], isPackage: false });
|
||||
const name = (names[row.seats.indexOf(seat)] ?? '').trim() || '—';
|
||||
groups.get(key).entries.push({ ref: row.ref, name, phone: row.phone, isPackage: row.isPackage });
|
||||
if (row.isPackage) groups.get(key).isPackage = true;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
const groups = buildDupGroups();
|
||||
if (!groups.size) return;
|
||||
|
||||
for (const [key, { entries, isPackage }] of groups) {
|
||||
if (new Set(entries.map(e => e.ref)).size < 2) continue;
|
||||
const [depDate, legLabel, route, seatClass, coach, seatNum] = key.split('|');
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.dup = '1';
|
||||
tr.dataset.type = isPackage ? 'package' : 'regular';
|
||||
tr.dataset.date = depDate;
|
||||
tr.dataset.route = route;
|
||||
tr.dataset.seatclass = seatClass.toLowerCase();
|
||||
tr.classList.add('dup-row');
|
||||
|
||||
const journeyCell = `<td style="white-space:nowrap">${depDate} · ${legLabel} · ${route} · ${seatClass} · ${coach}-${seatNum}</td>`;
|
||||
|
||||
const bookingsCell = `<td>${entries.map(e =>
|
||||
`<div style="margin-bottom:0.5rem">
|
||||
<span class="dup-cell">${e.ref}</span>
|
||||
<span style="margin-left:0.4rem">${e.name}</span>
|
||||
<span style="font-size:0.78rem;color:#64748b;margin-left:0.4rem">${e.phone}</span>
|
||||
</div>`
|
||||
).join('')}</td>`;
|
||||
|
||||
tr.innerHTML = journeyCell + bookingsCell;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
applyFilter(currentFilter);
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const total = allRows.length;
|
||||
const pkgCount = allRows.filter(r => r.isPackage).length;
|
||||
const errCount = allRows.filter(r => r.error).length;
|
||||
const dupCount = allRows.filter(r => r.hasDupSeat).length;
|
||||
const regCount = total - pkgCount - errCount;
|
||||
document.getElementById('summary').innerHTML = `
|
||||
<span>Total: <strong>${total}</strong></span>
|
||||
<span>Package: <strong>${pkgCount}</strong></span>
|
||||
<span>Regular: <strong>${regCount}</strong></span>
|
||||
${errCount ? `<span style="color:#ef4444">Errors: <strong>${errCount}</strong></span>` : ''}
|
||||
${dupCount ? `<span style="color:#dc2626">⚠ Duplicate Seats: <strong>${dupCount}</strong></span>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function setFilter(type, btn) {
|
||||
currentFilter = type;
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
applyFilter(type);
|
||||
}
|
||||
|
||||
function applyFilter(type) {
|
||||
const dateQ = (document.getElementById('date-filter')?.value ?? '').trim().toLowerCase();
|
||||
const routeQ = (document.getElementById('route-filter')?.value ?? '').trim().toLowerCase();
|
||||
const classQ = (document.getElementById('class-filter')?.value ?? '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
document.querySelectorAll('#tbody tr').forEach(tr => {
|
||||
const t = tr.dataset.type;
|
||||
const typeMatch = type === 'all'
|
||||
|| (type === 'package' && t === 'package')
|
||||
|| (type === 'regular' && t === 'regular')
|
||||
|| (type === 'error' && t === 'error')
|
||||
|| (type === 'dup' && tr.dataset.dup === '1');
|
||||
const dateMatch = !dateQ || (tr.dataset.date ?? '').toLowerCase().includes(dateQ);
|
||||
const routeMatch = !routeQ || (tr.dataset.route ?? '').toLowerCase().includes(routeQ);
|
||||
const classMatch = !classQ || (tr.dataset.seatclass ?? '').includes(classQ);
|
||||
const show = typeMatch && dateMatch && routeMatch && classMatch;
|
||||
tr.classList.toggle('hidden-row', !show);
|
||||
if (show) visible++;
|
||||
});
|
||||
document.getElementById('row-count').textContent = `Showing ${visible} row${visible !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const header = 'Booking Ref,Status,Booking Type,Is Package,Package Name,Package ID,Passenger(s),Phone,Coach-Seat,Seat Class,Duplicate Seat In';
|
||||
const lines = allRows.map(r =>
|
||||
[r.ref, r.status, r.bookingType, r.isPackage, r.packageName, r.packageId, r.seats.map((s, idx) => `${(r.passengerNames.split(', ')[idx] ?? '').trim()} · ${s.legLabel} · ${s.seatClass} · ${r.origin}→${r.destination} · ${s.coach}-${s.number}`).join('; '), r.phone, r.seatClasses, r.dupSeatRefs.join('; ') || '']
|
||||
.map(v => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(',')
|
||||
);
|
||||
const csv = [header, ...lines].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'booking-check-' + new Date().toISOString().slice(0,10) + '.csv';
|
||||
a.click();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
document.getElementById('date-filter').value = '';
|
||||
document.getElementById('route-filter').value = '';
|
||||
document.getElementById('class-filter').value = '';
|
||||
document.getElementById('refs').value = '';
|
||||
document.getElementById('tbody').innerHTML = '';
|
||||
document.getElementById('results-section').style.display = 'none';
|
||||
document.getElementById('export-btn').style.display = 'none';
|
||||
document.getElementById('progress-wrap').style.display = 'none';
|
||||
allRows = [];
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||
function setProgress(pct) { document.getElementById('progress-bar').style.width = pct + '%'; }
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,256 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDR Booking Extractor</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #f1f5f9; color: #1e293b; min-height: 100vh; padding: 2rem; }
|
||||
h1 { font-size: 1.25rem; font-weight: 700; margin-bottom: 1.5rem; color: #0f172a; }
|
||||
.card { background: #fff; border-radius: 0.75rem; border: 1px solid #e2e8f0; padding: 1.5rem; margin-bottom: 1.5rem; }
|
||||
label { display: block; font-size: 0.8rem; font-weight: 600; color: #475569; margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
|
||||
.drop-zone {
|
||||
border: 2px dashed #cbd5e1; border-radius: 0.5rem; padding: 2rem;
|
||||
text-align: center; cursor: pointer; color: #94a3b8; font-size: 0.9rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.drop-zone.over { border-color: #10b981; background: #f0fdf4; color: #065f46; }
|
||||
.drop-zone input[type="file"] { display: none; }
|
||||
button {
|
||||
background: #10b981; color: #fff; border: none; border-radius: 0.5rem;
|
||||
padding: 0.65rem 1.5rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #059669; }
|
||||
button:disabled { background: #a7f3d0; cursor: not-allowed; }
|
||||
.btn-secondary { background: #e2e8f0; color: #475569; }
|
||||
.btn-secondary:hover:not(:disabled) { background: #cbd5e1; }
|
||||
.export-btn { background: #6366f1; color: #fff; }
|
||||
.export-btn:hover:not(:disabled) { background: #4f46e5; }
|
||||
.btn-row { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
|
||||
#status { font-size: 0.85rem; color: #64748b; }
|
||||
.summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1rem; font-size: 0.85rem; color: #475569; }
|
||||
.summary strong { color: #0f172a; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
||||
thead tr { background: #f8fafc; border-bottom: 2px solid #e2e8f0; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; font-weight: 600; color: #475569; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
|
||||
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
#results-section { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>EDR Booking Extractor</h1>
|
||||
|
||||
<div class="card">
|
||||
<label>Bookings JSON File</label>
|
||||
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
||||
<input type="file" id="file-input" accept=".json" onchange="handleFile(this.files[0])" />
|
||||
Drop <code>bookings.json</code> here or click to browse
|
||||
</div>
|
||||
<p class="hint">Accepts a JSON array of bookings or an object with a <code>bookings</code> key.</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary" onclick="clearAll()">Clear</button>
|
||||
<button class="export-btn" id="export-btn" onclick="exportCSV()" style="display:none">Export CSV</button>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="results-section">
|
||||
<div class="summary" id="summary"></div>
|
||||
<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap">
|
||||
<label for="date-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Departure Date:</label>
|
||||
<input type="text" id="date-filter" placeholder="e.g. 18 Jul 2026" style="width:160px;border:1px solid #cbd5e1;border-radius:0.5rem;padding:0.4rem 0.6rem;font-size:0.85rem;outline:none" oninput="applyFilter()" />
|
||||
<span id="row-count" style="font-size:0.82rem;color:#64748b"></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Booking Ref</th>
|
||||
<th>Status</th>
|
||||
<th>Booking Type</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th>Departure</th>
|
||||
<th>Origin</th>
|
||||
<th>Destination</th>
|
||||
<th>Passenger(s)</th>
|
||||
<th>Coach - Seat</th>
|
||||
<th>Payment Method</th>
|
||||
<th>Payment Status</th>
|
||||
<th>Total (DJF)</th>
|
||||
<th>Created At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let extracted = [];
|
||||
|
||||
function fmt(v) { return v ?? ''; }
|
||||
|
||||
function extractRow(b) {
|
||||
const seats = (b.seats ?? b.passengers ?? []);
|
||||
const passengerNames = seats.map(s => s.passengerName ?? s.name ?? '').filter(Boolean).join(', ');
|
||||
const coachSeats = seats.map(s => {
|
||||
const num = s.seat?.seatNumber ?? '';
|
||||
const coach = typeof s.seat?.coach === 'string' ? s.seat.coach : (s.seat?.coach?.number ?? '');
|
||||
return coach && num ? `${coach}-${num}` : (num || coach || '');
|
||||
}).filter(Boolean).join(', ');
|
||||
|
||||
const dep = b.schedule?.departureAt
|
||||
? new Date(b.schedule.departureAt).toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
const createdAt = b.createdAt
|
||||
? new Date(b.createdAt).toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
|
||||
return {
|
||||
booking_ref: fmt(b.bookingRef),
|
||||
status: fmt(b.status),
|
||||
booking_type: fmt(b.bookingType),
|
||||
phone: fmt(b.contactPhone),
|
||||
email: fmt(b.contactEmail),
|
||||
departure: dep,
|
||||
origin: fmt(b.schedule?.originStation?.code),
|
||||
destination: fmt(b.schedule?.destinationStation?.code),
|
||||
passenger_names: passengerNames,
|
||||
coach_seat: coachSeats,
|
||||
payment_method: fmt(b.paymentIntent?.method),
|
||||
payment_status: fmt(b.paymentIntent?.status),
|
||||
total: b.displayTotalMinor != null ? (b.displayTotalMinor / 100).toFixed(2) : '',
|
||||
created_at: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function extractObjects(text) {
|
||||
const results = [];
|
||||
let depth = 0, start = -1, inString = false, escape = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch === '\\' && inString) { escape = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === '{') { if (depth === 0) start = i; depth++; }
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0 && start !== -1) {
|
||||
try { results.push(JSON.parse(text.slice(start, i + 1))); } catch (_) {}
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
window.handleFile = function(file) {
|
||||
if (!file) return;
|
||||
setStatus(`Reading ${file.name}…`);
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
try {
|
||||
const text = e.target.result.replace(/^\uFEFF/, '');
|
||||
let bookings = [], parseWarning = '';
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
bookings = Array.isArray(data) ? data : (data.bookings ?? []);
|
||||
} catch (_) {
|
||||
bookings = extractObjects(text);
|
||||
parseWarning = `⚠ File was truncated — recovered ${bookings.length} complete record(s).`;
|
||||
}
|
||||
if (!bookings.length) throw new Error('No booking records found in the JSON.');
|
||||
extracted = bookings.map(extractRow);
|
||||
renderTable();
|
||||
document.getElementById('results-section').style.display = 'block';
|
||||
document.getElementById('export-btn').style.display = 'inline-block';
|
||||
document.getElementById('summary').innerHTML = `Loaded <strong>${extracted.length}</strong> booking(s) from <strong>${file.name}</strong>`;
|
||||
setStatus(parseWarning);
|
||||
} catch (err) {
|
||||
setStatus(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const HEADERS = ['booking_ref','status','booking_type','phone','email','departure','origin','destination','passenger_names','coach_seat','payment_method','payment_status','total','created_at'];
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
extracted.forEach((row, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.departure = row.departure.toLowerCase();
|
||||
tr.innerHTML = `
|
||||
<td>${i + 1}</td>
|
||||
<td><code>${row.booking_ref || '—'}</code></td>
|
||||
<td>${row.status || '—'}</td>
|
||||
<td>${row.booking_type || '—'}</td>
|
||||
<td>${row.phone || '—'}</td>
|
||||
<td>${row.email || '—'}</td>
|
||||
<td>${row.departure || '—'}</td>
|
||||
<td>${row.origin || '—'}</td>
|
||||
<td>${row.destination || '—'}</td>
|
||||
<td>${row.passenger_names || '—'}</td>
|
||||
<td>${row.coach_seat || '—'}</td>
|
||||
<td>${row.payment_method || '—'}</td>
|
||||
<td>${row.payment_status || '—'}</td>
|
||||
<td>${row.total || '—'}</td>
|
||||
<td>${row.created_at || '—'}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
const q = (document.getElementById('date-filter')?.value ?? '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
document.querySelectorAll('#tbody tr').forEach(tr => {
|
||||
const show = !q || (tr.dataset.departure ?? '').includes(q);
|
||||
tr.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
document.getElementById('row-count').textContent = `Showing ${visible} row${visible !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
window.exportCSV = function() {
|
||||
const header = HEADERS.join(',');
|
||||
const lines = extracted.map(row =>
|
||||
HEADERS.map(k => `"${String(row[k] ?? '').replace(/"/g, '""')}"`).join(',')
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([[header, ...lines].join('\n')], { type: 'text/csv' }));
|
||||
a.download = 'bookings_extracted.csv';
|
||||
a.click();
|
||||
};
|
||||
|
||||
window.clearAll = function() {
|
||||
extracted = [];
|
||||
document.getElementById('tbody').innerHTML = '';
|
||||
document.getElementById('results-section').style.display = 'none';
|
||||
document.getElementById('export-btn').style.display = 'none';
|
||||
document.getElementById('file-input').value = '';
|
||||
document.getElementById('date-filter').value = '';
|
||||
document.getElementById('row-count').textContent = '';
|
||||
setStatus('');
|
||||
};
|
||||
|
||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||
|
||||
const dz = document.getElementById('drop-zone');
|
||||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
|
||||
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
|
||||
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('over'); handleFile(e.dataTransfer.files[0]); });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,53 +0,0 @@
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const PORT = 8080;
|
||||
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
|
||||
// Serve any .html file in the same directory
|
||||
if (req.url === '/' || req.url.endsWith('.html')) {
|
||||
const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1);
|
||||
const filepath = path.join(__dir, filename);
|
||||
if (fs.existsSync(filepath)) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
fs.createReadStream(filepath).pipe(res);
|
||||
} else {
|
||||
res.writeHead(404); res.end('Not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Proxy /proxy?url=<encoded-api-url>
|
||||
if (req.url.startsWith('/proxy?url=')) {
|
||||
const target = decodeURIComponent(req.url.slice('/proxy?url='.length));
|
||||
const parsed = new URL(target);
|
||||
const mod = parsed.protocol === 'https:' ? https : http;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: parsed.hostname },
|
||||
};
|
||||
const proxy = mod.request(options, (apiRes) => {
|
||||
res.writeHead(apiRes.statusCode, apiRes.headers);
|
||||
apiRes.pipe(res);
|
||||
});
|
||||
proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); });
|
||||
req.pipe(proxy);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404); res.end();
|
||||
});
|
||||
|
||||
server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`));
|
||||
@@ -1,239 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDR Ticket Extractor</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #f1f5f9; color: #1e293b; min-height: 100vh; padding: 2rem; }
|
||||
h1 { font-size: 1.25rem; font-weight: 700; margin-bottom: 1.5rem; color: #0f172a; }
|
||||
.card { background: #fff; border-radius: 0.75rem; border: 1px solid #e2e8f0; padding: 1.5rem; margin-bottom: 1.5rem; }
|
||||
label { display: block; font-size: 0.8rem; font-weight: 600; color: #475569; margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
|
||||
.drop-zone {
|
||||
border: 2px dashed #cbd5e1; border-radius: 0.5rem; padding: 2rem;
|
||||
text-align: center; cursor: pointer; color: #94a3b8; font-size: 0.9rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.drop-zone.over { border-color: #10b981; background: #f0fdf4; color: #065f46; }
|
||||
.drop-zone input[type="file"] { display: none; }
|
||||
button {
|
||||
background: #10b981; color: #fff; border: none; border-radius: 0.5rem;
|
||||
padding: 0.65rem 1.5rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #059669; }
|
||||
button:disabled { background: #a7f3d0; cursor: not-allowed; }
|
||||
.btn-secondary { background: #e2e8f0; color: #475569; }
|
||||
.btn-secondary:hover:not(:disabled) { background: #cbd5e1; }
|
||||
.export-btn { background: #6366f1; color: #fff; }
|
||||
.export-btn:hover:not(:disabled) { background: #4f46e5; }
|
||||
.btn-row { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
|
||||
#status { font-size: 0.85rem; color: #64748b; }
|
||||
.summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1rem; font-size: 0.85rem; color: #475569; }
|
||||
.summary strong { color: #0f172a; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
||||
thead tr { background: #f8fafc; border-bottom: 2px solid #e2e8f0; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; font-weight: 600; color: #475569; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
|
||||
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
#results-section { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>EDR Ticket Extractor</h1>
|
||||
|
||||
<div class="card">
|
||||
<label>Tickets JSON File</label>
|
||||
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
||||
<input type="file" id="file-input" accept=".json" onchange="handleFile(this.files[0])" />
|
||||
Drop <code>tickets.json</code> here or click to browse
|
||||
</div>
|
||||
<p class="hint">Accepts a JSON array of tickets or an object with a <code>tickets</code> key.</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary" onclick="clearAll()">Clear</button>
|
||||
<button class="export-btn" id="export-btn" onclick="exportCSV()" style="display:none">Export CSV</button>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="results-section">
|
||||
<div class="summary" id="summary"></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Ticket No.</th>
|
||||
<th>Booking Ref</th>
|
||||
<th>Passenger</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th>Journey Type</th>
|
||||
<th>Origin</th>
|
||||
<th>Destination</th>
|
||||
<th>Seat Class</th>
|
||||
<th>Coach</th>
|
||||
<th>Seat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const FIELDS = [
|
||||
{ key: 'ticket_number', paths: [['ticketNumber']] },
|
||||
{ key: 'booking_ref', paths: [['bookingRef']] },
|
||||
{ key: 'passenger_name', paths: [['passengerName'], ['booking','seats',0,'passengerName']] },
|
||||
{ key: 'phone_number', paths: [['booking','contactPhone'], ['booking','passenger','phone']] },
|
||||
{ key: 'email', paths: [['booking','contactEmail'], ['booking','passenger','email']] },
|
||||
{ key: 'journey_type', paths: [['booking','bookingType']] },
|
||||
{ key: 'origin', paths: [['schedule','originStation','code']] },
|
||||
{ key: 'destination', paths: [['schedule','destinationStation','code']] },
|
||||
{ key: 'seat_class', paths: [['seat','coach','coachType','name']] },
|
||||
{ key: 'coach_number', paths: [['seat','coach','number']] },
|
||||
{ key: 'seat_number', paths: [['seat','seatNumber']] },
|
||||
];
|
||||
|
||||
let extracted = [];
|
||||
|
||||
function getField(obj, keys) {
|
||||
let cur = obj;
|
||||
for (const k of keys) {
|
||||
if (cur == null) return '';
|
||||
if (Array.isArray(cur) && typeof k === 'number') cur = cur[k];
|
||||
else if (typeof cur === 'object' && k in cur) cur = cur[k];
|
||||
else return '';
|
||||
}
|
||||
return cur ?? '';
|
||||
}
|
||||
|
||||
function extractRow(t) {
|
||||
const row = {};
|
||||
for (const f of FIELDS) {
|
||||
for (const path of f.paths) {
|
||||
const val = getField(t, path);
|
||||
if (val !== '') { row[f.key] = String(val); break; }
|
||||
}
|
||||
if (!row[f.key]) row[f.key] = '';
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// Extract complete JSON objects from a truncated array string using brace-depth counting
|
||||
function extractObjects(text) {
|
||||
const results = [];
|
||||
let depth = 0, start = -1, inString = false, escape = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch === '\\' && inString) { escape = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === '{') {
|
||||
if (depth === 0) start = i;
|
||||
depth++;
|
||||
} else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0 && start !== -1) {
|
||||
try { results.push(JSON.parse(text.slice(start, i + 1))); } catch (_) {}
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
window.handleFile = function(file) {
|
||||
if (!file) return;
|
||||
setStatus(`Reading ${file.name}…`);
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
try {
|
||||
const text = e.target.result.replace(/^\uFEFF/, '');
|
||||
let tickets = [];
|
||||
let parseWarning = '';
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
tickets = Array.isArray(data) ? data : (data.tickets ?? []);
|
||||
} catch (_) {
|
||||
// Truncated file: extract complete JSON objects by brace depth
|
||||
tickets = extractObjects(text);
|
||||
parseWarning = `⚠ File was truncated — recovered ${tickets.length} complete record(s).`;
|
||||
}
|
||||
if (!Array.isArray(tickets) || !tickets.length) throw new Error('No ticket records found in the JSON.');
|
||||
extracted = tickets.map(extractRow);
|
||||
renderTable();
|
||||
document.getElementById('results-section').style.display = 'block';
|
||||
document.getElementById('export-btn').style.display = 'inline-block';
|
||||
document.getElementById('summary').innerHTML = `Loaded <strong>${extracted.length}</strong> ticket(s) from <strong>${file.name}</strong>`;
|
||||
setStatus(parseWarning);
|
||||
} catch (err) {
|
||||
setStatus(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
extracted.forEach((row, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${i + 1}</td>
|
||||
<td><code>${row.ticket_number || '—'}</code></td>
|
||||
<td><code>${row.booking_ref || '—'}</code></td>
|
||||
<td>${row.passenger_name || '—'}</td>
|
||||
<td>${row.phone_number || '—'}</td>
|
||||
<td>${row.email || '—'}</td>
|
||||
<td>${row.journey_type || '—'}</td>
|
||||
<td>${row.origin || '—'}</td>
|
||||
<td>${row.destination || '—'}</td>
|
||||
<td>${row.seat_class || '—'}</td>
|
||||
<td>${row.coach_number || '—'}</td>
|
||||
<td>${row.seat_number || '—'}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
window.exportCSV = function() {
|
||||
const header = FIELDS.map(f => f.key).join(',');
|
||||
const lines = extracted.map(row =>
|
||||
FIELDS.map(f => `"${String(row[f.key] ?? '').replace(/"/g, '""')}"`).join(',')
|
||||
);
|
||||
const csv = [header, ...lines].join('\n');
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
|
||||
a.download = 'tickets_extracted.csv';
|
||||
a.click();
|
||||
}
|
||||
|
||||
window.clearAll = function() {
|
||||
extracted = [];
|
||||
document.getElementById('tbody').innerHTML = '';
|
||||
document.getElementById('results-section').style.display = 'none';
|
||||
document.getElementById('export-btn').style.display = 'none';
|
||||
document.getElementById('file-input').value = '';
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||
|
||||
// Drag and drop
|
||||
const dz = document.getElementById('drop-zone');
|
||||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
|
||||
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
|
||||
dz.addEventListener('drop', e => {
|
||||
e.preventDefault(); dz.classList.remove('over');
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user