diff --git a/.gitignore b/.gitignore index ca2a5b7af..21edddcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,11 @@ coverage/ \#*\# .\#* docker-compose.override.yml + +# cypress e2e artifacts +e2e/**/cypress/videos/ +e2e/**/cypress/screenshots/ +e2e/**/cypress/downloads/ + +# e2e launcher state (ports of the running stack) +e2e/freight/.e2e-ports.json diff --git a/apps/edr-freight-api/src/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts new file mode 100644 index 000000000..2f5288048 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-financials.util.ts @@ -0,0 +1,61 @@ +import { DataSource } from 'typeorm'; + +type MileRecord = { + bookingId?: string | null; + advancedPayment?: number | string | null; + booking?: { + cargoTotalWeightVgm?: number | string | null; + bookingContainers?: Array<{ + units?: Array<{ vgmTons?: number | string | null }> | null; + }> | null; + } | null; +}; + +/** + * Display enrichment for first/last-mile lists (Assign Vehicle modal etc.): + * - Advance payment: mile records are created with advanced_payment 0 — the + * real advance is the FIRST_MILE/LAST_MILE line the customer already paid + * on the booking invoice. + * - Cargo tons: container bookings often carry tonnage on the per-unit VGMs + * while cargo_total_weight_vgm stays 0 — fall back to the summed units. + * Fills both in-memory on the loaded records; nothing is persisted. + */ +export async function attachMileFinancials( + dataSource: DataSource, + records: MileRecord[], + chargeType: 'FIRST_MILE' | 'LAST_MILE', +): Promise { + for (const r of records) { + const b = r.booking; + if (!b || Number(b.cargoTotalWeightVgm) > 0) continue; + const unitTons = (b.bookingContainers ?? []).reduce( + (sum, bc) => + sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0), + 0, + ); + if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3)); + } + + const needAdvance = records.filter( + (r) => r.bookingId && !(Number(r.advancedPayment) > 0), + ); + if (!needAdvance.length) return; + + const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query( + `SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount + FROM freight.invoice_lines il + JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL + WHERE i.source = 'booking' + AND i.status = 'PAID' + AND i.source_id = ANY($1::text[]) + AND il.charge_type = $2 + AND il.deleted_at IS NULL + GROUP BY i.source_id`, + [needAdvance.map((r) => r.bookingId), chargeType], + ); + const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)])); + for (const r of needAdvance) { + const paid = byBooking.get(r.bookingId as string); + if (paid) r.advancedPayment = paid; + } +} diff --git a/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts new file mode 100644 index 000000000..c8f48af05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck EDR last-mile handovers. `truck_assignment_id` FKs + * customer_truck_assignments (self-haul only), so EDR trucks need their own + * link to the last-mile vehicle assignment that hauled the goods. Generated + * when the EDR truck exits the warehouse (with its exit paper) and signed by + * the customer in the portal — one per truck, or booking-level (both ids null) + * when the truck cannot be resolved. + */ +export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface { + name = 'AddHandoverEdrAssignment2440000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + ADD COLUMN IF NOT EXISTS edr_assignment_id uuid + REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck" + ON freight.booking_handovers (booking_id, edr_assignment_id) + WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`, + ); + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 1896034cc..36705daa0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -7,6 +7,7 @@ import { Logger, Optional, } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -349,6 +350,22 @@ export class BookingTransitionService { return fresh; } + /** + * Import EDR last-mile: every handover signed + every truck departed ⇒ the + * warehouses module delivered the goods and asks the booking to complete. + * Best-effort — a booking already COMPLETED (or not yet in transit) just logs. + */ + @OnEvent('import.handover.completed') + async onImportHandoverCompleted(payload: { bookingId: string }): Promise { + try { + await this.complete(payload.bookingId); + } catch (err) { + this.logger.log( + `Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`, + ); + } + } + async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]); 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 d9dd53f94..393f892f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1228,9 +1228,9 @@ export class BookingsService { /** * Batched version of the findById flag: marks each page item whose booking - * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal - * dashboard) can show "Approve delivery" for exactly the generated→signed - * window. One query for the whole page. + * has a generated-but-unsigned handover (self-haul or EDR last-mile), so list + * rows (portal dashboard) can show "Approve delivery" for exactly the + * generated→signed window. One query for the whole page. */ private async attachHandoverFlags(bookings: Booking[]): Promise { const ids = bookings.map((b) => b.id); @@ -1239,8 +1239,7 @@ export class BookingsService { `SELECT DISTINCT booking_id AS "bookingId" FROM freight.booking_handovers WHERE booking_id = ANY($1::uuid[]) - AND signed_at IS NULL AND deleted_at IS NULL - AND mile_type = 'SELF_HAUL'`, + AND signed_at IS NULL AND deleted_at IS NULL`, [ids], ); const pending = new Set(rows.map((r) => r.bookingId)); @@ -1583,14 +1582,12 @@ export class BookingsService { schedule?.status ?? null; } - // A generated-but-unsigned SELF_HAUL handover means the customer must approve - // delivery from the portal (booking-based, one per booking). EDR last-mile - // handovers are per delivering truck and signed by the receiver at the door, - // so they never surface the portal "Approve delivery" action. + // A generated-but-unsigned handover means the customer must approve delivery + // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: + // per delivering truck (generated on truck exit), signed one by one. const [pendingHandover] = await this.dataSource.query( `SELECT 1 FROM freight.booking_handovers WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL - AND mile_type = 'SELF_HAUL' LIMIT 1`, [id], ); diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 0143f0796..948853e22 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { attachMileFinancials } from '../../common/mile-financials.util'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { BookingsRepository } from "../bookings/bookings.repository"; import { DriversService } from "../drivers/drivers.service"; @@ -66,6 +67,7 @@ export class FirstMileService { for (const r of records) { (r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await attachMileFinancials(this.dataSource, records, 'FIRST_MILE'); } /** Resolve a vehicle's driver + human labels, for stamping mile events onto diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index b31a2ecbb..601e03aec 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -12,6 +12,7 @@ import { SELF_HAUL_CONFLICT_MESSAGE, usesEdrMileService, } from '../../common/mile-haulage.util'; +import { attachMileFinancials } from '../../common/mile-financials.util'; import { assertBulkTonnageRemains, assertTruckCountWithinContainers, @@ -88,6 +89,7 @@ export class LastMileService { for (const r of records) { (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await attachMileFinancials(this.dataSource, records, 'LAST_MILE'); } /** Resolve a vehicle's driver + human labels, for stamping mile events onto @@ -333,6 +335,8 @@ export class LastMileService { driverPhone: string | null; truckType: string | null; containerNumber: string | null; + arrivedAt: string | null; + departedAt: string | null; }> > { const [lm] = await this.lastMileRepository.findAll({ @@ -347,9 +351,11 @@ export class LastMileService { ? lm.vehicleAssignments.map((va) => ({ vehicle: va.vehicle, containerNumber: va.containerNumber ?? null, + arrivedAt: va.arrivedAt ?? null, + departedAt: va.departedAt ?? null, })) : lm.vehicle - ? [{ vehicle: lm.vehicle, containerNumber: null }] + ? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }] : []; const out: Array<{ @@ -361,8 +367,10 @@ export class LastMileService { driverPhone: string | null; truckType: string | null; containerNumber: string | null; + arrivedAt: string | null; + departedAt: string | null; }> = []; - for (const { vehicle, containerNumber } of sources) { + for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) { if (!vehicle) continue; let driverName = vehicle.assignedDriverName ?? null; let driverLicense: string | null = null; @@ -386,6 +394,8 @@ export class LastMileService { driverPhone, truckType: vehicle.vehicleType || null, containerNumber, + arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null, + departedAt: departedAt ? new Date(departedAt).toISOString() : null, }); } return out; diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts new file mode 100644 index 000000000..5696f00cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts @@ -0,0 +1,61 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateVehicleDto } from './create-vehicle.dto'; + +const base = { + vehicleType: 'TRUCK', + manufacturer: 'IVECO', + model: 'HYT', + year: 2020, + fuelType: 'DIESEL', + capacity: 0, + status: 'ACTIVE', +}; + +const errorsFor = (over: Record) => + validate(plainToInstance(CreateVehicleDto, { ...base, ...over })); + +const plateErrors = ( + errors: Awaited>, + property: string, +) => errors.find((e) => e.property === property && e.constraints?.matches); + +describe('CreateVehicleDto — plate format', () => { + it('accepts a plate like ET-9875', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'ET-9875' }), 'plateNumber')).toBeUndefined(); + }); + + it('accepts a plate like AA-8642', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'AA-8642' }), 'plateNumber')).toBeUndefined(); + }); + + it('upper-cases a lower-case plate before validating', async () => { + const dto = plainToInstance(CreateVehicleDto, { ...base, plateNumber: 'et-9875' }); + expect(dto.plateNumber).toBe('ET-9875'); + expect(plateErrors(await validate(dto), 'plateNumber')).toBeUndefined(); + }); + + it('rejects a free-text plate like assadasd', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'assadasd' }), 'plateNumber')).toBeDefined(); + }); + + it('rejects a plate with no letters or no digits', async () => { + expect(plateErrors(await errorsFor({ plateNumber: '1234' }), 'plateNumber')).toBeDefined(); + expect(plateErrors(await errorsFor({ plateNumber: 'ABCD' }), 'plateNumber')).toBeDefined(); + }); + + it('rejects a bad trailer plate but allows a valid one', async () => { + expect( + plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'asdasdasda' }), 'trailerPlateNo'), + ).toBeDefined(); + expect( + plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'AA-8642' }), 'trailerPlateNo'), + ).toBeUndefined(); + }); + + it('allows an empty trailer plate (optional)', async () => { + const errors = await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: '' }); + expect(plateErrors(errors, 'trailerPlateNo')).toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 33d441ecb..4dda3c053 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,7 +1,30 @@ -import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator'; +import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator'; +import { Transform } from 'class-transformer'; import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; +/** + * A vehicle plate is two or three letters, a hyphen, then two to six digits — + * e.g. ET-9875 or AA-8642. Kept in one place so plate, power-plate and trailer + * all match and the message stays consistent. + */ +export const VEHICLE_PLATE_REGEX = /^[A-Z]{2,3}-\d{2,6}$/; +export const VEHICLE_PLATE_MESSAGE = + 'must be letters and numbers like ET-9875 or AA-8642'; + +/** + * Trim and upper-case a plate before validating, so "et-9875" is accepted. An + * empty optional plate (trailer/power) becomes undefined so @IsOptional skips it + * rather than failing the pattern. + */ +const normalizePlate = ({ value }: { value: unknown }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim().toUpperCase(); + return trimmed === '' ? undefined : trimmed; +}; + export class CreateVehicleDto { + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() plateNumber!: string; @@ -47,10 +70,14 @@ export class CreateVehicleDto { code?: string; @IsOptional() + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Power plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() powerPlateNo?: string; @IsOptional() + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Trailer plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() trailerPlateNo?: string; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 49fb22fde..f86261d3c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator'; import { WAREHOUSE_INVENTORY_STATUSES, @@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto { @IsOptional() @IsString() dateTo?: string; + + // ── KPI drill-down filters ────────────────────────────────────────────── + // Each mirrors one opsStats() counter so a dashboard card's count always + // equals the length of the list it opens. + + @ApiPropertyOptional({ type: Boolean, description: 'Only items received (created) today' }) + @IsOptional() + @Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1')) + @IsBoolean() + receivedToday?: boolean; + + @ApiPropertyOptional({ type: Boolean, description: 'Only RECEIVED items with no inspection yet' }) + @IsOptional() + @Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1')) + @IsBoolean() + pendingInspection?: boolean; + + @ApiPropertyOptional({ type: Number, minimum: 1, description: 'Only in-warehouse items older than N days' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsInt() + @Min(1) + agingOverDays?: number; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts index a0a7ad70f..c90fb5a16 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -8,8 +8,9 @@ export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number]; * One import handover. A booking has a single handover when one truck takes the * whole booking (`truckAssignmentId` null = per-booking), or one per truck when * multiple trucks are used. Self-haul handovers are generated on truck arrival - * and signed before the truck leaves; EDR last-mile handovers are generated at - * delivery (after exit). + * and signed before the truck leaves; EDR last-mile handovers are generated + * when the EDR truck exits the warehouse (with its exit paper) and signed by + * the customer in the portal on delivery — one signature per truck. */ @Entity({ schema: 'freight', name: 'booking_handovers' }) @Index(['bookingId']) @@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity { @Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true }) truckAssignmentId?: string | null; + /** EDR last-mile vehicle assignment this handover belongs to; null = per-booking. */ + @Column({ name: 'edr_assignment_id', type: 'uuid', nullable: true }) + edrAssignmentId?: string | null; + /** Denormalised plate for display / EDR trucks (which aren't customer trucks). */ @Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true }) truckPlate?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts b/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts new file mode 100644 index 000000000..f89d987ce --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts @@ -0,0 +1,66 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +// Exercises the per-truck [Exit Inspection] block helpers directly (no DI). +const svc = Object.create(WarehouseInventoryService.prototype) as any; + +const arrivalA = + '[Exit Inspection]\nTruck Plate: 3-15288/56858\nDriver: Abebe Lemeno\nGate In Time: 2026-07-21T08:00:00.000Z\nTare Weight: 12 t'; +const arrivalB = + '[Exit Inspection]\nTruck Plate: 3-85957/48562\nDriver: Suleman Tamrat\nGate In Time: 2026-07-21T09:00:00.000Z\nWeighing: SKIPPED'; + +describe('per-truck exit inspection blocks', () => { + it('keeps truck A intact when truck B arrives', () => { + const afterA = svc.replaceExitInspectionNote('Receive note', arrivalA, '3-15288/56858'); + const afterB = svc.replaceExitInspectionNote(afterA, arrivalB, '3-85957/48562'); + expect(afterB).toContain('Abebe Lemeno'); + expect(afterB).toContain('Suleman Tamrat'); + expect(afterB.match(/\[Exit Inspection\]/g)).toHaveLength(2); + expect(afterB.startsWith('Receive note')).toBe(true); + }); + + it("exit for truck A updates only A's block and preserves arrival data", () => { + const notes = svc.replaceExitInspectionNote( + svc.replaceExitInspectionNote(null, arrivalA, '3-15288/56858'), + arrivalB, + '3-85957/48562', + ); + const dto = svc.preserveTruckArrivalForExit( + { truckPlateNumber: '3-15288/56858', grossWeight: 40, gateOutTime: '2026-07-21T12:00:00.000Z' }, + notes, + ); + expect(dto.driverName).toBe('Abebe Lemeno'); + expect(dto.tareWeight).toBe(12); + expect(dto.weighingSkipped).toBeUndefined(); + const exitNote = svc.buildExitInspectionNote(dto); + const replaced = svc.replaceExitInspectionNote(notes, exitNote, dto.truckPlateNumber); + expect(replaced).toContain('Gross Weight: 40 t'); + expect(replaced).toContain('Net Weight: 28 t'); + expect(replaced).toContain('Suleman Tamrat'); // B untouched + expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(2); + }); + + it('skipped weighing records the container-derived net in the note', () => { + const dto = { + truckPlateNumber: '3-85957/48562', + driverName: 'Suleman Tamrat', + weighingSkipped: true, + netWeight: 27.5, + gateInTime: '2026-07-21T09:00:00.000Z', + gateOutTime: '2026-07-21T13:00:00.000Z', + }; + const note = svc.buildExitInspectionNote(dto); + expect(note).toContain('Weighing: SKIPPED'); + expect(note).toContain('Net Weight: 27.5 t'); + }); + + it('matches a legacy comma-joined plate list and keeps foreign notes', () => { + const legacy = + 'Receive note\n\n[Exit Inspection]\nTruck Plate: 3-15288/56858, 3-85957/48562\nDriver: Abebe Lemeno\nTare Weight: 12 t\nCUSTOMER_DELIVERY_APPROVAL:{"ok":true}'; + const block = svc.extractExitInspectionForPlate(legacy, '3-15288/56858'); + expect(block).toContain('Abebe Lemeno'); + const replaced = svc.replaceExitInspectionNote(legacy, arrivalA, '3-15288/56858'); + expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(1); + expect(replaced).toContain('CUSTOMER_DELIVERY_APPROVAL:{"ok":true}'); + expect(replaced).toContain('Receive note'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index d50b27150..81f83a57a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; -import { DataSource, EntityManager, IsNull } from 'typeorm'; +import { DataSource, EntityManager, IsNull, Repository } from 'typeorm'; -import { BookingHandover } from './entities/booking-handover.entity'; +import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; @@ -12,7 +12,10 @@ import { sendCompanyChannels } from '../notifications/notify-company.util'; * Import handover records. A booking has one handover per truck (single truck ⇒ * one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type: * - SELF_HAUL: generated when the customer truck arrives, signed before it leaves. - * - EDR_LAST_MILE: generated at delivery (after exit). + * - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its + * exit paper), signed by the customer in the portal per truck; once every + * handover is signed the delivery auto-completes (inventory / cargo / + * booking → delivered). */ @Injectable() export class HandoverService { @@ -25,14 +28,22 @@ export class HandoverService { ) {} /** Tell the customer a handover is ready and needs their signature. */ - private async notifySignNeeded(bookingId: string, reference: string): Promise { + private async notifySignNeeded( + bookingId: string, + reference: string, + opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {}, + ): Promise { try { const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); if (!b?.companyId) return; - const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; + const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''; + const body = + opts.mileType === 'EDR_LAST_MILE' + ? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.` + : `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; await this.inbox.notify({ recipients: { companyId: b.companyId }, audience: NotificationAudience.PORTAL, @@ -117,31 +128,98 @@ export class HandoverService { return saved; } - /** - * EDR last-mile: generate a handover at delivery (after exit). One per EDR - * truck (by plate) or per booking. Idempotent by (booking, plate). - */ - async ensureAtDelivery( + /** Find an existing EDR handover by assignment, else by plate, else booking-level. */ + private async findEdrHandover( + repo: Repository, bookingId: string, - opts: { truckPlate?: string | null; truckAssignmentId?: string | null }, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, + ): Promise { + if (opts.edrAssignmentId) { + const byAssignment = await repo.findOne({ + where: { bookingId, edrAssignmentId: opts.edrAssignmentId }, + }); + if (byAssignment) return byAssignment; + } + if (opts.truckPlate) { + return repo.findOne({ + where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate }, + }); + } + return repo.findOne({ + where: { + bookingId, + mileType: 'EDR_LAST_MILE', + truckPlate: IsNull(), + edrAssignmentId: IsNull(), + }, + }); + } + + /** + * EDR last-mile: generate the handover when the EDR truck exits the warehouse + * (alongside its exit paper) and ask the customer to sign it from the portal. + * One per truck (multiple trucks ⇒ one each) or booking-level when the truck + * cannot be resolved. Idempotent by (booking, assignment) / (booking, plate). + */ + async ensureForDepartedEdrTruck( + bookingId: string, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, manager?: EntityManager, ): Promise { const m = manager ?? this.dataSource.manager; const repo = m.getRepository(BookingHandover); - const existing = await repo.findOne({ - where: { - bookingId, - truckPlate: opts.truckPlate ?? IsNull(), - truckAssignmentId: opts.truckAssignmentId ?? IsNull(), - }, - }); + const existing = await this.findEdrHandover(repo, bookingId, opts); if (existing) return existing; const reference = await this.generateReference(bookingId, m); - return repo.save( + const saved = await repo.save( repo.create({ bookingId, - truckAssignmentId: opts.truckAssignmentId ?? null, + edrAssignmentId: opts.edrAssignmentId ?? null, + truckPlate: opts.truckPlate ?? null, + mileType: 'EDR_LAST_MILE', + reference, + generatedAt: new Date(), + }), + ); + this.logger.log( + `EDR handover ${reference} generated on truck exit for booking ${bookingId}` + + (opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''), + ); + void this.notifySignNeeded(bookingId, reference, { + mileType: 'EDR_LAST_MILE', + truckPlate: opts.truckPlate, + }); + return saved; + } + + /** + * EDR last-mile: ensure a handover exists at delivery and stamp delivered_at. + * Normally the handover was already generated on truck exit — this only fills + * the delivery timestamp; a handover is created here only for legacy flows + * where the exit was recorded before this feature existed. + */ + async ensureAtDelivery( + bookingId: string, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const repo = m.getRepository(BookingHandover); + const existing = await this.findEdrHandover(repo, bookingId, opts); + if (existing) { + if (!existing.deliveredAt) { + existing.deliveredAt = new Date(); + await repo.save(existing); + } + return existing; + } + + const reference = await this.generateReference(bookingId, m); + const saved = await repo.save( + repo.create({ + bookingId, + edrAssignmentId: opts.edrAssignmentId ?? null, truckPlate: opts.truckPlate ?? null, mileType: 'EDR_LAST_MILE', reference, @@ -149,6 +227,25 @@ export class HandoverService { deliveredAt: new Date(), }), ); + void this.notifySignNeeded(bookingId, reference, { + mileType: 'EDR_LAST_MILE', + truckPlate: opts.truckPlate, + }); + return saved; + } + + /** Re-send the sign notification for every unsigned handover on the booking. */ + async notifyUnsignedForBooking(bookingId: string): Promise { + const unsigned = await this.dataSource.getRepository(BookingHandover).find({ + where: { bookingId, signedAt: IsNull() }, + order: { generatedAt: 'ASC' }, + }); + for (const h of unsigned) { + await this.notifySignNeeded(bookingId, h.reference, { + mileType: h.mileType, + truckPlate: h.truckPlate, + }); + } } /** @@ -182,6 +279,27 @@ export class HandoverService { } } + /** + * Sign one handover (EDR last-mile: the customer signs per truck). Returns the + * fresh handover; idempotent — an already-signed handover is returned as-is. + */ + async sign( + handoverId: string, + userId?: string | null, + signerName?: string | null, + ): Promise { + const repo = this.dataSource.getRepository(BookingHandover); + const handover = await repo.findOne({ where: { id: handoverId } }); + if (!handover) { + throw new NotFoundException(`Handover ${handoverId} not found`); + } + if (handover.signedAt) return handover; + handover.signedAt = new Date(); + handover.signedByUserId = userId ?? null; + handover.signerName = signerName?.trim() || null; + return repo.save(handover); + } + /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ async signForBooking( bookingId: string, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 2373cb6d8..acd31bd51 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -486,6 +486,21 @@ export class WarehouseInventoryController { return this.handoverService.list(bookingId); } + @Post('handovers/:handoverId/sign') + @ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' }) + signHandover( + @Param('handoverId', ParseUUIDPipe) handoverId: string, + @Body() dto: ApproveDeliveryDto, + @Request() req: { user?: { id?: string; sub?: string } }, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.signHandover( + handoverId, + user?.id ?? req.user?.id ?? req.user?.sub, + dto.signerName, + ); + } + @Post('bookings/:bookingId/request-handover-signature') @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { @@ -513,9 +528,16 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/handover-document') - @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) - async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { - const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId); + @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' }) + async bookingHandoverDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Res() res: Response, + @Query('handoverId') handoverId?: string, + ) { + const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking( + bookingId, + handoverId || undefined, + ); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `inline; filename="${filename}"`); res.setHeader('Content-Length', buffer.length); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 96122d9bd..50c71f485 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1,6 +1,19 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + Between, + DataSource, + EntityManager, + FindManyOptions, + FindOptionsWhere, + ILike, + In, + IsNull, + LessThanOrEqual, + MoreThanOrEqual, + Raw, +} from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { generateGrnNumber } from '../../common/grn.util'; @@ -13,6 +26,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; +import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; import { @@ -68,6 +82,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) => const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:'; const HANDOVER_DOCUMENT_MARKER = '[Handover Document]'; +const EXIT_INSPECTION_MARKER = '[Exit Inspection]'; export interface InventoryInquiryResult { id: string; @@ -396,6 +411,7 @@ export class WarehouseInventoryService { private readonly signatures: SignaturesService, private readonly handover: HandoverService, private readonly inbox: NotificationInboxService, + private readonly events: EventEmitter2, ) {} /** @@ -508,12 +524,20 @@ export class WarehouseInventoryService { WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday", (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection", - (SELECT count(*)::int FROM freight.customer_truck_assignments - WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite", + -- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite() + ((SELECT count(*)::int FROM freight.customer_truck_assignments a + JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL + WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL) + + + (SELECT count(*)::int FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL + JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL + WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite", (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL - AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') + AND status = ANY($1) AND created_at < now() - interval '7 days') AS "itemsAging"`, + [this.IN_WAREHOUSE_STATUSES], ); return { receivedToday: row?.receivedToday ?? 0, @@ -525,7 +549,7 @@ export class WarehouseInventoryService { } /** In-warehouse statuses used by the dwell / aging metrics. */ - private readonly IN_WAREHOUSE_STATUSES = [ + private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [ 'RECEIVED', 'UNLOADED', 'STORED', @@ -953,7 +977,7 @@ export class WarehouseInventoryService { ? LessThanOrEqual(new Date(filter.dateTo)) : undefined; - const base = { + const base: FindOptionsWhere = { ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), ...(filter.yardId ? { yardId: filter.yardId } : {}), ...(filter.zoneId ? { zoneId: filter.zoneId } : {}), @@ -967,6 +991,24 @@ export class WarehouseInventoryService { ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; + // KPI drill-down filters — predicates mirror opsStats() exactly so the + // dashboard card's count equals the length of the list it opens. + if (filter.receivedToday) { + base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`); + } + if (filter.pendingInspection) { + base.status = 'RECEIVED'; + base.inspectionStatus = IsNull(); + } + if (filter.agingOverDays) { + if (!filter.status && !filter.pendingInspection) { + base.status = In(this.IN_WAREHOUSE_STATUSES); + } + base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, { + days: filter.agingOverDays, + }); + } + const search = filter.search?.trim(); const where: FindManyOptions['where'] = search ? [ @@ -2972,12 +3014,29 @@ export class WarehouseInventoryService { const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() : item.releaseDate ?? null; - const reference = isTruckLeaving - ? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)) - : dto.reference?.trim() || (await this.generateReleaseReference(item)); - const exitInspectionDto = isTruckLeaving - ? this.preserveTruckArrivalForExit(dto, item.notes) - : dto; + // One reference per item — the first truck's arrival mints it, later trucks + // (arrival or exit) reuse it so all exit papers share the release order. + const reference = + item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)); + const exitInspectionDto = { + ...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto), + }; + // Weighbridge skipped on exit: the recorded net still comes from what the + // truck is holding — the summed cargo weight of its selected containers. + if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) { + const selected = (exitInspectionDto.containerNumber ?? '') + .split(/[,;\n]+/) + .map((n) => n.trim()) + .filter(Boolean); + if (selected.length) { + const weights = await this.bookingContainerWeights(item.bookingId); + const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons])); + const heldTons = Number( + selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3), + ); + if (heldTons > 0) exitInspectionDto.netWeight = heldTons; + } + } const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto); // The load actually leaving on this truck, in TONNES (the weighing UI is in @@ -2990,12 +3049,46 @@ export class WarehouseInventoryService { ? Math.round((grossTons - tareTons) * 1000) / 1000 : (exitInspectionDto.netWeight ?? null); + // The weight to record on the inventory when this truck leaves: prefer the + // item's own container cargo weight (a truck may carry other items too); + // fall back to the truck's recorded net. Fills an empty weight only. + let recordedItemTons: number | null = null; + if (isTruckLeaving && netTons != null) { + recordedItemTons = netTons; + if (item.containerId && item.bookingId) { + const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`, + [item.containerId], + ); + const ownNumber = cont?.containerNumber?.trim().toUpperCase(); + if (ownNumber) { + const weights = await this.bookingContainerWeights(item.bookingId); + const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber); + if (own && own.weightTons > 0) recordedItemTons = own.weightTons; + } + } + } + await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { releaseDate, releaseOrderReference: reference, - notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), + notes: this.replaceExitInspectionNote( + item.notes, + exitInspectionNote, + exitInspectionDto.truckPlateNumber, + ), }); + // Even an unweighed truck records the inventory weight it is holding — + // without this the handover/exit papers print "0 t" for skipped weighings. + if (recordedItemTons != null) { + await manager.query( + `UPDATE freight.warehouse_inventory + SET weight = $2, updated_at = NOW() + WHERE id = $1 AND COALESCE(weight, 0) = 0`, + [id, recordedItemTons], + ); + } if (!isTruckLeaving && item.bookingId) { // Per-truck arrival: mark the customer truck carrying THIS item's // container as arrived (matched via the physical container number). @@ -3058,7 +3151,7 @@ export class WarehouseInventoryService { // EDR last-mile: this truck is leaving — record its exit and the load it // actually took. net_weight_tons drives the bulk drawdown (booking VGM // minus everything already hauled away). - await manager.query( + const [edrDeparted] = (await manager.query( `UPDATE freight.last_mile_vehicle_assignments va SET departed_at = COALESCE($3::timestamptz, NOW()), arrived_at = COALESCE(va.arrived_at, NOW()), @@ -3072,7 +3165,8 @@ export class WarehouseInventoryService { AND v.id = va.vehicle_id AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2)) AND va.departed_at IS NULL - AND va.deleted_at IS NULL`, + AND va.deleted_at IS NULL + RETURNING va.id`, [ item.bookingId, dto.truckPlateNumber.trim(), @@ -3080,7 +3174,31 @@ export class WarehouseInventoryService { grossTons, netTons, ], - ); + )) as [Array<{ id: string }>, unknown]; + // EDR last-mile: the handover is generated the moment the truck exits + // (with its exit paper) — one per truck — and the customer is asked to + // sign it from the portal. Booking-level fallback when the plate matched + // no live assignment (e.g. exit re-recorded) but the booking is EDR-hauled. + for (const row of edrDeparted) { + await this.handover.ensureForDepartedEdrTruck( + item.bookingId, + { truckPlate: dto.truckPlateNumber.trim(), edrAssignmentId: row.id }, + manager, + ); + } + if (!edrDeparted.length) { + const [lm]: Array<{ id: string }> = await manager.query( + `SELECT id FROM freight.last_mile WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`, + [item.bookingId], + ); + if (lm) { + await this.handover.ensureForDepartedEdrTruck( + item.bookingId, + { truckPlate: dto.truckPlateNumber.trim() }, + manager, + ); + } + } // Customer self-haul: the same exit record on the customer's own truck. // Without it a self-haul bulk booking never draws down — hauled tonnage // summed to zero and the booking could take unlimited trucks. Matched by @@ -3130,11 +3248,43 @@ export class WarehouseInventoryService { // the transaction and fire-and-forget: notifying must never fail the exit. if (isTruckLeaving && item.bookingId) { void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons); + } else if (item.bookingId) { + // Gate-in: same single-path hook for the arrival side (self-haul + EDR). + void this.notifyTruckArrival(item.bookingId, dto.truckPlateNumber?.trim() ?? null); } return this.findById(id); } + /** Best-effort truck-arrival notification (gate-in), mirror of the departure one. */ + private async notifyTruckArrival(bookingId: string, plateNumber: string | null): Promise { + try { + const [booking]: Array<{ companyId: string | null; reference: string | null }> = + await this.dataSource.query( + `SELECT company_id AS "companyId", reference + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking?.companyId) return; + const ref = booking.reference ?? bookingId; + const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck'; + const body = `${truck} has arrived at the warehouse for booking ${ref}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Truck arrived at the warehouse', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + /** * Best-effort truck-departure notification to the booking's company across * every channel: in-app (portal inbox) + SMS + email. Never throws — a missing @@ -3816,8 +3966,213 @@ export class WarehouseInventoryService { }; } - /** Handover PDF resolved by booking (for the portal, which only has bookingId). */ - async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Customer signs ONE handover from the portal (EDR last-mile: one per truck). + * When the last one is signed — and every EDR truck has left the warehouse — + * the delivery completes automatically: inventory + cargo delivered, last-mile + * leg DELIVERED (trucks freed), booking completed ("shipment delivered"). + */ + async signHandover( + handoverId: string, + userId?: string, + signerName?: string, + ): Promise<{ + handoverId: string; + bookingId: string; + signedAt: string | null; + signerDisplayName: string; + allSigned: boolean; + }> { + if (!userId) { + throw new BadRequestException('Authentication is required to sign the handover'); + } + const name = signerName?.trim(); + if (!name) { + throw new BadRequestException('Please enter your full name to sign the handover'); + } + + const [h]: Array<{ + bookingId: string; + reference: string; + truckPlate: string | null; + mileType: string; + edrAssignmentId: string | null; + }> = await this.dataSource.query( + `SELECT booking_id AS "bookingId", reference, truck_plate AS "truckPlate", + mile_type AS "mileType", edr_assignment_id AS "edrAssignmentId" + FROM freight.booking_handovers + WHERE id = $1 AND deleted_at IS NULL`, + [handoverId], + ); + if (!h) throw new NotFoundException(`Handover ${handoverId} not found`); + // Self-haul stays a single booking-level signature via approve-delivery, + // which also enforces inspection-passed + truck-arrived. Per-truck signing + // is an EDR last-mile flow only. + if (h.mileType !== 'EDR_LAST_MILE') { + throw new BadRequestException( + 'This handover is signed through Approve delivery, not per truck', + ); + } + + // Same gate as approve-delivery: storage/demurrage must be settled first. + const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( + `SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 1`, + [h.bookingId], + ); + if (inv) await this.invoices.assertClearanceAllowed(inv.id); + + const signed = await this.handover.sign(handoverId, userId, name); + const allSigned = await this.handover.isFullySigned(h.bookingId); + + if (inv) { + await this.activityLog.record({ + activityType: 'INVENTORY_RELEASED', + inventoryId: inv.id, + warehouseId: inv.warehouseId, + description: `Customer signed handover ${h.reference}${h.truckPlate ? ` (truck ${h.truckPlate})` : ''} as ${name}`, + performedBy: name, + }); + } + + // EDR last-mile delivers PER TRUCK: this signature confirms receipt of the + // goods THIS truck carried, so only its containers become DELIVERED now. + // (Self-haul keeps the single booking-level handover + manual Deliver.) + if (h.mileType === 'EDR_LAST_MILE') { + try { + await this.deliverEdrTruckContainers(h, name); + } catch (err) { + this.logger.warn( + `Per-truck auto-deliver after handover sign failed for ${h.bookingId}: ${(err as Error).message}`, + ); + } + } + + if (allSigned) { + void this.completeEdrDeliveryIfReady(h.bookingId, name).catch((err: Error) => + this.logger.warn(`Auto-complete after handover sign failed for ${h.bookingId}: ${err.message}`), + ); + } + + return { + handoverId, + bookingId: h.bookingId, + signedAt: signed.signedAt ? new Date(signed.signedAt).toISOString() : null, + signerDisplayName: name, + allSigned, + }; + } + + /** + * EDR last-mile auto-completion: once every handover is signed and every EDR + * truck has departed, deliver the remaining inventory, mark the last-mile leg + * DELIVERED and complete the booking. Self-haul bookings keep their manual + * Deliver flow (no last_mile record ⇒ no-op). + */ + private async completeEdrDeliveryIfReady(bookingId: string, signerName: string): Promise { + const [lm]: Array<{ id: string; status: string }> = await this.dataSource.query( + `SELECT id, status FROM freight.last_mile + WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!lm) return; + + const [pending]: Array<{ notDeparted: string }> = await this.dataSource.query( + `SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS "notDeparted" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL`, + [bookingId], + ); + if (Number(pending?.notDeparted ?? 0) > 0) return; + if (!(await this.handover.isFullySigned(bookingId))) return; + + const items: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND status = 'READY_FOR_PICKUP' AND deleted_at IS NULL`, + [bookingId], + ); + for (const it of items) { + try { + await this.deliver(it.id, { + receiverName: signerName, + remarks: 'Auto-delivered on customer handover signature', + performedBy: signerName, + } as DeliverInventoryDto); + } catch (err) { + this.logger.warn(`Auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`); + } + } + + if (lm.status !== 'DELIVERED') { + try { + await this.lastMileService.update(lm.id, { status: 'DELIVERED' } as UpdateLastMileDto); + } catch (err) { + this.logger.warn(`Auto-deliver of last-mile ${lm.id} failed: ${(err as Error).message}`); + } + } + + // Booking → COMPLETED ("shipment delivered" notification) — owned by the + // bookings module; evented to avoid a warehouses→bookings service dependency. + this.events.emit('import.handover.completed', { bookingId }); + } + + /** + * EDR last-mile per-truck delivery: the customer signed THIS truck's handover, + * so only the container items that truck carried become DELIVERED. Bulk cargo + * (no container rows) is delivered by completeEdrDeliveryIfReady once every + * truck is signed off. + */ + private async deliverEdrTruckContainers( + h: { bookingId: string; edrAssignmentId: string | null; truckPlate: string | null }, + signerName: string, + ): Promise { + if (!h.edrAssignmentId && !h.truckPlate) return; + const items: Array<{ id: string }> = await this.dataSource.query( + `SELECT DISTINCT inv.id + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id + JOIN freight.containers c + ON c.container_number = COALESCE(vc.container_number, va.container_number) + AND c.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.container_id = c.id AND inv.booking_id = l.booking_id AND inv.deleted_at IS NULL + WHERE l.booking_id = $1 + AND va.deleted_at IS NULL + AND inv.status = 'READY_FOR_PICKUP' + AND (va.id = $2::uuid + OR ($2::uuid IS NULL + AND (UPPER(v.power_plate_no) = UPPER($3) OR UPPER(v.plate_number) = UPPER($3))))`, + [h.bookingId, h.edrAssignmentId, h.truckPlate ?? ''], + ); + for (const it of items) { + try { + await this.deliver(it.id, { + receiverName: signerName, + remarks: `Auto-delivered on customer handover signature${h.truckPlate ? ` (truck ${h.truckPlate})` : ''}`, + performedBy: signerName, + } as DeliverInventoryDto); + } catch (err) { + this.logger.warn( + `Per-truck auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`, + ); + } + } + } + + /** + * Handover PDF resolved by booking (for the portal, which only has bookingId). + * With `handoverId` the document is rendered for that specific handover — the + * per-truck EDR last-mile variant (truck plate + that truck's signature state). + */ + async handoverDocumentForBooking( + bookingId: string, + handoverId?: string, + ): Promise<{ filename: string; buffer: Buffer }> { const [inv]: Array<{ id: string }> = await this.dataSource.query( `SELECT id FROM freight.warehouse_inventory WHERE booking_id = $1 AND deleted_at IS NULL @@ -3828,7 +4183,29 @@ export class WarehouseInventoryService { if (!inv) { throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); } - return this.handoverDocument(inv.id); + if (!handoverId) return this.handoverDocument(inv.id); + + const [h]: Array<{ + reference: string; + truckPlate: string | null; + signedAt: string | null; + signerName: string | null; + }> = await this.dataSource.query( + `SELECT reference, truck_plate AS "truckPlate", + signed_at AS "signedAt", signer_name AS "signerName" + FROM freight.booking_handovers + WHERE id = $1 AND booking_id = $2 AND deleted_at IS NULL`, + [handoverId, bookingId], + ); + if (!h) { + throw new NotFoundException(`Handover ${handoverId} not found for booking ${bookingId}`); + } + return this.handoverDocument(inv.id, { + reference: h.reference, + truckPlate: h.truckPlate, + signedAt: h.signedAt ? new Date(h.signedAt) : null, + signerName: h.signerName, + }); } /** Resolve the primary warehouse-inventory item for a booking (most recent). */ @@ -3856,12 +4233,22 @@ export class WarehouseInventoryService { return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId)); } - async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + async handoverDocument( + id: string, + perTruck?: { + reference: string; + truckPlate: string | null; + signedAt: Date | null; + signerName: string | null; + }, + ): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", inv.quantity, - inv.weight, + -- An unweighed item still reports the cargo weight it holds: fall + -- back to the item's container VGM when no weight was recorded. + COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight, inv.status, inv.notes, inv.inspection_status AS "inspectionStatus", @@ -3913,6 +4300,16 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id + AND bcu.deleted_at IS NULL + AND (container.container_number IS NULL + OR bcu.container_number = container.container_number) + ) item_vgm ON true LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -3931,12 +4328,14 @@ export class WarehouseInventoryService { const bookingReference = row.bookingReference || row.bookingId || 'N/A'; const reference = + perTruck?.reference || this.extractHandoverDocumentLine(row.notes, 'Handover Reference') || `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`; const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At'); const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date(); const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt; - if (!generatedAtValue) { + // Per-truck renders must not stamp their reference into the shared item notes. + if (!generatedAtValue && !perTruck) { await this.inventoryRepository.update(id, { notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)), }); @@ -3970,7 +4369,16 @@ export class WarehouseInventoryService { releaseDate: row.releaseDate ? new Date(row.releaseDate) : null, trainSchedule: row.trainSchedule ?? null, lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null, - customerApproval: this.extractCustomerDeliveryApproval(row.notes), + truckPlate: perTruck?.truckPlate ?? null, + customerApproval: perTruck + ? perTruck.signedAt + ? { + approvedAt: perTruck.signedAt.toISOString(), + signerDisplayName: perTruck.signerName ?? '-', + signatureImageUrl: null, + } + : null + : this.extractCustomerDeliveryApproval(row.notes), }); return { @@ -4009,14 +4417,61 @@ export class WarehouseInventoryService { if (!(await this.handover.isFullySigned(item.bookingId))) { throw new BadRequestException('Handover must be signed before delivery'); } - const [left]: Array<{ n: string }> = await this.dataSource.query( - `SELECT COUNT(*) AS n FROM freight.customer_truck_assignments - WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`, + const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left" + FROM freight.customer_truck_assignments + WHERE booking_id = $1 AND deleted_at IS NULL`, [item.bookingId], ); - if (Number(left?.n ?? 0) === 0) { + const totalTrucks = Number(trucks?.total ?? 0); + const leftTrucks = Number(trucks?.left ?? 0); + if (leftTrucks === 0) { throw new BadRequestException('Deliver is available only after the customer truck has left'); } + // Multi-truck booking: every assigned truck must arrive and leave — + // each is weighed out separately before the goods count as delivered. + if (leftTrucks < totalTrucks) { + throw new BadRequestException( + `Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`, + ); + } + } + // EDR last-mile delivers per truck: a container item only needs the truck + // CARRYING IT to have left; bulk (no container) waits for every truck. + if (item.containerId) { + const [own]: Array<{ pending: string }> = await this.dataSource.query( + `SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS pending + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + JOIN freight.containers c ON c.id = $2 AND c.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL + AND COALESCE(vc.container_number, va.container_number) = c.container_number`, + [item.bookingId, item.containerId], + ); + if (Number(own?.pending ?? 0) > 0) { + throw new BadRequestException( + 'Deliver is available only after the EDR truck carrying this container has left', + ); + } + } else { + const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL`, + [item.bookingId], + ); + const lmTotal = Number(lm?.total ?? 0); + const lmLeft = Number(lm?.left ?? 0); + if (lmTotal > 0 && lmLeft < lmTotal) { + throw new BadRequestException( + `Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`, + ); + } } } @@ -4112,6 +4567,12 @@ export class WarehouseInventoryService { } }); + // "Approve delivery" nudge: on Deliver the customer is reminded to sign any + // handover still unsigned (per truck for EDR last-mile). Fire-and-forget. + if (item.bookingId) { + void this.handover.notifyUnsignedForBooking(item.bookingId).catch(() => undefined); + } + return this.findById(id); } @@ -4839,10 +5300,11 @@ export class WarehouseInventoryService { releaseDate: Date | null; trainSchedule: string | null; lastMileDeliveryAddress: string | null; + truckPlate?: string | null; customerApproval: { approvedAt: string; signerDisplayName: string; - signatureImageUrl: string; + signatureImageUrl: string | null; } | null; }): string { const esc = (value: unknown) => @@ -4888,6 +5350,7 @@ export class WarehouseInventoryService { ['Release Order', data.releaseOrderReference], ['Release Date', fmt(data.releaseDate)], ['Last-mile Delivery Address', data.lastMileDeliveryAddress], + ...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []), ]; const approval = data.customerApproval; @@ -5297,7 +5760,11 @@ export class WarehouseInventoryService { weighingSkipped ? 'Weighing: SKIPPED' : null, tareWeight == null ? null : `Tare Weight: ${tareWeight} t`, grossWeight == null ? null : `Gross Weight: ${grossWeight} t`, - computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`, + // Skipped weighing still records a net — the cargo weight of the + // containers the truck is holding, resolved by the caller. + (computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null + ? null + : `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, ]; @@ -5305,12 +5772,14 @@ export class WarehouseInventoryService { } private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto { - const inspection = this.extractExitInspectionNote(notes); + const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber); if (!inspection) return dto; return { ...dto, - truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, + // The submitted plate wins: a legacy block may store a comma-joined list + // of plates, and the exit must be recorded against the ONE truck leaving. + truckPlateNumber: dto.truckPlateNumber?.trim() || this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber, driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName, driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense, @@ -5324,25 +5793,94 @@ export class WarehouseInventoryService { }; } - private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null { + /** + * Split notes into exit-inspection blocks (one per truck, in order) and + * everything else. A block ends at the first line that isn't one of the + * known inspection labels, so appended notes (delivery approval, handover + * marker) are preserved as "other" content instead of being swallowed by + * the block they happen to follow. + */ + private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } { const trimmed = notes?.trim(); - if (!exitInspectionNote) return trimmed || null; - if (!trimmed) return exitInspectionNote; - - const marker = '[Exit Inspection]'; - const index = trimmed.lastIndexOf(marker); - if (index < 0) { - return `${trimmed}\n\n${exitInspectionNote}`; + if (!trimmed) return { others: [], blocks: [] }; + const labelPattern = + /^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i; + const parts = trimmed.split(EXIT_INSPECTION_MARKER); + const others: string[] = []; + const blocks: string[] = []; + if (parts[0]?.trim()) others.push(parts[0].trim()); + for (const part of parts.slice(1)) { + const lines = part.split('\n'); + const kept: string[] = []; + let i = 0; + while (i < lines.length && !lines[i].trim()) i += 1; + for (; i < lines.length; i += 1) { + const line = lines[i].trim(); + if (!line || !labelPattern.test(line)) break; + kept.push(line); + } + if (kept.length) blocks.push(kept.join('\n')); + const tail = lines.slice(i).join('\n').trim(); + if (tail) others.push(tail); } - return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n'); + return { others, blocks }; } + /** + * A block belongs to a plate when its stored `Truck Plate` equals it, or is a + * legacy comma-joined list ("P1, P2") containing it. + */ + private blockMatchesPlate(block: string, plateNumber?: string | null): boolean { + const plate = plateNumber?.trim().toUpperCase(); + if (!plate) return false; + const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase(); + if (!stored) return false; + if (stored === plate) return true; + return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate); + } + + /** + * Replace THIS truck's inspection block (matched by plate), keeping every + * other truck's block untouched; append when the plate has no block yet. + * A single legacy block (comma-joined plates or plate-less caller) is + * replaced in place so old single-truck items keep their behaviour. + */ + private replaceExitInspectionNote( + notes: string | null | undefined, + exitInspectionNote: string | null, + plateNumber?: string | null, + ): string | null { + const { others, blocks } = this.splitExitInspectionSections(notes); + if (exitInspectionNote) { + const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim(); + const index = plateNumber + ? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber)) + : blocks.length - 1; + if (index >= 0) blocks[index] = content; + else blocks.push(content); + } + const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)]; + return sections.join('\n\n') || null; + } + + /** Latest truck's inspection block — legacy summary for documents. */ private extractExitInspectionNote(notes?: string | null): string | null { - if (!notes) return null; - const marker = '[Exit Inspection]'; - const index = notes.lastIndexOf(marker); - if (index < 0) return null; - return notes.slice(index + marker.length).trim() || null; + const { blocks } = this.splitExitInspectionSections(notes); + return blocks.length ? blocks[blocks.length - 1] : null; + } + + /** + * The inspection block for one truck. Falls back to a lone existing block so + * legacy single-truck items (saved before per-plate blocks) keep working. + */ + private extractExitInspectionForPlate( + notes: string | null | undefined, + plateNumber?: string | null, + ): string | null { + const { blocks } = this.splitExitInspectionSections(notes); + const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber)); + if (match) return match; + return blocks.length === 1 ? blocks[0] : null; } private extractExitInspectionLine(note: string | null | undefined, label: string): string | null { diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 8686f9cdf..5d1247358 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -249,6 +249,16 @@ const FleetFormDialog = ({ } } } + + // Format check (e.g. plate numbers). Skipped for an empty optional field — + // "required" above already owns the empty case. Upper-cased to match the + // server, which stores plates upper-case. + if (field.pattern && stringValue && stringValue !== FLEET_SELECT_NONE) { + const candidate = field.pattern.uppercase === false ? stringValue : stringValue.toUpperCase(); + if (!field.pattern.regex.test(candidate)) { + next[field.name] = field.pattern.message; + } + } }); setErrors(next); return Object.keys(next).length === 0; 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 93c3bf64e..41e5fcc81 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -2647,7 +2647,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {/* Primary stage action stays visible; the rest live under the kebab. */} - {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && ( + {/* Stays visible after the first exit — multi-truck bookings + weigh each truck in and out until all have left. */} + {r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && ( )} {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( @@ -2692,7 +2694,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { Ready for pickup )} - {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && ( + {r.currentStatus === 'READY_FOR_PICKUP' && ( } disabled={!r.hasAssignedTruck} @@ -2700,7 +2702,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { > {r.hasAssignedTruck ? r.releaseOrderReference - ? 'Truck leaving' + ? 'Truck arrival / leaving' : 'Truck arrival' : 'Truck arrival — assign a truck first'} 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 5f0a8a092..fcced78d9 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; -import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; -import { Info, Scale } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; +import { Info, Scale, Truck } from 'lucide-react'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; @@ -28,6 +28,8 @@ export interface ReleaseOrderTruckPrefill { containerNumber?: string | null; } +const EXIT_INSPECTION_MARKER = '[Exit Inspection]'; + const toIsoDateTime = (value: string) => { if (!value) return undefined; const date = new Date(value); @@ -70,9 +72,6 @@ const splitContainerNumbers = (value: string | null | undefined) => const getItemContainerNumber = (item: WarehouseInventoryItem | null) => (item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? ''; -const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable) => - item?.booking?.[key] == null ? '' : String(item.booking[key]); - const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => { const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null) ?.booking?.freightType; @@ -88,29 +87,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? ''); }; -const parseInspectionNote = (notes: string | null | undefined) => { - const marker = '[Exit Inspection]'; - const index = notes?.lastIndexOf(marker) ?? -1; - const note = index >= 0 ? notes?.slice(index + marker.length) : notes; - return { - truckPlateNumber: lineValue(note, 'Truck Plate'), - trailerPlateNumber: lineValue(note, 'Trailer Plate'), - driverName: lineValue(note, 'Driver'), - driverLicense: lineValue(note, 'Driver License'), - driverPhone: lineValue(note, 'Driver Phone'), - truckType: lineValue(note, 'Truck Type'), - containerNumber: lineValue(note, 'Container Number'), - gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')), - tareWeight: lineNumber(note, 'Tare Weight'), - grossWeight: lineNumber(note, 'Gross Weight'), - netWeight: lineNumber(note, 'Net Weight'), - gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')), - weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''), - }; +/** One truck's saved arrival/exit weighing, parsed from its inspection block. */ +interface InspectionBlock { + truckPlateNumber: string; + trailerPlateNumber: string; + driverName: string; + driverLicense: string; + driverPhone: string; + truckType: string; + containerNumber: string; + gateInTime: string; + tareWeight: number | ''; + grossWeight: number | ''; + netWeight: number | ''; + gateOutTime: string; + weighingSkipped: boolean; +} + +const parseInspectionSection = (note: string): InspectionBlock => ({ + truckPlateNumber: lineValue(note, 'Truck Plate'), + trailerPlateNumber: lineValue(note, 'Trailer Plate'), + driverName: lineValue(note, 'Driver'), + driverLicense: lineValue(note, 'Driver License'), + driverPhone: lineValue(note, 'Driver Phone'), + truckType: lineValue(note, 'Truck Type'), + containerNumber: lineValue(note, 'Container Number'), + gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')), + tareWeight: lineNumber(note, 'Tare Weight'), + grossWeight: lineNumber(note, 'Gross Weight'), + netWeight: lineNumber(note, 'Net Weight'), + gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')), + weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note), +}); + +/** Every truck's saved block — multi-truck bookings weigh each truck separately. */ +const parseInspectionBlocks = (notes: string | null | undefined): InspectionBlock[] => + (notes ?? '') + .split(EXIT_INSPECTION_MARKER) + .slice(1) + .map(parseInspectionSection) + .filter((block) => block.truckPlateNumber); + +/** Match by plate; a legacy block may hold a comma-joined plate list. */ +const blockForPlate = (blocks: InspectionBlock[], plate: string): InspectionBlock | undefined => { + const key = plate.trim().toUpperCase(); + if (!key) return undefined; + return blocks.find((block) => { + const stored = block.truckPlateNumber.toUpperCase(); + return stored === key || stored.split(/[,;]+/).map((p) => p.trim()).includes(key); + }); }; +const blockArrived = (block: InspectionBlock | undefined) => + Boolean(block && (block.tareWeight !== '' || block.weighingSkipped)); + +const blockLeft = (block: InspectionBlock | undefined) => + Boolean(block?.gateOutTime && (block.grossWeight !== '' || block.weighingSkipped)); + export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { const { toast } = useToast(); + const queryClient = useQueryClient(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); // Some openers (inventory workbench) supply bookingId without the booking // relation — fall back to it, or the truck/container-weight queries never run. @@ -152,78 +188,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const [netWeight, setNetWeight] = useState(''); const [gateOutTime, setGateOutTime] = useState(''); const [downloading, setDownloading] = useState(false); + // Plate whose saved block was last loaded into the form — stops the + // per-plate loader effect from clobbering operator edits in a loop. + const loadedPlateRef = useRef(null); - useEffect(() => { - if (opened) { - const inspection = parseInspectionNote(item?.notes); - const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber'); - const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName'); - const assignedTruckType = assignedTruckValue(item, 'customerTruckType'); - const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber'); - const prefillContainerNumber = truckPrefill?.containerNumber ?? ''; - setReference(item?.releaseOrderReference ?? generateReleaseReference(item)); - setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || ''); - setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || ''); - setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || ''); - setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || ''); - setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || ''); - setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || ''); - setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber)); - setGateInTime(inspection.gateInTime); - setTareWeight(inspection.tareWeight); - setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes'); - setGrossWeight(inspection.grossWeight); - setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight)); - setGateOutTime(inspection.gateOutTime); - } - }, [opened, item, truckPrefill]); - - const savedInspection = parseInspectionNote(item?.notes); - const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped; - const isEntranceLocked = isExitStep; - const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); - const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); - - // Opened from the warehouse flow (no truckPrefill prop): once the last-mile - // truck query resolves, auto-fill the first assigned EDR truck — without - // overwriting anything the operator typed or the locked exit-step values. - useEffect(() => { - if (!opened || truckPrefill || isExitStep) return; - const first = lastMileTrucks[0]; - if (!first) return; - setTruckPlateNumber((p) => p || first.truckPlateNumber || ''); - setTrailerPlateNumber((p) => p || first.trailerPlateNumber || ''); - setDriverName((p) => p || first.driverName || ''); - setDriverLicense((p) => p || first.driverLicense || ''); - setDriverPhone((p) => p || first.driverPhone || ''); - setTruckType((p) => p || first.truckType || ''); - setContainerNumbers((prev) => - prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev, - ); - }, [opened, truckPrefill, isExitStep, lastMileTrucks]); - - // The same for a customer self-haul truck. The prefill above reads the - // booking.customer_truck_* columns, but multi-truck self-haul writes the plate - // and driver to customer_truck_assignments and leaves those columns null — so - // a booking with a truck on file still opened this form blank. Only auto-fills - // a single truck: with several, the operator picks which one is at the gate. - useEffect(() => { - if (!opened || truckPrefill || isExitStep) return; - if (customerTrucks.length !== 1) return; - const [truck] = customerTrucks; - setTruckPlateNumber((p) => p || truck.plateNumber || ''); - setDriverName((p) => p || truck.driverName || ''); - setTruckType((p) => p || truck.truckType || ''); - setContainerNumbers((prev) => { - const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean); - return prev.every((n) => !n) && loaded.length ? loaded : prev; - }); - }, [opened, truckPrefill, isExitStep, customerTrucks]); + const savedBlocks = parseInspectionBlocks(item?.notes); // Registered trucks for THIS booking, from both sources: EDR last-mile // (truckPrefill) and the customer portal (customer_truck_assignments). const assignedTruckOptions = [ - ...(truckPrefill?.truckPlateNumber + ...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',') ? [ { value: truckPrefill.truckPlateNumber, @@ -232,6 +206,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea driverName: truckPrefill.driverName ?? '', driverPhone: truckPrefill.driverPhone ?? '', truckType: truckPrefill.truckType ?? '', + containerNumbers: splitContainerNumbers(truckPrefill.containerNumber), + arrived: false, + left: false, }, ] : []), @@ -242,6 +219,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea driverName: t.driverName, driverPhone: '', truckType: t.truckType, + containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean), + arrived: Boolean(t.arrivedAt), + left: Boolean(t.departedAt), })), ...lastMileTrucks .filter((t) => t.truckPlateNumber || t.vehicleId) @@ -252,6 +232,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea driverName: t.driverName ?? '', driverPhone: t.driverPhone ?? '', truckType: t.truckType ?? '', + containerNumbers: splitContainerNumbers(t.containerNumber), + arrived: Boolean(t.arrivedAt), + left: Boolean(t.departedAt), })), ]; // Only trucks actually assigned to THIS booking (last-mile prefill or customer @@ -261,10 +244,132 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const truckSelectOptions = [ ...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(), ]; + const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); // Neither a last-mile truck nor a customer truck has been assigned yet. const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck; - const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; - const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName); + + // Per-truck progress: every truck is weighed in and out on its own; the saved + // blocks also cover walk-in trucks that were never formally assigned. + const truckProgress = new Map(); + for (const option of truckSelectOptions) { + truckProgress.set(option.value.trim().toUpperCase(), { arrived: option.arrived, left: option.left }); + } + for (const block of savedBlocks) { + const key = block.truckPlateNumber.trim().toUpperCase(); + const prior = truckProgress.get(key); + truckProgress.set(key, { + arrived: Boolean(prior?.arrived) || blockArrived(block), + left: Boolean(prior?.left) || blockLeft(block), + }); + } + const totalTrucks = truckProgress.size; + const arrivedTrucks = [...truckProgress.values()].filter((t) => t.arrived).length; + const leftTrucks = [...truckProgress.values()].filter((t) => t.left).length; + + // The step is decided PER TRUCK: the selected plate's saved block. A new plate + // (or a truck without a saved arrival) starts at the arrival step even when + // other trucks of the booking are already mid-flow or gone. + const selectedBlock = blockForPlate(savedBlocks, truckPlateNumber); + const isExitStep = blockArrived(selectedBlock); + const hasTruckLeft = blockLeft(selectedBlock); + const isEntranceLocked = isExitStep; + const selectedOption = truckSelectOptions.find( + (option) => option.value.trim().toUpperCase() === truckPlateNumber.trim().toUpperCase(), + ); + // Identity comes from the arrival record or the assignment — locked either + // way. A walk-in truck (typed plate, no assignment) stays editable at arrival. + const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption); + const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName); + const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0; + + /** Load a truck into the form: its saved block if any, else its assignment. */ + const applyTruckSelection = (plate: string) => { + const block = blockForPlate(savedBlocks, plate); + const option = truckSelectOptions.find( + (o) => o.value.trim().toUpperCase() === plate.trim().toUpperCase(), + ); + loadedPlateRef.current = plate.trim().toUpperCase(); + setTruckPlateNumber(plate); + setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || ''); + setDriverName(block?.driverName || option?.driverName || ''); + setDriverLicense(block?.driverLicense || ''); + setDriverPhone(block?.driverPhone || option?.driverPhone || ''); + setTruckType(block?.truckType || option?.truckType || ''); + const loaded = block + ? splitContainerNumbers(block.containerNumber) + : (option?.containerNumbers ?? []); + setContainerNumbers(loaded.length ? loaded : initialContainerNumbers(item, '')); + setGateInTime(block?.gateInTime ?? ''); + setTareWeight(block?.tareWeight ?? ''); + setWeighTruck(block?.weighingSkipped ? 'no' : 'yes'); + setGrossWeight(block?.grossWeight ?? ''); + setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight))); + setGateOutTime(block?.gateOutTime ?? ''); + }; + + useEffect(() => { + if (opened) { + loadedPlateRef.current = null; + setReference(item?.releaseOrderReference ?? generateReleaseReference(item)); + // Initial truck: the caller's prefill, else the first truck still mid-flow + // (arrived but not left) — the operator can switch trucks in the select. + const prefillPlate = + truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',') + ? truckPrefill.truckPlateNumber + : ''; + const blocks = parseInspectionBlocks(item?.notes); + const inProgress = blocks.find((block) => blockArrived(block) && !blockLeft(block)); + // Legacy single-truck bookings stored the truck on the booking columns; a + // comma-joined value means several trucks, so the operator picks instead. + const bookingPlate = item?.booking?.customerTruckPlateNumber ?? ''; + const legacyPlate = bookingPlate && !bookingPlate.includes(',') ? bookingPlate : ''; + const initialPlate = prefillPlate || inProgress?.truckPlateNumber || legacyPlate || ''; + const block = blockForPlate(blocks, initialPlate); + loadedPlateRef.current = initialPlate ? initialPlate.trim().toUpperCase() : null; + setTruckPlateNumber(initialPlate); + setTrailerPlateNumber(block?.trailerPlateNumber || truckPrefill?.trailerPlateNumber || ''); + setDriverName( + block?.driverName || + truckPrefill?.driverName || + (legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckDriverName ?? '') : ''), + ); + setDriverLicense(block?.driverLicense || truckPrefill?.driverLicense || ''); + setDriverPhone(block?.driverPhone || truckPrefill?.driverPhone || ''); + setTruckType( + block?.truckType || + truckPrefill?.truckType || + (legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckType ?? '') : ''), + ); + setContainerNumbers( + initialContainerNumbers(item, block?.containerNumber || truckPrefill?.containerNumber || ''), + ); + setGateInTime(block?.gateInTime ?? ''); + setTareWeight(block?.tareWeight ?? ''); + setWeighTruck(block?.weighingSkipped ? 'no' : 'yes'); + setGrossWeight(block?.grossWeight ?? ''); + setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight))); + setGateOutTime(block?.gateOutTime ?? ''); + } + }, [opened, item, truckPrefill]); + + // No truck chosen yet and exactly one is assigned — load it. With several + // trucks the operator picks which one is at the gate. + useEffect(() => { + if (!opened || truckPlateNumber || loadedPlateRef.current) return; + if (truckSelectOptions.length !== 1) return; + applyTruckSelection(truckSelectOptions[0].value); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, truckPlateNumber, customerTrucks, lastMileTrucks]); + + // A typed plate that matches a saved arrival reloads that truck's record, so + // the exit step opens with the weigh-in data instead of blank fields. + useEffect(() => { + if (!opened) return; + const key = truckPlateNumber.trim().toUpperCase(); + if (!key || loadedPlateRef.current === key) return; + if (blockForPlate(savedBlocks, truckPlateNumber)) applyTruckSelection(truckPlateNumber); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, truckPlateNumber]); // Which containers ride this truck, and their combined cargo weight. When the // booking has container weights, that sum is the authoritative net; the @@ -294,7 +399,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea ); // Skip is only offered for container bookings; bulk always weighs. const skipWeighing = hasContainerWeights && weighTruck === 'no'; - const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing; + // Even an unweighed truck records the cargo weight it is holding — the + // selected containers' sum is the net that goes on the exit record. + const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0; const systemNetWeight = useContainerNet ? selectedCargoWeight @@ -314,6 +421,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); return; } + if (hasTruckLeft) { + toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` }); + return; + } if (!gateInTime || (!skipWeighing && tareWeight === '')) { toast({ variant: 'destructive', @@ -363,13 +474,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea weighingSkipped: skipWeighing || undefined, tareWeight: skipWeighing ? undefined : Number(tareWeight), grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight), - netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, + // Skipped weighing still records the net from what the truck holds. + netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined, }, }); + await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] }); + await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] }); if (!isExitStep) { + const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : ''; toast({ - title: 'Truck arrival saved', + title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`, description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`, }); onClose(); @@ -380,11 +495,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const blob = response.data; const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`; const opened = openPdfBlob(blob, filename, pdfWindow); + const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : ''; toast({ title: 'Release exit paper issued', - description: opened + description: (opened ? 'The PDF opened in a browser tab for printing or saving.' - : 'The browser blocked the preview tab, so the PDF was downloaded.', + : 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit, }); onClose(); } catch (error) { @@ -411,12 +527,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} + {totalTrucks > 1 && ( + } color="blue" variant="light"> + + + {totalTrucks} trucks on this booking — each is weighed in and out separately. + + + {arrivedTrucks}/{totalTrucks} arrived + + + {leftTrucks}/{totalTrucks} left + + + + )} + {hasTruckLeft && ( + } color="green" variant="light"> + + Truck {truckPlateNumber} has already left — its exit record is locked. Pick another + truck to continue the remaining arrivals and exits. + + + )} setReference(e.currentTarget.value)} - readOnly={isEntranceLocked} + readOnly={referenceLocked} /> {noTruckAssigned && ( }> @@ -425,22 +564,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} {truckSelectOptions.length > 0 && ( by its label and pick an option by exact text. */ +Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string | RegExp) => { + cy.contains("label", label) + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).click({ force: true }); + }); + cy.get('[role="option"]').contains(option).click(); +}); + +/** Type a 6-digit code into a Mantine PinInput. */ +Cypress.Commands.add("typeOtp", (code: string) => { + cy.get(".mantine-PinInput-root input").should("have.length.at.least", code.length); + code.split("").forEach((digit, i) => { + cy.get(".mantine-PinInput-root input").eq(i).type(digit, { force: true }); + }); +}); + +/** + * Draw a squiggle on the signature-pad canvas (mouse events). When the account + * already has a saved signature the modal opens in "Approve signature" mode + * with no canvas — nothing to draw, the saved image is used as-is. + */ +Cypress.Commands.add("drawSignature", () => { + cy.get(".mantine-Modal-content").then(($modals) => { + if ($modals.find("canvas").length === 0) return; + drawOnCanvas(); + }); +}); + +function drawOnCanvas() { + cy.get(".mantine-Modal-content canvas") + .first() + .then(($canvas) => { + const rect = $canvas[0].getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + const midY = rect.top + rect.height / 2; + cy.wrap($canvas) + .trigger("mousedown", { clientX: midX - 60, clientY: midY, force: true }) + .trigger("mousemove", { clientX: midX - 20, clientY: midY - 15, force: true }) + .trigger("mousemove", { clientX: midX + 20, clientY: midY + 15, force: true }) + .trigger("mousemove", { clientX: midX + 60, clientY: midY, force: true }) + .trigger("mouseup", { force: true }); + }); +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** POST /api/auth/login, returns the flattened token body. */ + apiLogin(email: string, pass?: string): Chainable; + /** Cached programmatic staff session (default ceo@edr.local). */ + loginBackoffice(email?: string, pass?: string): Chainable; + /** Cached programmatic customer session (default user@gmail.com). */ + loginPortal(email?: string, pass?: string): Chainable; + /** cy.visit against the portal origin (env.portalUrl). */ + visitPortal(path?: string): Chainable; + /** Latest OTP stored for an email/phone (delivery is off in e2e). */ + getOtp(target: string): Chainable; + /** Open a Mantine Select by label, pick an option. */ + mantineSelect(label: string | RegExp, option: string | RegExp): Chainable; + /** Fill a Mantine PinInput with a code. */ + typeOtp(code: string): Chainable; + /** Scribble on the signature-pad canvas inside the open modal. */ + drawSignature(): Chainable; + } + } +} + +export {}; diff --git a/e2e/freight/cypress/support/e2e.ts b/e2e/freight/cypress/support/e2e.ts new file mode 100644 index 000000000..49bc8540b --- /dev/null +++ b/e2e/freight/cypress/support/e2e.ts @@ -0,0 +1,18 @@ +import "./commands"; + +// The API's user seeders are disabled in app code — the SQL fixture creates +// the staff + demo test users instead. Idempotent, runs before each spec file. +before(() => { + cy.task("db:seedUsers"); +}); + +// Third-party noise (PostHog, Google Maps, socket.io reconnects) can throw +// uncaught exceptions that are irrelevant to the assertion under test. App +// errors still fail tests via failed assertions / failed intercepts. +Cypress.on("uncaught:exception", (err) => { + const ignorable = [/posthog/i, /google/i, /websocket/i, /socket\.io/i, /ResizeObserver/i]; + if (ignorable.some((pattern) => pattern.test(err.message))) { + return false; + } + return true; +}); diff --git a/e2e/freight/package.json b/e2e/freight/package.json new file mode 100644 index 000000000..4097b09da --- /dev/null +++ b/e2e/freight/package.json @@ -0,0 +1,22 @@ +{ + "name": "@edr/freight-e2e", + "version": "0.0.0", + "private": true, + "description": "Cypress end-to-end tests for the EDR freight system (portal + backoffice + API)", + "scripts": { + "cy:open": "cypress open --e2e", + "cy:run": "cypress run", + "cy:run:backoffice": "cypress run --spec 'cypress/e2e/backoffice/**'", + "cy:run:portal": "cypress run --spec 'cypress/e2e/portal/**'", + "cy:run:api": "cypress run --spec 'cypress/e2e/api/**'", + "cy:run:flows": "cypress run --spec 'cypress/e2e/flows/**'", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "cypress": "^15.3.0", + "pg": "^8.13.0", + "typescript": "^5.5.4", + "@types/node": "^22.0.0", + "@types/pg": "^8.11.0" + } +} diff --git a/e2e/freight/scripts/e2e.mjs b/e2e/freight/scripts/e2e.mjs new file mode 100644 index 000000000..81c8bf216 --- /dev/null +++ b/e2e/freight/scripts/e2e.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * Freight e2e launcher — one command, no manual steps: + * + * node e2e/freight/scripts/e2e.mjs [cypress args...] + * + * - run/open/ci auto-start the docker stack (build + wait healthy) if it + * isn't already running, then launch Cypress pointed at the right ports. + * - Host ports default to 3101/5373/5383/5533/9310/9311; any default that is + * busy is replaced by the next free port. Chosen ports are written to + * e2e/freight/.e2e-ports.json (gitignored) and reused while the stack is + * up, so cypress and compose always agree. + * - Extra args are forwarded to Cypress: `pnpm e2e:freight:run --spec ...`. + * + * No dependencies — plain Node, spawns `docker compose` and `pnpm`. + */ + +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const e2eDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(e2eDir, "..", ".."); +const stateFile = join(e2eDir, ".e2e-ports.json"); +const composeBase = ["compose", "-f", join(repoRoot, "docker-compose.e2e.yaml")]; + +const DEFAULT_PORTS = { + E2E_API_PORT: 3101, + E2E_PORTAL_PORT: 5373, + E2E_BACKOFFICE_PORT: 5383, + E2E_DB_PORT: 5533, + E2E_MINIO_PORT: 9310, + E2E_MINIO_CONSOLE_PORT: 9311, +}; + +// Long-running services that must be up before tests (minio-init exits). +const SERVICES = [ + "postgres-freight-e2e", + "minio-e2e", + "freight-api-e2e", + "freight-portal-e2e", + "freight-backoffice-e2e", +]; + +function fail(msg) { + console.error(`\ne2e: ${msg}`); + process.exit(1); +} + +function preflight() { + try { + execFileSync("docker", ["info"], { stdio: "ignore" }); + } catch { + fail("docker is not running (or not installed) — start Docker and retry."); + } + if (!existsSync(join(repoRoot, ".npmrc"))) { + fail( + ".npmrc missing at repo root — image builds need GitHub Packages auth " + + "for @tria-plc (same file the main docker-compose.yaml uses).", + ); + } +} + +function isPortFree(port) { + return new Promise((res) => { + const srv = createServer(); + srv.once("error", () => res(false)); + srv.once("listening", () => srv.close(() => res(true))); + srv.listen(port); + }); +} + +function stackRunning(env) { + try { + const out = execFileSync( + "docker", + [...composeBase, "ps", "--services", "--status", "running"], + { encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] }, + ); + const running = new Set(out.split("\n").filter(Boolean)); + return SERVICES.every((s) => running.has(s)); + } catch { + return false; + } +} + +/** + * While the stack runs, ports are whatever it was started with (state file, + * else the defaults — a hand-started stack used the compose defaults). Only a + * fresh start gets to scan for free ports. + */ +async function resolvePorts() { + if (stackRunning(process.env)) { + return existsSync(stateFile) + ? JSON.parse(readFileSync(stateFile, "utf8")) + : { ...DEFAULT_PORTS }; + } + const ports = {}; + const taken = new Set(); + for (const [name, preferred] of Object.entries(DEFAULT_PORTS)) { + let port = preferred; + while (taken.has(port) || !(await isPortFree(port))) port += 1; + taken.add(port); + ports[name] = port; + if (port !== preferred) + console.log(`e2e: port ${preferred} busy → ${name}=${port}`); + } + return ports; +} + +function envFor(ports) { + return { + ...process.env, + ...Object.fromEntries( + Object.entries(ports).map(([k, v]) => [k, String(v)]), + ), + CYPRESS_BASE_URL: `http://localhost:${ports.E2E_BACKOFFICE_PORT}`, + CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`, + CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_PORT}`, + E2E_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${ports.E2E_DB_PORT}/edr_freight_e2e`, + }; +} + +function compose(args, env) { + const { status } = spawnSync("docker", [...composeBase, ...args], { + stdio: "inherit", + env, + }); + return status ?? 1; +} + +function up(ports, env) { + preflight(); + console.log( + `e2e: starting stack — api :${ports.E2E_API_PORT} portal :${ports.E2E_PORTAL_PORT} backoffice :${ports.E2E_BACKOFFICE_PORT} db :${ports.E2E_DB_PORT}`, + ); + const status = compose(["up", "-d", "--build", "--wait"], env); + if (status !== 0) + fail( + "stack failed to become healthy. Inspect with:\n" + + " docker compose -f docker-compose.e2e.yaml logs freight-api-e2e", + ); + writeFileSync(stateFile, JSON.stringify(ports, null, 2) + "\n"); +} + +function ensureUp(ports, env) { + if (stackRunning(env)) return; + up(ports, env); +} + +function runPnpm(script, extra, env) { + const { status } = spawnSync( + "pnpm", + ["--filter", "@edr/freight-e2e", "run", script, ...extra], + { cwd: repoRoot, stdio: "inherit", env }, + ); + process.exit(status ?? 1); +} + +const [cmd, ...extra] = process.argv.slice(2); +const ports = await resolvePorts(); +const env = envFor(ports); + +switch (cmd) { + case "up": + up(ports, env); + break; + case "run": + ensureUp(ports, env); + runPnpm("cy:run", extra, env); + break; + case "open": + ensureUp(ports, env); + runPnpm("cy:open", extra, env); + break; + case "ci": + ensureUp(ports, env); + process.exit(compose(["--profile", "cypress", "run", "--rm", "cypress", ...extra], env)); + break; + case "down": + process.exit( + (() => { + const status = compose(["down", "-v", "--remove-orphans"], env); + rmSync(stateFile, { force: true }); + return status; + })(), + ); + break; + default: + fail(`unknown command "${cmd ?? ""}" — use up | run | open | ci | down`); +} diff --git a/e2e/freight/tsconfig.json b/e2e/freight/tsconfig.json new file mode 100644 index 000000000..c2b049986 --- /dev/null +++ b/e2e/freight/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["cypress", "node"] + }, + "include": ["cypress/**/*.ts", "cypress.config.ts"] +} diff --git a/package.json b/package.json index 8909de6cf..193732907 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,11 @@ "format": "prettier --write \"**/*.{ts,tsx,json,md}\"", "docker:build": "docker compose build", "docker:up": "docker compose up -d", + "e2e:freight:up": "node e2e/freight/scripts/e2e.mjs up", + "e2e:freight:open": "node e2e/freight/scripts/e2e.mjs open", + "e2e:freight:run": "node e2e/freight/scripts/e2e.mjs run", + "e2e:freight:ci": "node e2e/freight/scripts/e2e.mjs ci", + "e2e:freight:down": "node e2e/freight/scripts/e2e.mjs down", "prepare": "husky" }, "devDependencies": { diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index be13ed349..2fe24773f 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -546,6 +546,7 @@ export interface ICustomerTruck { truckType: string; assignedAt: string; arrivedAt?: string | null; + departedAt?: string | null; containers?: ICustomerTruckContainer[]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f59a388ba..72daf6a26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -580,7 +580,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1203,6 +1203,24 @@ importers: specifier: ^5.5.4 version: 5.9.3 + e2e/freight: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + '@types/pg': + specifier: ^8.11.0 + version: 8.20.0 + cypress: + specifier: ^15.3.0 + version: 15.18.1 + pg: + specifier: ^8.13.0 + version: 8.21.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + packages/api-common: dependencies: '@edr/types': @@ -1743,6 +1761,13 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@cypress/request@4.0.1': + resolution: {integrity: sha512-y20e+e6dFYkOUUJLVUZTsJRuTiXZaUQ32WD+R/ux/HBybbTx4ge7cNINcua0pU8+SNkKuRbOF12mBmzuzM8n5w==} + engines: {node: '>= 14.17.0'} + + '@cypress/xvfb@1.2.4': + resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==} + '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} @@ -4702,6 +4727,9 @@ packages: '@types/node@20.19.42': resolution: {integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/node@24.13.1': resolution: {integrity: sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==} @@ -4760,6 +4788,9 @@ packages: '@types/signature_pad@2.3.6': resolution: {integrity: sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==} + '@types/sinonjs__fake-timers@8.1.1': + resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==} + '@types/sizzle@2.3.10': resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==} @@ -4778,6 +4809,9 @@ packages: '@types/tinymce@4.6.9': resolution: {integrity: sha512-pDxBUlV4v1jgJ97SlnVOSyf3KUy3OQ3s5Ddpfh1L9M5lXlBmX7TJ2OLSozx1WBxp91acHvYPWDwz2U/kMM1oxQ==} + '@types/tmp@0.2.6': + resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -5372,6 +5406,9 @@ packages: append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + archiver-utils@2.1.0: resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} engines: {node: '>= 6'} @@ -5472,6 +5509,13 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -5504,6 +5548,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + atob@2.1.2: resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} engines: {node: '>= 4.5.0'} @@ -5527,6 +5575,12 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axe-core@4.12.0: resolution: {integrity: sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg==} engines: {node: '>=4'} @@ -5663,6 +5717,9 @@ packages: resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} engines: {node: '>=10.0.0'} + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bcrypt@6.0.0: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} @@ -5684,12 +5741,18 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blob-util@2.0.2: + resolution: {integrity: sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==} + block-stream2@2.1.0: resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@1.20.5: resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -5794,6 +5857,10 @@ packages: resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==} engines: {node: '>=0.10.0'} + cachedir@2.4.0: + resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} + engines: {node: '>=6'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -5832,6 +5899,9 @@ packages: resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} engines: {node: '>=10.0.0'} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + cfb@1.2.2: resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} engines: {node: '>=0.8'} @@ -5890,6 +5960,10 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -5931,6 +6005,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-table3@0.6.1: + resolution: {integrity: sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==} + engines: {node: 10.* || >= 12.*} + cli-table3@0.6.5: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} @@ -5939,6 +6017,10 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + cli-width@1.1.1: resolution: {integrity: sha512-eMU2akIeEIkCxGXUNmDnJq1KzOIiPnJ+rKqRe6hcxE3vIOPvpMrBYOn/Bl7zNlYJj/zQxXquAnozHUCf9Whnsg==} @@ -6022,6 +6104,10 @@ packages: resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} engines: {node: '>=0.1.90'} + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -6045,10 +6131,18 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + comment-json@5.0.0: resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} engines: {node: '>= 6'} + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -6140,6 +6234,9 @@ packages: core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -6254,6 +6351,11 @@ packages: resolution: {integrity: sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==} engines: {node: '>=0.4.0'} + cypress@15.18.1: + resolution: {integrity: sha512-JtkTVtUE2lvLYgZCaug+Uai0H9IqsJirlBO49c87QwG0bJUGvAUVBz1EJve0b0oaYP244Ew9M0BkrHpcqkYxmw==} + engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0} + hasBin: true + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} @@ -6305,6 +6407,10 @@ packages: resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} engines: {node: '>=12'} + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -6587,6 +6693,9 @@ packages: ebec@2.3.0: resolution: {integrity: sha512-bt+0tSL7223VU3PSVi0vtNLZ8pO1AfWolcPPMk2a/a5H+o/ZU9ky0n3A0zhrR4qzJTN61uPsGIO4ShhOukdzxA==} + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -6913,6 +7022,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter2@6.4.7: + resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==} + eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} @@ -6941,6 +7053,10 @@ packages: resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} engines: {node: '>=8.3.0'} + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -6953,6 +7069,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + executable@4.1.1: + resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} + engines: {node: '>=4'} + exit-hook@1.1.1: resolution: {integrity: sha512-MsG3prOVw1WtLXAZbM3KiYtooKR1LvxHh3VHsVtIy0uiUu8usxgB/94DP2HxtD/661lLdB6yzQ09lGJSQr6nkg==} engines: {node: '>=0.10.0'} @@ -6998,6 +7118,9 @@ packages: resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==} engines: {node: '>=0.10.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extglob@2.0.4: resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==} engines: {node: '>=0.10.0'} @@ -7007,6 +7130,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + eyes@0.1.8: resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} engines: {node: '> 0.1.90'} @@ -7191,6 +7318,9 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + fork-ts-checker-webpack-plugin@9.1.0: resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} engines: {node: '>=14.21.3'} @@ -7262,6 +7392,10 @@ packages: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} @@ -7362,6 +7496,9 @@ packages: resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} engines: {node: '>=0.10.0'} + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -7406,6 +7543,10 @@ packages: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} @@ -7501,6 +7642,10 @@ packages: resolution: {integrity: sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==} engines: {node: '>=0.10.0'} + hasha@5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -7571,6 +7716,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-signature@1.4.0: + resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} + engines: {node: '>=0.10'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -7579,6 +7728,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -7684,6 +7837,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + ini@4.1.1: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -7852,6 +8009,10 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -7969,6 +8130,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -8257,6 +8421,9 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jsdom@25.0.1: resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} engines: {node: '>=18'} @@ -8286,12 +8453,18 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} json-stream@1.0.0: resolution: {integrity: sha512-H/ZGY0nIAg3QcOwE1QN/rK/Fa7gJn7Ii5obwp6zyPO4xiPNwpIMjqy2gwjBEGqzkF/vSWEIBQCBuN19hYiL6Qg==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -8330,6 +8503,10 @@ packages: jspdf@4.2.1: resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + jsprim@2.0.2: + resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} + engines: {'0': node >=0.6.0} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -8508,6 +8685,10 @@ packages: resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} engines: {node: '>=18.0.0'} + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + little-state-machine@4.8.1: resolution: {integrity: sha512-liPHqaWMQ7rzZryQUDnbZ1Gclnnai3dIyaJ0nAgwZRXMzqbYrydrlCI0NDojRUbE5VYh5vu6hygEUZiH77nQkQ==} peerDependencies: @@ -9194,6 +9375,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + ospath@1.2.2: + resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -9562,6 +9746,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9609,6 +9797,9 @@ packages: resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} engines: {node: '>= 14'} + proxy-from-env@1.0.0: + resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -10083,6 +10274,9 @@ packages: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} + request-progress@3.0.0: + resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -10410,6 +10604,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -10516,6 +10714,11 @@ packages: resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} engines: {node: '>=0.8'} + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -10593,6 +10796,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -10766,6 +10973,12 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + systeminformation@5.33.0: + resolution: {integrity: sha512-0LYSL01CCbjVeJG7iXI8fUCFU76zMjzbHd/EU3or4QpSFYCLMgslR11prwHuA3siz5jmOkqoLhjgOyDRmXBKmA==} + engines: {node: '>=10.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -10883,6 +11096,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + throttleit@1.0.1: + resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} + through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} @@ -11006,6 +11222,10 @@ packages: traverse@0.3.9: resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + trim-canvas@0.1.2: resolution: {integrity: sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==} @@ -11090,6 +11310,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turbo@2.9.16: resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} hasBin: true @@ -11097,6 +11320,9 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -11113,6 +11339,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -11285,6 +11515,10 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + unzipper@0.10.14: resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} @@ -11425,6 +11659,10 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -11763,6 +12001,10 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + year@0.2.1: resolution: {integrity: sha512-9GnJUZ0QM4OgXuOzsKNzTJ5EOkums1Xc+3YQXp+Q+UxFjf7zLucp9dQ8QMIft0Szs1E1hUiXFim1OYfEKFq97w==} engines: {node: '>=0.8'} @@ -11889,11 +12131,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -11928,7 +12170,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -11937,7 +12179,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -11952,9 +12201,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -11969,13 +12218,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12128,6 +12377,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12286,6 +12547,33 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@cypress/request@4.0.1': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 4.0.5 + http-signature: 1.4.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.15.2 + safe-buffer: 5.2.1 + tough-cookie: 5.1.2 + tunnel-agent: 0.6.0 + + '@cypress/xvfb@1.2.4(supports-color@8.1.1)': + dependencies: + debug: 3.2.7(supports-color@8.1.1) + lodash.once: 4.1.1 + transitivePeerDependencies: + - supports-color + '@date-fns/tz@1.5.0': {} '@dotenvx/dotenvx@1.71.0': @@ -12324,7 +12612,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -12490,7 +12778,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -12636,7 +12924,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -13803,7 +14091,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -15871,7 +16159,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -16162,6 +16450,130 @@ snapshots: - utf-8-validate - vite + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': + dependencies: + '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) + '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) + '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) + '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) + '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 7.17.8(react@19.2.6) + '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) + '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf/renderer': 4.5.1(react@19.2.6) + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + '@tabler/icons-react': 3.44.0(react@19.2.6) + '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) + '@tanstack/react-query': 5.101.0(react@19.2.6) + '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) + '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) + '@types/dompurify': 3.2.0 + '@types/node': 24.13.1 + '@types/tinymce': 4.6.9 + axios: 1.17.0 + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + date-fns: 3.6.0 + dayjs: 1.11.21 + dompurify: 3.4.8 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-calendar-new: 1.1.0 + file-type: 18.7.0 + framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + html2canvas: 1.4.1 + i18next: 25.10.10(typescript@5.9.3) + i18next-browser-languagedetector: 8.2.1 + jquery: 3.7.1 + js-cookie: 3.0.8 + jspdf: 3.0.4 + lodash: 4.18.1 + lucide-react: 0.513.0(react@19.2.6) + mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) + next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + path: 0.12.7 + pdf-lib: 1.17.1 + qs: 6.15.2 + react: 19.2.6 + react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) + react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + react-dropzone: 14.4.1(react@19.2.6) + react-hook-form: 7.77.0(react@19.2.6) + react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-icons: 5.6.0(react@19.2.6) + react-image-crop: 11.0.10(react@19.2.6) + react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@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) + react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) + rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) + socket.io-client: 4.8.3 + sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tailwind-merge: 3.6.0 + tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) + tailwindcss: 4.3.0 + tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) + tinymce: 7.9.3 + url: 0.11.4 + vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + xlsx: 0.18.5 + zod: 3.25.76 + transitivePeerDependencies: + - '@babel/core' + - '@emotion/is-prop-valid' + - '@mui/icons-material' + - '@mui/material' + - '@mui/x-date-pickers' + - '@types/prop-types' + - '@types/react' + - '@types/react-dom' + - bufferutil + - debug + - pdfjs-dist + - prop-types + - react-is + - react-native + - redux + - rolldown + - rollup + - supports-color + - typescript + - utf-8-validate + - vite + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -16376,6 +16788,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + '@types/node@24.13.1': dependencies: undici-types: 7.18.2 @@ -16442,6 +16858,8 @@ snapshots: '@types/signature_pad@2.3.6': {} + '@types/sinonjs__fake-timers@8.1.1': {} + '@types/sizzle@2.3.10': {} '@types/stack-utils@2.0.3': {} @@ -16464,6 +16882,8 @@ snapshots: dependencies: '@types/jquery': 4.0.1 + '@types/tmp@0.2.6': {} + '@types/trusted-types@2.0.7': optional: true @@ -16514,7 +16934,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -16524,7 +16944,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -16543,7 +16963,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -16558,7 +16978,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -16838,7 +17258,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -17093,6 +17513,8 @@ snapshots: append-field@1.0.0: {} + arch@2.2.0: {} + archiver-utils@2.1.0: dependencies: glob: 7.2.3 @@ -17240,6 +17662,12 @@ snapshots: asap@2.0.6: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + assertion-error@2.0.1: {} assign-symbols@1.0.0: {} @@ -17264,6 +17692,8 @@ snapshots: asynckit@0.4.0: {} + at-least-node@1.0.0: {} + atob@2.1.2: {} attr-accept@2.2.5: {} @@ -17285,6 +17715,10 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + axe-core@4.12.0: {} axios@1.17.0: @@ -17348,6 +17782,16 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): + dependencies: + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + picomatch: 4.0.4 + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - supports-color + babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -17442,6 +17886,10 @@ snapshots: basic-ftp@5.3.1: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + bcrypt@6.0.0: dependencies: node-addon-api: 8.8.0 @@ -17466,12 +17914,16 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + blob-util@2.0.2: {} + block-stream2@2.1.0: dependencies: readable-stream: 3.6.2 bluebird@3.4.7: {} + bluebird@3.7.2: {} + body-parser@1.20.5: dependencies: bytes: 3.1.2 @@ -17493,7 +17945,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -17624,6 +18076,8 @@ snapshots: union-value: 1.0.1 unset-value: 1.0.0 + cachedir@2.4.0: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -17665,6 +18119,8 @@ snapshots: svg-pathdata: 6.0.3 optional: true + caseless@0.12.0: {} + cfb@1.2.2: dependencies: adler-32: 1.3.1 @@ -17731,6 +18187,8 @@ snapshots: ci-info@3.9.0: {} + ci-info@4.4.0: {} + citty@0.1.6: dependencies: consola: 3.4.2 @@ -17774,6 +18232,12 @@ snapshots: cli-spinners@2.9.2: {} + cli-table3@0.6.1: + dependencies: + string-width: 4.2.3 + optionalDependencies: + colors: 1.4.0 + cli-table3@0.6.5: dependencies: string-width: 4.2.3 @@ -17785,6 +18249,11 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.2.0 + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.2 + cli-width@1.1.1: {} cli-width@4.1.0: {} @@ -17858,6 +18327,9 @@ snapshots: colors@1.0.3: {} + colors@1.4.0: + optional: true + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -17872,11 +18344,15 @@ snapshots: commander@4.1.1: {} + commander@6.2.1: {} + comment-json@5.0.0: dependencies: array-timsort: 1.0.3 esprima: 4.0.1 + common-tags@1.8.2: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -17953,6 +18429,8 @@ snapshots: core-js@3.49.0: {} + core-util-is@1.0.2: {} + core-util-is@1.0.3: {} cors@2.8.6: @@ -18084,6 +18562,48 @@ snapshots: cycle@1.0.3: {} + cypress@15.18.1: + dependencies: + '@cypress/request': 4.0.1 + '@cypress/xvfb': 1.2.4(supports-color@8.1.1) + '@types/sinonjs__fake-timers': 8.1.1 + '@types/sizzle': 2.3.10 + '@types/tmp': 0.2.6 + arch: 2.2.0 + blob-util: 2.0.2 + bluebird: 3.7.2 + buffer: 5.7.1 + cachedir: 2.4.0 + chalk: 4.1.2 + ci-info: 4.4.0 + cli-table3: 0.6.1 + commander: 6.2.1 + common-tags: 1.8.2 + dayjs: 1.11.21 + debug: 4.4.3(supports-color@8.1.1) + eventemitter2: 6.4.7 + execa: 4.1.0 + executable: 4.1.1 + fs-extra: 9.1.0 + hasha: 5.2.2 + is-installed-globally: 0.4.0 + listr2: 9.0.5 + lodash: 4.18.1 + log-symbols: 4.1.0 + minimist: 1.2.8 + ospath: 1.2.2 + pretty-bytes: 5.6.0 + process: 0.11.10 + proxy-from-env: 1.0.0 + request-progress: 3.0.0 + supports-color: 8.1.1 + systeminformation: 5.33.0 + tmp: 0.2.7 + tree-kill: 1.2.2 + tslib: 1.14.1 + untildify: 4.0.0 + yauzl: 3.4.0 + d3-array@3.2.4: dependencies: internmap: 2.0.3 @@ -18126,6 +18646,10 @@ snapshots: dargs@8.1.0: {} + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@6.0.2: {} @@ -18175,9 +18699,11 @@ snapshots: dependencies: ms: 2.0.0 - debug@3.2.7: + debug@3.2.7(supports-color@8.1.1): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 debug@4.4.3(supports-color@5.5.0): dependencies: @@ -18185,6 +18711,12 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + decamelize@1.2.0: {} decimal.js-light@2.5.1: {} @@ -18366,6 +18898,11 @@ snapshots: ebec@2.3.0: {} + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -18407,7 +18944,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -18427,7 +18964,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -18651,7 +19188,7 @@ snapshots: eslint-import-resolver-node@0.3.10: dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@8.1.1) is-core-module: 2.16.2 resolve: 2.0.0-next.7 transitivePeerDependencies: @@ -18660,7 +19197,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -18674,7 +19211,7 @@ snapshots: eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 @@ -18690,7 +19227,7 @@ snapshots: array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7 + debug: 3.2.7(supports-color@8.1.1) doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 @@ -18788,7 +19325,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -18854,6 +19391,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter2@6.4.7: {} + eventemitter2@6.4.9: {} eventemitter3@4.0.7: {} @@ -18886,6 +19425,18 @@ snapshots: unzipper: 0.10.14 uuid: 8.3.2 + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -18925,6 +19476,10 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + executable@4.1.1: + dependencies: + pify: 2.3.0 + exit-hook@1.1.1: {} exit@0.1.2: {} @@ -19000,7 +19555,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -19036,6 +19591,8 @@ snapshots: assign-symbols: 1.0.0 is-extendable: 1.0.1 + extend@3.0.2: {} + extglob@2.0.4: dependencies: array-unique: 0.3.2 @@ -19051,7 +19608,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19059,6 +19616,8 @@ snapshots: transitivePeerDependencies: - supports-color + extsprintf@1.3.0: {} + eyes@0.1.8: {} falsey@0.3.2: @@ -19200,7 +19759,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -19266,6 +19825,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forever-agent@0.6.1: {} + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0): dependencies: '@babel/code-frame': 7.29.7 @@ -19341,6 +19902,13 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-monkey@1.1.0: {} fs.realpath@1.0.0: {} @@ -19434,12 +20002,16 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color get-value@2.0.6: {} + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + giget@2.0.0: dependencies: citty: 0.1.6 @@ -19501,6 +20073,10 @@ snapshots: dependencies: ini: 4.1.1 + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + globals@13.24.0: dependencies: type-fest: 0.20.2 @@ -19623,6 +20199,11 @@ snapshots: is-number: 3.0.0 kind-of: 4.0.0 + hasha@5.2.2: + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -19702,24 +20283,32 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color + http-signature@1.4.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 2.0.2 + sshpk: 1.18.0 + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color + human-signals@1.1.1: {} + human-signals@2.1.0: {} human-signals@5.0.0: {} @@ -19795,6 +20384,8 @@ snapshots: inherits@2.0.4: {} + ini@2.0.0: {} + ini@4.1.1: {} input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): @@ -19960,6 +20551,11 @@ snapshots: dependencies: is-docker: 3.0.0 + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + is-interactive@1.0.0: {} is-interactive@2.0.0: {} @@ -20051,6 +20647,8 @@ snapshots: dependencies: which-typed-array: 1.1.22 + is-typedarray@1.0.0: {} + is-unicode-supported@0.1.0: {} is-unicode-supported@1.3.0: {} @@ -20124,7 +20722,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -20514,6 +21112,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsbn@0.1.1: {} + jsdom@25.0.1: dependencies: cssstyle: 4.6.0 @@ -20554,10 +21154,14 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stream@1.0.0: {} + json-stringify-safe@5.0.1: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -20626,6 +21230,13 @@ snapshots: dompurify: 3.4.8 html2canvas: 1.4.1 + jsprim@2.0.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -20778,7 +21389,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -20800,6 +21411,15 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + little-state-machine@4.8.1(react@19.2.6): dependencies: react: 19.2.6 @@ -21493,6 +22113,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + ospath@1.2.2: {} + outvariant@1.4.3: {} own-keys@1.0.1: @@ -21531,7 +22153,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -21658,8 +22280,7 @@ snapshots: perfect-debounce@1.0.0: {} - performance-now@2.1.0: - optional: true + performance-now@2.1.0: {} pg-cloudflare@1.4.0: optional: true @@ -21814,6 +22435,8 @@ snapshots: prettier@3.8.3: {} + pretty-bytes@5.6.0: {} + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -21860,7 +22483,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -21870,6 +22493,8 @@ snapshots: transitivePeerDependencies: - supports-color + proxy-from-env@1.0.0: {} + proxy-from-env@1.1.0: {} proxy-from-env@2.1.0: {} @@ -21887,7 +22512,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -22127,6 +22752,15 @@ snapshots: - '@babel/core' - react-is + react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - react-is + react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -22568,6 +23202,10 @@ snapshots: repeat-string@1.6.1: {} + request-progress@3.0.0: + dependencies: + throttleit: 1.0.1 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -22688,7 +23326,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -22806,7 +23444,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -22990,6 +23628,11 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} smob@1.6.2: {} @@ -23019,7 +23662,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23029,7 +23672,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -23040,7 +23683,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -23049,7 +23692,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -23061,7 +23704,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -23122,6 +23765,18 @@ snapshots: dependencies: frac: 1.1.2 + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + stable-hash@0.0.5: {} stack-trace@0.0.10: {} @@ -23202,6 +23857,11 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -23324,6 +23984,24 @@ snapshots: transitivePeerDependencies: - '@babel/core' + styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/stylis': 0.8.5 + '@emotion/unitless': 0.7.5 + babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) + css-to-react-native: 3.2.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 19.2.7 + shallowequal: 1.1.0 + supports-color: 5.5.0 + transitivePeerDependencies: + - '@babel/core' + styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -23349,7 +24027,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -23403,6 +24081,8 @@ snapshots: symbol-tree@3.2.4: {} + systeminformation@5.33.0: {} + tabbable@6.4.0: {} tagged-tag@1.0.0: {} @@ -23530,6 +24210,8 @@ snapshots: dependencies: any-promise: 1.3.0 + throttleit@1.0.1: {} + through2@2.0.5: dependencies: readable-stream: 2.3.8 @@ -23639,6 +24321,8 @@ snapshots: traverse@0.3.9: {} + tree-kill@1.2.2: {} + trim-canvas@0.1.2: {} ts-api-utils@2.5.0(typescript@5.9.3): @@ -23743,6 +24427,10 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + turbo@2.9.16: optionalDependencies: '@turbo/darwin-64': 2.9.16 @@ -23754,6 +24442,8 @@ snapshots: tw-animate-css@1.4.0: {} + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -23764,6 +24454,8 @@ snapshots: type-fest@0.21.3: {} + type-fest@0.8.1: {} + type-fest@4.41.0: {} type-fest@5.7.0: @@ -23842,7 +24534,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -23866,7 +24558,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -23968,6 +24660,8 @@ snapshots: until-async@3.0.2: {} + untildify@4.0.0: {} + unzipper@0.10.14: dependencies: big-integer: 1.6.52 @@ -24118,6 +24812,12 @@ snapshots: - '@types/react' - '@types/react-dom' + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + victory-vendor@36.9.2: dependencies: '@types/d3-array': 3.2.2 @@ -24161,7 +24861,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -24197,7 +24897,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -24538,6 +25238,10 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + year@0.2.1: {} yn@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ecf919f09..e5e748478 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - "apps/edr-passenger-web/*" - "packages/*" - "packages/config/*" + - "e2e/*" verifyDepsBeforeRun: warn allowBuilds: "@nestjs/core": true @@ -14,6 +15,7 @@ allowBuilds: argon2: true bcrypt: true core-js: true + cypress: true es5-ext: true esbuild: true highlight.js: true