From 8bd00ea78a728ab9f41c732c593afb9f65cbced9 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 6 Jul 2026 17:15:22 +0000 Subject: [PATCH] enhance user management and booking features with permissions and real-time updates --- .../modules/bookings/bookings.controller.ts | 13 +- .../modules/contracts/contracts.controller.ts | 18 +- .../batch-window.util.spec.ts | 38 +++ .../booking-batch.service.spec.ts | 1 + .../train-scheduling/booking-batch.service.ts | 13 + .../train-scheduling.service.spec.ts | 1 + .../train-scheduling.service.ts | 55 +++- .../src/seed/edr-org.seeder.ts | 273 +++--------------- apps/edr-freight-web/backoffice/src/App.tsx | 3 + .../layout/FreightDashboardHeader.tsx | 19 +- .../layout/FreightDashboardLayout.tsx | 34 ++- 11 files changed, 214 insertions(+), 254 deletions(-) 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 eda249868..444ac578a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -15,11 +15,13 @@ import { UnauthorizedException, UploadedFile, UploadedFiles, + UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { BookingStaff } from '../../common/booking-guards'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { BookingStaff, BookingView } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { @@ -195,6 +197,7 @@ export class BookingsController { } @Get("by-company/:companyId/customer-view") + @BookingView() @ApiOperation({ summary: "List bookings for a company (customer-view shape, backoffice)", }) @@ -205,6 +208,7 @@ export class BookingsController { } @Get("list-summary") + @BookingView() @ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" }) @ApiOkResponse({ type: BookingListSummaryDto }) findListSummary(@Query() filter: FilterBookingDto) { @@ -226,6 +230,7 @@ export class BookingsController { } @Get("queues/:queue") + @BookingView() @ApiOperation({ summary: "List bookings for a dashboard queue", description: "Queues: intake, approval, signatures, marketing, finance", @@ -956,12 +961,18 @@ export class BookingsController { } @Post(":id/contract/sign") + @UseGuards(JwtGuard) @ApiOperation({ summary: "Apply digital signature (customer or staff)" }) async signContract( @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, + @CurrentUser() user: TCurrentUser, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { + // Staff signature needs the sign permission; customer signs their own booking. + if (dto.role !== "CUSTOMER") { + assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff); + } const userId = req.user?.id ?? req.user?.sub; const booking = await this.contractService.signContract(id, dto, { signerUserId: userId, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index a22c7cad4..8591a54d8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -13,10 +13,12 @@ import { UnauthorizedException, UploadedFiles, UploadedFile, + UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { @@ -242,6 +244,7 @@ export class ContractsController { } @Get('list-summary') + @BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view]) @ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' }) @ApiOkResponse({ type: ContractListSummaryDto }) findListSummary(@Query() filter: FilterContractDto) { @@ -449,14 +452,25 @@ export class ContractsController { } @Post(':id/contract/sign') + @UseGuards(JwtGuard) @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) signContract( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SignContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { + // Each staff signing role maps to the permission that step already requires; + // customers sign their own contract with no permission key. + const signRolePermission: Record = { + STAFF: FREIGHT_PERMS.contracts.signStaff, + DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, + CEO: FREIGHT_PERMS.contracts.approveCeo, + }; + if (dto.role !== 'CUSTOMER') { + assertFreightPermission(user, signRolePermission[dto.role]); + } return this.transitionService.sign(id, dto, { - signerUserId: user?.id ?? user?.sub, + signerUserId: user?.id, }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index 4e1770dfb..ba0995679 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -302,3 +302,41 @@ describe('batch-window board windows (config-driven booking cycles)', () => { expect(withEarly?.window?.label).toContain('08:00'); }); }); + +// Regression: a schedule created INSIDE its own window day must open right away +// when the desk is open, and re-deriving after a settings change (close hour +// extended past "now", or lead pulled so the window day becomes today) must +// yield an immediate open — not tomorrow morning. +describe('computeImportWindowTimes — immediate open inside the window day', () => { + // 19:15:17 EAT on Mon 6 Jul = 16:15:17 UTC + const now = new Date('2026-07-06T16:15:17.000Z'); + // Departs Thu 9 Jul ~08:53 EAT + const departure = new Date('2026-07-09T05:53:00.000Z'); + const base = { importWindowLeadDays: 3, windowOpenHour: 8, windowDurationHours: 0.05 }; + + it('desk 8–23, created 19:15 on the window day → opens NOW', () => { + const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now); + expect(t.windowOpensAt.getTime()).toBe(now.getTime()); + }); + + it('desk 8–17, created 19:15 (desk shut) → opens next morning 08:00 EAT', () => { + const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 17 }, now); + expect(t.windowOpensAt.toISOString()).toBe('2026-07-07T05:00:00.000Z'); + }); + + it('close hour extended 17 → 23 after hours: re-derive opens NOW', () => { + // Same call restampPendingWindows makes after the global-rules edit. + const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now); + expect(t.windowOpensAt.getTime()).toBe(now.getTime()); + }); + + it('lead 3 → 4 pulls the window day to today: re-derive opens NOW', () => { + const departsJul10 = new Date('2026-07-10T05:53:00.000Z'); + const t = computeImportWindowTimes( + departsJul10, + { ...base, importWindowLeadDays: 4, windowCloseHour: 23 }, + now, + ); + expect(t.windowOpensAt.getTime()).toBe(now.getTime()); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 4282e045a..8f11cfc95 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -122,6 +122,7 @@ describe('BookingBatchService — PAID reconcile', () => { { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 2d243c819..fd9b74d28 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -40,6 +40,7 @@ import { import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; +import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; /** A train's remaining capacity along the three physical limits the batch enforces. */ @@ -204,6 +205,7 @@ export class BookingBatchService implements OnModuleInit { private readonly scheduler: SchedulerRegistry, private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, + private readonly bookingWindowGateway: BookingWindowGateway, @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, @@ -1873,6 +1875,17 @@ export class BookingBatchService implements OnModuleInit { await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); + // Push the change (open / train full / closed) so portal home and GL cards + // flip in real time — FULL in particular happens outside the window tick + // (batch fill, staff mark-paid) and had no live signal before. + try { + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + if (fresh) this.bookingWindowGateway.emitPhase(fresh); + } catch (err) { + this.logger.warn( + `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`, + ); + } } /** No wagon slots left for allocated + reserved bookings. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index b449669ec..6aecd94a8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -154,6 +154,7 @@ describe('TrainSchedulingService', () => { { htmlToPdfBuffer: jest.fn(), } as never, + { emitPhase: jest.fn() } as never, // bookingWindowGateway ); const defaultFleetWagons = [ 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 fd0bec774..16d5052bf 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 @@ -67,6 +67,7 @@ import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduli import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; import { type BookingWindowConfig } from './booking-window.config'; +import { BookingWindowGateway } from './booking-window.gateway'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -272,9 +273,27 @@ export class TrainSchedulingService { private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, private readonly warehouseInventoryService: WarehouseInventoryService, private readonly pdfDocuments: WarehouseReleaseDocumentService, + private readonly bookingWindowGateway: BookingWindowGateway, private readonly configService?: ConfigService, ) {} + /** + * Push a schedule's current booking-window state over the socket so the + * portal home card and backoffice GL/batch views update in real time — + * used for lifecycle changes outside the window tick (create, cancel, + * finalize, restamp). A push failure must never break the mutation. + */ + private async emitWindowState(scheduleId: string): Promise { + try { + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + if (fresh) this.bookingWindowGateway.emitPhase(fresh); + } catch (err) { + this.logger.warn( + `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + async getEligibleBookings(query: GetEligibleBookingsDto) { // Day-level pooling: when the wizard targets a schedule, surface the whole // (route, EAT day) pool — not just bookings pre-pinned to that train — by @@ -449,6 +468,7 @@ export class TrainSchedulingService { this.logger.log( `Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`, ); + void this.emitWindowState(id); const fresh = await this.trainSchedulesRepository.findById(id); return fresh ?? schedule; @@ -522,6 +542,7 @@ export class TrainSchedulingService { `Departure date changed for schedule ${id} → ${departure.toISOString()} ` + `(window reopens ${times.windowOpensAt.toISOString()})`, ); + void this.emitWindowState(id); const fresh = await this.trainSchedulesRepository.findById(id); return fresh ?? schedule; @@ -561,6 +582,9 @@ export class TrainSchedulingService { ...windowRuleSnapshot(cfg), }); restamped += 1; + // New times take effect immediately on every card (the tick then opens + // the window within seconds if the re-derived open is already due). + void this.emitWindowState(s.id); } if (restamped > 0) { this.logger.log( @@ -764,6 +788,8 @@ export class TrainSchedulingService { }); const created = await this.getTrainScheduleById(createdScheduleId); + // New window announced — portal home / GL cards pick it up immediately. + void this.emitWindowState(createdScheduleId); return { ...created, warnings: scheduleWarnings }; } @@ -1343,6 +1369,8 @@ export class TrainSchedulingService { } }); + // Finalized — push so portal/GL cards reflect the new state instantly. + void this.emitWindowState(scheduleId); return this.getTrainScheduleById(scheduleId); } @@ -1413,6 +1441,8 @@ export class TrainSchedulingService { ); } + // Dispatch closed the window — drop it from portal/GL cards right away. + void this.emitWindowState(scheduleId); return this.getTrainScheduleById(scheduleId); } @@ -2122,6 +2152,7 @@ export class TrainSchedulingService { await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); + void this.emitWindowState(scheduleId); } /** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */ @@ -2462,6 +2493,8 @@ export class TrainSchedulingService { } }); + // Window retired (DONE) — remove the card from portal/GL lists right away. + void this.emitWindowState(id); return this.getTrainScheduleById(id); } @@ -2758,7 +2791,16 @@ export class TrainSchedulingService { take: 1, }); return rows[0] ?? null; - } catch { + } catch (err) { + // A read failure here silently downgrades every booking window to the + // hardcoded defaults (desk 8–17, duration 3h, lead 3) while the settings + // UI keeps showing the saved row — a maddening mismatch. The usual cause + // is a missing column (migrations not run on this database). Scream. + this.logger.error( + `Failed to read train-scheduling global rules — booking windows are ` + + `running on HARDCODED DEFAULTS (8–17). Run pending migrations. ` + + `Cause: ${(err as Error).message}`, + ); return null; } } @@ -3787,7 +3829,18 @@ export class TrainSchedulingService { order: { scheduledDepartureDate: 'ASC' }, }); + // A train that has already departed can never be booked, even if the window + // engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the + // `scheduled_departure_date >= now()` guard the booking-window SQL uses so a + // past-departure schedule never leaks into the portal day pool, the schedule + // calendar, or the ET GL create-booking gate. + const now = new Date(); return schedules + .filter( + (s) => + s.scheduledDepartureDate != null && + s.scheduledDepartureDate > now, + ) .filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status)) .filter((s) => { // Build the full stop list: origin -> milestones (ordered) -> destination diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index cb379890a..6b8529225 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -1,30 +1,20 @@ import { Injectable, Logger } from "@nestjs/common"; import { + Application, Organization, OrganizationConfiguration, Permission, - Position, - PositionPermission, - PositionType, - Role, - RolePermission, Unit, } from "@tria-plc/iamapi-common"; -import { DataSource, EntityManager, In } from "typeorm"; +import { DataSource, EntityManager } from "typeorm"; -import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; -import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; import { - EDR_FREIGHT_POSITIONS, - EDR_FREIGHT_ROLES, - type FreightSeedPosition, - type FreightSeedRole, + EDR_FREIGHT_APPLICATION, + EDR_FREIGHT_PERMISSIONS, } from "./edr-freight.seed"; -const EDR_UNIT_KEY = "edr_freight_hq"; -const EDR_UNIT_NAME = { en: "EDR Freight HQ" }; -const EDR_POSITION_TYPE_KEY = "edr_freight_role"; -const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" }; +const EDR_UNIT_KEY = "edr_freight_app"; +const EDR_UNIT_NAME = { en: "EDR Freight App" }; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; @@ -51,22 +41,15 @@ export class EdrOrgSeeder { const organization = await this.ensureOrganization(manager); await this.ensureOrganizationConfiguration(manager, organization.id); - await this.ensureRoles(manager, EDR_FREIGHT_ROLES); - await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); - await this.ensureSuperAdminPermissions(manager); + await this.ensureDefaultUnit(manager, organization.id); - // Positions-as-roles: seed operational positions and grant their - // permissions via PositionPermission (not Role/RolePermission). - const unit = await this.ensureDefaultUnit(manager, organization.id); - const positionType = await this.ensureDefaultPositionType(manager, unit.id); - await this.ensurePositions( - manager, - organization.id, - unit.id, - positionType.id, - EDR_FREIGHT_POSITIONS, - ); - await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS); + const application = await this.ensureApplication(manager); + await this.ensurePermissions(manager, application.id); + + // Roles, positions and their permission links are intentionally NOT + // seeded for now — only the application-scoped permission catalog, + // mirroring how the default IAM seed relates permissions to their + // application. Grants are assigned later through the IAM UI. }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -128,111 +111,6 @@ export class EdrOrgSeeder { ); } - private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) { - await manager.getRepository(Role).upsert( - seedRoles.map(({ key, name }) => ({ key, name })), - { - conflictPaths: { key: true }, - }, - ); - - this.logger.log( - `Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`, - ); - } - - private async ensureRolePermissions( - manager: EntityManager, - seedRoles: FreightSeedRole[], - ) { - const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))]; - - if (!permissionKeys.length) { - this.logger.log("No EDR role permissions configured; skipping role-permission links"); - return; - } - - const roleRepository = manager.getRepository(Role); - const rolePermissionRepository = manager.getRepository(RolePermission); - - const roles = await roleRepository.find({ - where: { key: In(seedRoles.map((role) => role.key)) }, - select: { id: true, key: true }, - }); - const seededPermissions = await manager.getRepository(Permission).find({ - where: { key: In(permissionKeys) }, - select: { id: true, key: true }, - }); - - const roleByKey = new Map(roles.map((role) => [role.key, role])); - const permissionByKey = new Map( - seededPermissions.map((permission) => [permission.key, permission]), - ); - - const rolePermissions = seedRoles.flatMap((role) => { - const seededRole = roleByKey.get(role.key); - - if (!seededRole) { - throw new Error(`missing_role:${role.key}`); - } - - return role.permissionKeys.map((permissionKey) => { - const seededPermission = permissionByKey.get(permissionKey); - - if (!seededPermission) { - throw new Error(`missing_permission:${permissionKey}`); - } - - return { - roleId: seededRole.id, - permissionId: seededPermission.id, - }; - }); - }); - - await rolePermissionRepository.upsert(rolePermissions, { - conflictPaths: { roleId: true, permissionId: true }, - }); - - this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`); - } - - private async ensureSuperAdminPermissions(manager: EntityManager) { - const role = await manager.getRepository(Role).findOne({ - where: { key: ERoleKey.SUPER_ADMIN }, - select: { id: true, key: true }, - }); - - if (!role) { - this.logger.warn( - `Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`, - ); - return; - } - - const permissions = await manager.getRepository(Permission).find({ - where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) }, - select: { id: true, key: true }, - }); - - if (!permissions.length) { - this.logger.warn('No booking/rule-engine permissions found for super_admin'); - return; - } - - await manager.getRepository(RolePermission).upsert( - permissions.map((permission) => ({ - roleId: role.id, - permissionId: permission.id, - })), - { conflictPaths: { roleId: true, permissionId: true } }, - ); - - this.logger.log( - `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, - ); - } - private async ensureDefaultUnit( manager: EntityManager, organizationId: string, @@ -258,120 +136,51 @@ export class EdrOrgSeeder { return { id: unit.id }; } - private async ensureDefaultPositionType( + private async ensureApplication( manager: EntityManager, - unitId: string, ): Promise<{ id: string }> { - const positionTypeRepository = manager.getRepository(PositionType); + const applicationRepository = manager.getRepository(Application); - // PositionType has no unique constraint on (key, unitId); find-then-insert. - let positionType = await positionTypeRepository.findOne({ - where: { key: EDR_POSITION_TYPE_KEY, unitId }, + const application = await applicationRepository.findOne({ + where: { key: EDR_FREIGHT_APPLICATION.key }, select: { id: true }, }); - if (!positionType) { - const insertResult = await positionTypeRepository.insert({ - key: EDR_POSITION_TYPE_KEY, - name: EDR_POSITION_TYPE_NAME, - isSystem: true, - unitId, + if (!application?.id) { + const insertResult = await applicationRepository.insert({ + id: EDR_FREIGHT_APPLICATION.id, + key: EDR_FREIGHT_APPLICATION.key, + name: { ...EDR_FREIGHT_APPLICATION.name }, }); - this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`); + this.logger.log(`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`); return { id: insertResult.identifiers[0]?.id as string }; } - this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`); - return { id: positionType.id }; + this.logger.log(`Ensured EDR application '${EDR_FREIGHT_APPLICATION.key}'`); + return { id: application.id }; } - private async ensurePositions( + private async ensurePermissions( manager: EntityManager, - organizationId: string, - unitId: string, - positionTypeId: string, - seedPositions: FreightSeedPosition[], + applicationId: string, ) { - await manager.getRepository(Position).upsert( - seedPositions.map(({ key, name, rank }) => ({ - key, - name, - rank, - organizationId, - unitId, - positionTypeId, + const permissionRepository = manager.getRepository(Permission); + + // Upsert by key so reruns are idempotent; applicationId ties every + // permission to the EDR Freight application (also backfills rows that + // were previously seeded without the relation). + await permissionRepository.upsert( + EDR_FREIGHT_PERMISSIONS.map((permission) => ({ + id: permission.id, + key: permission.key, + name: { ...permission.name }, + applicationId, })), - { - conflictPaths: { key: true, unitId: true }, - }, + { conflictPaths: { key: true } }, ); this.logger.log( - `Ensured ${seedPositions.length} EDR positions '${seedPositions - .map((position) => position.key) - .join("', '")}'`, - ); - } - - private async ensurePositionPermissions( - manager: EntityManager, - unitId: string, - seedPositions: FreightSeedPosition[], - ) { - const permissionKeys = [ - ...new Set(seedPositions.flatMap((position) => position.permissionKeys)), - ]; - - if (!permissionKeys.length) { - this.logger.log( - "No EDR position permissions configured; skipping position-permission links", - ); - return; - } - - const positions = await manager.getRepository(Position).find({ - where: { key: In(seedPositions.map((position) => position.key)), unitId }, - select: { id: true, key: true }, - }); - const seededPermissions = await manager.getRepository(Permission).find({ - where: { key: In(permissionKeys) }, - select: { id: true, key: true }, - }); - - const positionByKey = new Map( - positions.map((position) => [position.key, position]), - ); - const permissionByKey = new Map( - seededPermissions.map((permission) => [permission.key, permission]), - ); - - const positionPermissions = seedPositions.flatMap((position) => { - const seededPosition = positionByKey.get(position.key); - - if (!seededPosition) { - throw new Error(`missing_position:${position.key}`); - } - - return position.permissionKeys.map((permissionKey) => { - const seededPermission = permissionByKey.get(permissionKey); - - if (!seededPermission) { - throw new Error(`missing_permission:${permissionKey}`); - } - - return { - positionId: seededPosition.id as string, - permissionId: seededPermission.id, - }; - }); - }); - - await manager.getRepository(PositionPermission).upsert(positionPermissions, { - conflictPaths: { positionId: true, permissionId: true }, - }); - - this.logger.log( - `Ensured ${positionPermissions.length} EDR position-permission links`, + `Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`, ); } } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 263e19af0..06454e885 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -590,6 +590,9 @@ const DashboardShell = () => { return ( void; mobileOpened: boolean; onToggleMobile: () => void; + /** Hide the mobile burger when the shell has no sidebar to open. */ + hideSidebarBurger?: boolean; } // Every header control is a consistent 36px frosted chip — same language as the @@ -57,6 +59,7 @@ const FreightDashboardHeader = ({ onToggleTheme, mobileOpened, onToggleMobile, + hideSidebarBurger = false, }: FreightDashboardHeaderProps) => { const navigate = useNavigate(); @@ -88,13 +91,15 @@ const FreightDashboardHeader = ({ {/* Left: burger (mobile) + search — the search now occupies the slot the page title used to hold; each page owns its own title. */} - + {!hideSidebarBurger && ( + + )} void; headerRight?: ReactNode; @@ -36,6 +38,7 @@ export interface FreightDashboardLayoutProps { const FreightDashboardLayout = ({ sidebarSections, + hideSidebar = false, activeHref = "", onNavigate, headerRight, @@ -75,11 +78,17 @@ const FreightDashboardLayout = ({ padding={0} className="bg-edr-bg" header={{ height: HEADER_HEIGHT }} - navbar={{ - width: NAVBAR_WIDTH, - breakpoint: "sm", - collapsed: { mobile: !mobileOpened }, - }} + // When the sidebar is hidden the navbar slot is dropped entirely so Main + // spans the full viewport width (GL clearance-only users). + navbar={ + hideSidebar + ? undefined + : { + width: NAVBAR_WIDTH, + breakpoint: "sm", + collapsed: { mobile: !mobileOpened }, + } + } > - + {!hideSidebar && ( + + )} {/* Internal scroll keeps the fixed-viewport model the dashboard pages