From 57776c4fef0e5e5528aab547900f0d880fccd6bf Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Mon, 1 Jun 2026 16:28:04 +0300 Subject: [PATCH 1/7] feat(freight:backoffice): role and permission seeder --- .gitignore | 7 +- .../src/seed/edr-org.seeder.ts | 181 ++++++++++++++++-- apps/edr-freight-web/backoffice/src/App.tsx | 1 + 3 files changed, 169 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 132db3f2a..13865633d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,9 @@ coverage/ # OS/editor .DS_Store .idea/ -.vscode/ \ No newline at end of file +.vscode/ + +# emacs cache files +*~ +\#*\# +.\#* diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 63b464090..d03b9ef7f 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -2,21 +2,48 @@ import { Injectable, Logger } from "@nestjs/common"; import { Organization, OrganizationConfiguration, + Permission, Role, + RolePermission, } from "@tria-plc/iamapi-common"; -import { DataSource } from "typeorm"; +import { DataSource, EntityManager, In } from "typeorm"; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; const SEED_FLAG = "SEED_EDR_ORG"; -const EDR_ROLES = [ + +type SeedPermission = { + key: string; + name: { en: string }; +}; + +type SeedRole = { + key: string; + name: { en: string }; + permissions: SeedPermission[]; +}; + +type SeedOrganization = { + id: string; + key: string; +}; + +const SEED_ROLES: SeedRole[] = [ { key: "edr_employee", name: { en: "EDR Employee" }, + permissions: [ + // { key: "permission:key", name: { en: "Permission Name" } }, + { key: "permission:key", name: { en: "Permission Name" } }, + ], }, { key: "edr_customer", name: { en: "EDR Customer" }, + permissions: [ + // { key: "permission:key", name: { en: "Permission Name" } }, + { key: "permission:key", name: { en: "Permission Name" } }, + ], }, ]; @@ -27,24 +54,31 @@ export class EdrOrgSeeder { constructor(private readonly dataSource: DataSource) {} async run() { - const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; - - if (!shouldSeed) { + if (!this.shouldSeed()) { this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`); return; } - const roleRepository = this.dataSource.getRepository(Role); - const organizationRepository = this.dataSource.getRepository(Organization); - const organizationConfigurationRepository = - this.dataSource.getRepository(OrganizationConfiguration); + await this.dataSource.transaction(async (manager) => { + const organization = await this.ensureOrganization(manager); - await roleRepository.upsert(EDR_ROLES, { - conflictPaths: { key: true }, + await this.ensureOrganizationConfiguration(manager, organization.id); + await this.ensurePermissions(manager, SEED_ROLES); + await this.ensureRoles(manager, SEED_ROLES); + await this.ensureRolePermissions(manager, SEED_ROLES); }); - this.logger.log("Ensured EDR roles 'edr_employee' and 'edr_customer'"); + this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); + } + private shouldSeed() { + return process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + } + + private async ensureOrganization( + manager: EntityManager, + ): Promise { + const organizationRepository = manager.getRepository(Organization); let organization = await organizationRepository.findOne({ where: { key: EDR_ORG_KEY }, select: { id: true, key: true }, @@ -57,18 +91,31 @@ export class EdrOrgSeeder { isGovernmentOrganization: true, }); - organization = { + this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`); + + return { id: insertResult.identifiers[0]?.id as string, key: EDR_ORG_KEY, - } as Organization; - - this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`); - } else { - this.logger.log(`Ensured EDR organization '${EDR_ORG_KEY}'`); + }; } + this.logger.log(`Ensured EDR organization '${EDR_ORG_KEY}'`); + + return { + id: organization.id as string, + key: EDR_ORG_KEY, + }; + } + + private async ensureOrganizationConfiguration( + manager: EntityManager, + organizationId: string, + ) { + const organizationConfigurationRepository = + manager.getRepository(OrganizationConfiguration); + await organizationConfigurationRepository.upsert({ - organizationId: organization.id, + organizationId, canCreateBranchByItself: true, canStartReceivingRecord: true, }, { @@ -79,4 +126,100 @@ export class EdrOrgSeeder { `Ensured organization configuration for '${EDR_ORG_KEY}'`, ); } + + private collectPermissions(seedRoles: SeedRole[]) { + const permissionByKey = new Map(); + + for (const role of seedRoles) { + for (const permission of role.permissions) { + permissionByKey.set(permission.key, permission); + } + } + + return [...permissionByKey.values()]; + } + + private async ensurePermissions(manager: EntityManager, seedRoles: SeedRole[]) { + const permissions = this.collectPermissions(seedRoles); + + if (!permissions.length) { + this.logger.log("No EDR role permissions configured; skipping permission seed"); + return; + } + + await manager.getRepository(Permission).upsert(permissions, { + conflictPaths: { key: true }, + }); + + this.logger.log(`Ensured ${permissions.length} EDR permissions`); + } + + private async ensureRoles(manager: EntityManager, seedRoles: SeedRole[]) { + await manager.getRepository(Role).upsert( + seedRoles.map(({ key, name }) => ({ key, name })), + { + conflictPaths: { key: true }, + }, + ); + + this.logger.log( + `Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`, + ); + } + + private async ensureRolePermissions( + manager: EntityManager, + seedRoles: SeedRole[], + ) { + const permissions = this.collectPermissions(seedRoles); + + if (!permissions.length) { + return; + } + + const roleRepository = manager.getRepository(Role); + const permissionRepository = manager.getRepository(Permission); + const rolePermissionRepository = manager.getRepository(RolePermission); + + const roles = await roleRepository.find({ + where: { key: In(seedRoles.map((role) => role.key)) }, + select: { id: true, key: true }, + }); + const seededPermissions = await permissionRepository.find({ + where: { key: In(permissions.map((permission) => permission.key)) }, + select: { id: true, key: true }, + }); + + const roleByKey = new Map(roles.map((role) => [role.key, role])); + const permissionByKey = new Map( + seededPermissions.map((permission) => [permission.key, permission]), + ); + + const rolePermissions = seedRoles.flatMap((role) => { + const seededRole = roleByKey.get(role.key); + + if (!seededRole) { + throw new Error(`missing_role:${role.key}`); + } + + return role.permissions.map((permission) => { + const seededPermission = permissionByKey.get(permission.key); + + if (!seededPermission) { + throw new Error(`missing_permission:${permission.key}`); + } + + return { + roleId: seededRole.id, + permissionId: seededPermission.id, + }; + }); + }); + + await rolePermissionRepository.upsert(rolePermissions, { + conflictPaths: { roleId: true, permissionId: true }, + }); + + this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`); + } } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 3ab4f2e54..85e14d9eb 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -13,6 +13,7 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; +import { RuleEnginePage } from "./pages/ruleEngine/RuleEngine"; const sidebarItems: SidebarItem[] = [ { From ff3cbed7478eda65c1af9a03a7c6449581a01e0e Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Mon, 1 Jun 2026 15:44:23 +0300 Subject: [PATCH 2/7] feat(freight): Introduce booking reference data endpoint & schemas --- .../portal/src/services/api.ts | 6 ++ .../portal/src/services/bookings.service.ts | 4 ++ packages/types/src/freight/index.ts | 58 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index ac1a708da..4300cd56b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -137,6 +137,12 @@ export const api = { bookingsService.create, ), + referenceData: endpoint( + "bookings", + "referenceData", + bookingsService.getReferenceData, + ), + remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) => bookingsService.remove(id), ), diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 7d1ef1d2f..d4342eba6 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -17,6 +17,10 @@ export const bookingsService = { const { data } = await client.post("/api/bookings", payload); return data.data; }, + getReferenceData: async (): Promise => { + const { data } = await client.get("/bookings/reference-data"); + return data.data; + }, remove: async (id: string): Promise => { await client.delete(`/bookings/${id}`); }, diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 9439e3aeb..f4a0874ac 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -216,6 +216,64 @@ export interface IInvoice extends BaseEntity { dueAt: string; } +// ── Reference Data (booking form catalog) ────────────────────────────────────── + +export interface BookingReferenceYard { + id: string; + name: string; + code: string; + country: string; +} + +export interface BookingReferenceContainerType { + id: string; + name: string; + code: string; + is_reefer: boolean; + wagons_per_unit: number; +} + +export interface BookingReferenceContainerSizeGroup { + size: string; + types: BookingReferenceContainerType[]; +} + +export interface BookingReferenceService { + id: string; + name: string; + code: string; +} + +export interface BookingReferenceShippingLine { + id: string; + name: string; + code: string; +} + +export interface BookingReferenceCargoTypeChild { + id: string; + name: string; + code: string; + show_free_text_box: boolean; +} + +export interface BookingReferenceCargoTypeGroup { + id: string; + name: string; + code: string; + children?: BookingReferenceCargoTypeChild[]; +} + +export interface BookingReferenceData { + yard: BookingReferenceYard[]; + containers: BookingReferenceContainerSizeGroup[]; + service: BookingReferenceService[]; + shipping_line: BookingReferenceShippingLine[]; + cargo_type: BookingReferenceCargoTypeGroup[]; +} + +// ── DTOs ─────────────────────────────────────────────────────────────────────── + export interface CreateBookingDto { reference: string; customerId: string; From c880e8c7f921b396d6f4660001de6b2836e2fbbb Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Mon, 1 Jun 2026 16:01:46 +0300 Subject: [PATCH 3/7] feat(api): Add endpoint for booking reference data --- .../src/pages/bookings/NewBookingPage.tsx | 23 ++-- .../bookings/new-booking-form/step4-route.tsx | 101 ++++++++---------- .../new-booking-form/step5-cargo-details.tsx | 41 +++++-- .../portal/src/services/bookings.service.ts | 2 +- 4 files changed, 96 insertions(+), 71 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 7792c712a..2cf59dd40 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,10 +1,11 @@ import { useMemo, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { Check, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@edr/ui-common"; +import type { Freight } from "@edr/types"; import Breadcrumbs from "@/components/Breadcrumbs"; import { api } from "@/services/api"; import type { CreateBookingPayload } from "@/services/bookings.service"; @@ -32,6 +33,10 @@ export default function NewBookingPage() { const queryClient = useQueryClient(); const [step, setStep] = useState(1); const { customer } = useAuth(); + const { data: referenceData } = useQuery( + api.bookings.referenceData.queryOptions(), + ); + const createMutation = useMutation({ mutationFn: (payload: CreateBookingPayload) => api.bookings.create.call(payload), @@ -49,18 +54,12 @@ export default function NewBookingPage() { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); - const containers = form.watch("containers"); const direction = useMemo( () => getRouteDirection(originYard, destinationYard), [originYard, destinationYard], ); - const wagons = useMemo(() => { - if (!containers || containers.length === 0) return null; - return calcWagons(containers); - }, [containers]); - async function handleContinue() { const valid = await form.trigger(stepFields[step], { shouldFocus: true }); if (!valid) return; @@ -181,9 +180,15 @@ export default function NewBookingPage() {
{step === 1 && } {step === 2 && } - {step === 3 && } + {step === 3 && ( + + )} {step === 4 && ( - + )} {step === 5 && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 3d98fc854..89c2a648a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,37 +1,48 @@ import { Controller, type UseFormReturn } from "react-hook-form"; import { Flame, MapPin, Snowflake } from "lucide-react"; import { Field, SelectItem, Separator, Switch } from "@edr/ui-common"; +import type { Freight } from "@edr/types"; import { - SHIPPING_LINES, type BookingFormValues, getRouteDirection, - STATIONS, } from "./schema"; import { AlertBox, SelectField, - SelectOptions, StepHeader, StepLabel, } from "./shared"; -import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings"; -import { DropdownOption } from "@/types/dropdownSettings"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; type BookingForm = UseFormReturn; -const STATION_DROPDOWN_CODE = "stations_ter"; - -export function Step4Route({ form }: { form: BookingForm }) { +export function Step4Route({ + form, + referenceData, +}: { + form: BookingForm; + referenceData?: Freight.BookingReferenceData; +}) { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); - const { - data: stationSetting, - isLoading: stationsLoading, - isError: stationsError, - error: stationsFetchError, - } = useDropdownSettingByCode(STATION_DROPDOWN_CODE); - const stationOptions = getStationOptions(stationSetting?.children); + + const yardOptions = useMemo(() => { + if (!referenceData?.yard) return []; + return referenceData.yard.map((y) => ({ + value: y.name, + label: y.name, + country: y.country, + })); + }, [referenceData]); + + const shippingLineOptions = useMemo(() => { + if (!referenceData?.shipping_line) return []; + return referenceData.shipping_line.map((sl) => ({ + value: sl.name, + label: sl.name, + })); + }, [referenceData]); + const direction = getRouteDirection(originYard, destinationYard); const directionStyle: Record = { export: "bg-sky-50 text-sky-800 border-sky-200", @@ -43,7 +54,6 @@ export function Step4Route({ form }: { form: BookingForm }) { import: "Import workflow (outside country to inside country)", domestic: "Domestic corridor", }; - const stationSelectDisabled = stationsLoading || stationOptions.length === 0; useEffect(() => { if (direction === "domestic") { @@ -51,6 +61,8 @@ export function Step4Route({ form }: { form: BookingForm }) { } }, [direction]); + const stationSelectDisabled = yardOptions.length === 0; + return (
- )} @@ -91,21 +102,17 @@ export function Step4Route({ form }: { form: BookingForm }) { placeholder="Select destination..." disabled={stationSelectDisabled} > - )} />
- {stationsError && ( - - Failed to load stations from the API.{" "} - {stationsFetchError instanceof Error - ? stationsFetchError.message - : "Try again later."} + {!referenceData && ( + + Loading reference data... )} {direction && ( @@ -129,7 +136,11 @@ export function Step4Route({ form }: { form: BookingForm }) { label="Shipping Line" placeholder="Select shipping line..." > - + {shippingLineOptions.map((sl) => ( + + {sl.label} + + ))} )} /> @@ -179,23 +190,17 @@ export function Step4Route({ form }: { form: BookingForm }) { ); } -function getStationOptions(options?: DropdownOption[]): DropdownOption[] { - return [...(options ?? [])].sort((a, b) => a.order - b.order); -} - -function StationSelectOptions({ +function YardSelectOptions({ options, excludeValue, - isLoading, }: { - options: DropdownOption[]; + options: Array<{ value: string; label: string; country: string }>; excludeValue: string; - isLoading: boolean; }) { - if (isLoading) { + if (options.length === 0) { return ( - - Loading stations... + + No yards available ); } @@ -204,22 +209,10 @@ function StationSelectOptions({ (option) => option.value !== excludeValue, ); - if (availableOptions.length === 0) { - return ( - - No stations available - - ); - } - return ( <> {availableOptions.map((option) => ( - + {option.label} ))} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index 1898feb73..58af0897b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -1,10 +1,9 @@ +import { useMemo } from "react"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react"; import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common"; +import type { Freight } from "@edr/types"; import { - BREAK_BULK_TYPES, - BULK_COMMODITIES, - CONTAINER_TYPES, calcWagons, type BookingFormValues, type RouteDirection, @@ -13,7 +12,7 @@ import { AlertBox, OptionCard, SelectField, - SelectOptions, + SelectItem, StepHeader, StepLabel, } from "./shared"; @@ -23,9 +22,11 @@ type BookingForm = UseFormReturn; export function Step5CargoDetails({ form, direction, + referenceData, }: { form: BookingForm; direction: RouteDirection; + referenceData?: Freight.BookingReferenceData; }) { const cargoType = form.watch("cargoType"); const freightType = form.watch("freightType"); @@ -38,6 +39,20 @@ export function Step5CargoDetails({ name: "containers", }); + const containerTypeOptions = useMemo(() => { + if (!referenceData?.containers) return []; + return referenceData.containers.flatMap((group) => + group.types.map((t) => t.name), + ); + }, [referenceData]); + + const bulkCommodityOptions = useMemo(() => { + if (!referenceData?.cargo_type) return []; + return referenceData.cargo_type.flatMap((group) => + group.children?.map((c) => c.name) ?? [], + ); + }, [referenceData]); + function getOverweightAlert( type: "20ft" | "40ft", vgm: number, @@ -181,7 +196,11 @@ export function Step5CargoDetails({ label="Commodity *" placeholder="Select commodity *" > - + {bulkCommodityOptions.map((option) => ( + + {option} + + ))} )} /> @@ -216,7 +235,11 @@ export function Step5CargoDetails({ label="Break-bulk type *" placeholder="Select type *" > - + {bulkCommodityOptions.map((option) => ( + + {option} + + ))} )} /> @@ -408,7 +431,11 @@ export function Step5CargoDetails({ label="Container Type *" placeholder="Select type..." > - + {containerTypeOptions.map((option) => ( + + {option} + + ))} )} /> diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index d4342eba6..dd4c9f0dd 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -18,7 +18,7 @@ export const bookingsService = { return data.data; }, getReferenceData: async (): Promise => { - const { data } = await client.get("/bookings/reference-data"); + const { data } = await client.get("/api/bookings/reference-data"); return data.data; }, remove: async (id: string): Promise => { From 217f1bf097e0dfd552345bd6d1ad290544cead0b Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Mon, 1 Jun 2026 16:10:06 +0300 Subject: [PATCH 4/7] feat(bookings): Implement skeleton loaders for new booking form steps --- .../src/pages/bookings/NewBookingPage.tsx | 5 +- .../bookings/new-booking-form/step4-route.tsx | 118 ++++++++++-------- .../new-booking-form/step5-cargo-details.tsx | 24 +++- packages/ui-common/src/index.ts | 1 + 4 files changed, 95 insertions(+), 53 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 2cf59dd40..a1cbc6376 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -33,7 +33,7 @@ export default function NewBookingPage() { const queryClient = useQueryClient(); const [step, setStep] = useState(1); const { customer } = useAuth(); - const { data: referenceData } = useQuery( + const { data: referenceData, isLoading: refDataLoading } = useQuery( api.bookings.referenceData.queryOptions(), ); @@ -181,13 +181,14 @@ export default function NewBookingPage() { {step === 1 && } {step === 2 && } {step === 3 && ( - + )} {step === 4 && ( )} {step === 5 && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 89c2a648a..2713197a0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,27 +1,28 @@ +import { useEffect, useMemo } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; import { Flame, MapPin, Snowflake } from "lucide-react"; -import { Field, SelectItem, Separator, Switch } from "@edr/ui-common"; +import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common"; import type { Freight } from "@edr/types"; import { type BookingFormValues, getRouteDirection, } from "./schema"; import { - AlertBox, SelectField, StepHeader, StepLabel, } from "./shared"; -import { useEffect, useMemo } from "react"; type BookingForm = UseFormReturn; export function Step4Route({ form, referenceData, + isLoading, }: { form: BookingForm; referenceData?: Freight.BookingReferenceData; + isLoading?: boolean; }) { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); @@ -70,52 +71,50 @@ export function Step4Route({ description="Select the origin and destination yards." /> -
- Route -
- ( - - - - )} - /> - ( - - - - )} - /> -
- {!referenceData && ( - - Loading reference data... - - )} - {direction && ( + {isLoading ? ( + + ) : ( +
+ Route +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ {direction && (
@@ -123,7 +122,8 @@ export function Step4Route({ {directionLabel[direction]}
)} -
+
+ )} {direction && direction != "domestic" && ( +
+
+ + +
+
+ + +
+
+ +
+ ); +} + function YardSelectOptions({ options, excludeValue, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index 58af0897b..0105f54c3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react"; -import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common"; +import { Button, Field, FieldError, FieldLabel, Input, Skeleton } from "@edr/ui-common"; import type { Freight } from "@edr/types"; import { calcWagons, @@ -23,10 +23,12 @@ export function Step5CargoDetails({ form, direction, referenceData, + isLoading, }: { form: BookingForm; direction: RouteDirection; referenceData?: Freight.BookingReferenceData; + isLoading?: boolean; }) { const cargoType = form.watch("cargoType"); const freightType = form.watch("freightType"); @@ -69,6 +71,26 @@ export function Step5CargoDetails({ return null; } + if (isLoading) { + return ( +
+ +
+ +
+ + +
+ + +
+
+ ); + } + return (
Date: Mon, 1 Jun 2026 16:37:28 +0300 Subject: [PATCH 5/7] refactor(bookings): Migrate new booking API payload to use reference data IDs --- .../src/pages/bookings/NewBookingPage.tsx | 197 +++++++++++++----- .../pages/bookings/new-booking-form/schema.ts | 1 + .../bookings/new-booking-form/shared.tsx | 4 +- .../new-booking-form/step1-contract-type.tsx | 12 +- .../new-booking-form/step2-service-type.tsx | 8 +- .../bookings/new-booking-form/step4-route.tsx | 27 +-- .../new-booking-form/step5-cargo-details.tsx | 20 +- .../new-booking-form/step8-review.tsx | 8 +- packages/types/src/freight/index.ts | 49 ++--- 9 files changed, 218 insertions(+), 108 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index a1cbc6376..5be286d76 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -3,13 +3,21 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; -import { Check, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; +import { + AlertCircle, + Check, + CheckCircle2, + ChevronLeft, + ChevronRight, + LoaderCircle, +} from "lucide-react"; import { Button } from "@edr/ui-common"; import type { Freight } from "@edr/types"; import Breadcrumbs from "@/components/Breadcrumbs"; import { api } from "@/services/api"; import type { CreateBookingPayload } from "@/services/bookings.service"; import { + BookingFormInputValues, STEPS, bookingFormSchema, calcWagons, @@ -46,7 +54,7 @@ export default function NewBookingPage() { }, }); - const form = useForm({ + const form = useForm({ defaultValues: initialBookingFormValues, resolver: zodResolver(bookingFormSchema), mode: "onChange", @@ -77,8 +85,6 @@ export default function NewBookingPage() { return; } - const reference = data.previousContractRef; - const totalWeight = data.cargoType === "container" ? data.containers.reduce( @@ -87,59 +93,115 @@ export default function NewBookingPage() { ) : Number(data.cargoWeight || 0); - const apiPayload = { - reference, - customerId: customer!.id, - scheduledDate: new Date().toISOString().slice(0, 10), - totalAmount: 0, - contractType: - data.contractType.toUpperCase() as CreateBookingPayload["contractType"], - previousContractId: data.previousContractRef || undefined, - serviceType: - data.service.serviceType === "rail" - ? "RAIL_ONLY" - : "RAIL_AND_FORWARDING", - ...(data.service.serviceType === "rail" - ? {} - : { - firstMileEnabled: data.firstMile.enabled, - firstMilePickupAddress: data.firstMile.pickUpAddress ?? undefined, - lastMileEnabled: data.lastMile.enabled, - lastMileDeliveryAddress: data.lastMile.deliveryAddress ?? undefined, - equipmentReturn: - data.equipmentReturn === "with_return" - ? ("WITH_RETURN" as const) - : ("WITHOUT_RETURN" as const), - customsClearingEnabled: data.customsClearingEnabled, - }), - originStation: data.originYard, - destinationStation: data.destinationYard, - cargoTotalWeightVgm: totalWeight, - freightType: data.cargoType === "container" ? "BREAK_BULK" : "BULK", - freightSubtype: - data.cargoType === "container" - ? undefined - : data.freightType === "bulk" + // ── Reference data lookups ────────────────────────────────────────── + const yards = referenceData?.yard ?? []; + const services = referenceData?.service ?? []; + const shippingLines = referenceData?.shipping_line ?? []; + const cargoTree = referenceData?.cargo_type ?? []; + const containerGroups = referenceData?.containers ?? []; + + const findYardId = (name: string): string => + yards.find((y) => y.name === name)?.id ?? ""; + + const findServiceTypeId = (): string => { + const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING"; + return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? ""; + }; + + const findShippingLineId = (name: string): string | undefined => + shippingLines.find((l) => l.name === name)?.id; + + const findCargoTypeId = (name: string): string | undefined => { + for (const group of cargoTree) { + const child = group.children?.find((c) => c.name === name); + if (child) return child.id; + } + return undefined; + }; + + const findContainerCargoTypeId = (): string => { + const group = cargoTree.find( + (g) => g.code === "CONTAINER" || /container/i.test(g.name), + ); + console.log(group, cargoTree); + return group?.id ?? ""; + }; + + const findContainerTypeId = (name: string): string => { + for (const group of containerGroups) { + const ct = group.types.find((t) => t.name === name); + if (ct) return ct.id; + } + return ""; + }; + + const cargoTypeId = + data.cargoType === "container" + ? findContainerCargoTypeId() + : (findCargoTypeId( + data.freightType === "bulk" ? data.bulkCommodity : data.breakBulkType, - isHazardous: data.isHazardous, - isRefrigerated: data.isRefrigerated, + ) ?? ""); + + const cargoFreeText = + data.cargoType === "container" + ? undefined + : data.freightType === "bulk" && data.bulkCommodity === "Others" + ? data.bulkCommodityOther + : data.freightType === "break_bulk" && data.breakBulkType === "Others" + ? data.breakBulkTypeOther + : undefined; + + // ── Build API payload ─────────────────────────────────────────────── + const apiPayload: CreateBookingPayload = { + scheduledDate: new Date().toISOString().slice(0, 10), + contractType: + data.contractType.toUpperCase() as CreateBookingPayload["contractType"], + serviceTypeId: findServiceTypeId(), + equipmentReturn: + data.equipmentReturn === "with_return" + ? "WITH_RETURN" + : "WITHOUT_RETURN", + originYardId: findYardId(data.originYard), + destinationYardId: findYardId(data.destinationYard), tradeDirection: - getRouteDirection(data.originYard, data.destinationYard) === "export" + direction === "export" ? "EXPORT" - : "IMPORT", + : direction === "domestic" + ? "DOMESTIC" + : "IMPORT", + cargoTypeId, + cargoTotalWeightVgm: totalWeight, + isHazardous: data.isHazardous, paymentCurrency: "USD", allowConsolidation: data.consolidationEnabled, - ...(data.cargoType === "container" && data.containers.length > 0 - ? { - containers: data.containers.map((c) => ({ - type: c.type === "40ft" ? ("40FT" as const) : ("20FT" as const), - qty: Number(c.qty || 1), - vgm: Number(c.vgm || 0), - })), - } + containers: + data.cargoType === "container" + ? data.containers.map((c) => ({ + containerTypeId: findContainerTypeId(c.containerType), + quantity: Number(c.qty || 1), + vgmPerUnitTons: Number(c.vgm || 0), + })) + : [], + ...(customer ? { customerId: customer.id } : {}), + ...(data.previousContractRef + ? { previousContractId: data.previousContractRef } : {}), - } satisfies CreateBookingPayload; + ...(data.contractType === "renewal" && data.previousContractRef + ? { pnrCode: data.previousContractRef } + : {}), + ...(data.serviceType === "rail_forwarding" && data.firstMile.enabled + ? { firstMilePickupAddress: data.firstMile.pickUpAddress } + : {}), + ...(data.serviceType === "rail_forwarding" && data.lastMile.enabled + ? { lastMileDeliveryAddress: data.lastMile.deliveryAddress } + : {}), + ...(data.shippingLine + ? { shippingLineId: findShippingLineId(data.shippingLine) } + : {}), + ...(cargoFreeText ? { cargoFreeText } : {}), + }; createMutation.mutate(apiPayload); }); @@ -178,10 +240,27 @@ export default function NewBookingPage() {
+ {createMutation.isError && ( +
+ +
+

Submission failed

+

+ {createMutation.error instanceof Error + ? createMutation.error.message + : "An unexpected error occurred. Please try again."} +

+
+
+ )} {step === 1 && } {step === 2 && } {step === 3 && ( - + )} {step === 4 && ( ) : ( - )}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 4da8ed6ef..d994f93f7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -249,6 +249,7 @@ export const bookingFormSchema = z }); export type BookingFormValues = z.infer; +export type BookingFormInputValues = z.input; export const initialBookingFormValues: DeepPartial = { previousContractRef: "", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx index 120aa48a3..a731d081c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx @@ -21,7 +21,7 @@ import { SelectTrigger, SelectValue, } from "@edr/ui-common"; -import type { BookingFormValues } from "./schema"; +import type { BookingFormInputValues, BookingFormValues } from "./schema"; import { cn } from "@/lib/utils"; export function OptionFieldError({ error }: { error?: { message?: string } }) { @@ -122,7 +122,7 @@ export function SelectField({ disabled, children, }: { - field: ControllerRenderProps; + field: ControllerRenderProps; error?: RhfFieldError; label: string; placeholder: string; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx index 62a66d0e1..717de081e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx @@ -1,7 +1,11 @@ import { Controller, type UseFormReturn } from "react-hook-form"; import { FileText, RefreshCw } from "lucide-react"; import { Field } from "@edr/ui-common"; -import { MOCK_VALID_CONTRACTS, type BookingFormValues } from "./schema"; +import { + BookingFormInputValues, + MOCK_VALID_CONTRACTS, + type BookingFormValues, +} from "./schema"; import { AlertBox, OptionCard, @@ -11,7 +15,11 @@ import { StepHeader, } from "./shared"; -type BookingForm = UseFormReturn; +type BookingForm = UseFormReturn< + BookingFormInputValues, + any, + BookingFormValues +>; export function Step1ContractType({ form }: { form: BookingForm }) { const contractType = form.watch("contractType"); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index e9bd1d331..d202ce5c6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -2,10 +2,14 @@ import { useEffect, useRef } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; import { FileText, Package, Train, Truck } from "lucide-react"; import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common"; -import { type BookingFormValues } from "./schema"; +import { BookingFormInputValues, type BookingFormValues } from "./schema"; import { OptionCard, OptionFieldError, StepHeader } from "./shared"; -type BookingForm = UseFormReturn; +type BookingForm = UseFormReturn< + BookingFormInputValues, + any, + BookingFormValues +>; export function Step2ServiceType({ form }: { form: BookingForm }) { const serviceType = form.watch("serviceType"); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 2713197a0..f42e19449 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -4,16 +4,17 @@ import { Flame, MapPin, Snowflake } from "lucide-react"; import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common"; import type { Freight } from "@edr/types"; import { + BookingFormInputValues, type BookingFormValues, getRouteDirection, } from "./schema"; -import { - SelectField, - StepHeader, - StepLabel, -} from "./shared"; +import { SelectField, StepHeader, StepLabel } from "./shared"; -type BookingForm = UseFormReturn; +type BookingForm = UseFormReturn< + BookingFormInputValues, + any, + BookingFormValues +>; export function Step4Route({ form, @@ -115,13 +116,13 @@ export function Step4Route({ />
{direction && ( -
- - {directionLabel[direction]} -
- )} +
+ + {directionLabel[direction]} +
+ )}
)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index 0105f54c3..758cdede0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -1,9 +1,17 @@ import { useMemo } from "react"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react"; -import { Button, Field, FieldError, FieldLabel, Input, Skeleton } from "@edr/ui-common"; +import { + Button, + Field, + FieldError, + FieldLabel, + Input, + Skeleton, +} from "@edr/ui-common"; import type { Freight } from "@edr/types"; import { + BookingFormInputValues, calcWagons, type BookingFormValues, type RouteDirection, @@ -17,7 +25,11 @@ import { StepLabel, } from "./shared"; -type BookingForm = UseFormReturn; +type BookingForm = UseFormReturn< + BookingFormInputValues, + any, + BookingFormValues +>; export function Step5CargoDetails({ form, @@ -50,8 +62,8 @@ export function Step5CargoDetails({ const bulkCommodityOptions = useMemo(() => { if (!referenceData?.cargo_type) return []; - return referenceData.cargo_type.flatMap((group) => - group.children?.map((c) => c.name) ?? [], + return referenceData.cargo_type.flatMap( + (group) => group.children?.map((c) => c.name) ?? [], ); }, [referenceData]); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 6f72dc63f..2c1b2e4d0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -11,13 +11,17 @@ import { Textarea, } from "@edr/ui-common"; import { + BookingFormInputValues, type BookingFormValues, type RouteDirection, - type WagonCalcResult, } from "./schema"; import { StepHeader } from "./shared"; -type BookingForm = UseFormReturn; +type BookingForm = UseFormReturn< + BookingFormInputValues, + any, + BookingFormValues +>; export function Step8Review({ form, diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index f4a0874ac..2dce17419 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -274,45 +274,36 @@ export interface BookingReferenceData { // ── DTOs ─────────────────────────────────────────────────────────────────────── +export interface CreateBookingContainerDto { + containerTypeId: string; + quantity: number; + vgmPerUnitTons: number; +} + export interface CreateBookingDto { - reference: string; - customerId: string; + reference?: string; + customerId?: string; trainId?: string; scheduledDate: string; - totalAmount: number; - paymentStatus?: string; contractType: "NEW" | "RENEWAL"; previousContractId?: string; - serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING"; - - firstMileEnabled?: boolean; + serviceTypeId: string; firstMilePickupAddress?: string; - lastMileEnabled?: boolean; lastMileDeliveryAddress?: string; - - equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN"; - customsClearingEnabled?: boolean; - originStation: string; - destinationStation: string; + equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA"; + originYardId: string; + destinationYardId: string; + tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC"; + cargoTypeId: string; + cargoFreeText?: string; + shippingLineId?: string; cargoTotalWeightVgm: number; - - freightType: "BULK" | "BREAK_BULK"; - freightSubtype?: string; - isHazardous?: boolean; - isRefrigerated?: boolean; - - tradeDirection: "IMPORT" | "EXPORT"; - paymentCurrency: string; - allowConsolidation?: boolean; - + paymentCurrency: "ETB" | "USD"; + pnrCode?: string; startDate?: string; endDate?: string; financialTerms?: string; - - containers?: Array<{ - type: "20FT" | "40FT"; - qty: number; - vgm: number; - }>; + containers: CreateBookingContainerDto[]; + allowConsolidation?: boolean; } From fab6819151b220ae41fa48c294b74ebe0a020226 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Mon, 1 Jun 2026 17:17:54 +0300 Subject: [PATCH 6/7] feat(freight:backoffice): temp add user UI --- .../backoffice/backoffice.controller.ts | 11 + .../modules/backoffice/backoffice.module.ts | 16 +- .../modules/backoffice/backoffice.service.ts | 144 ++++ .../dto/create-organization-user.dto.ts | 34 + apps/edr-freight-web/backoffice/src/App.tsx | 10 + .../user-management/UserManagementPage.tsx | 1 + .../dashboard/user-management/UsersPage.tsx | 754 +++++++++++++++++- 7 files changed, 964 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts index 3459de0f9..41e303985 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -4,11 +4,13 @@ import { Get, Param, ParseUUIDPipe, + Post, Put, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { BackofficeService } from "./backoffice.service"; +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; @ApiTags("backoffice") @@ -16,6 +18,15 @@ import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto export class BackofficeController { constructor(private readonly backofficeService: BackofficeService) {} + @Post("organizations/:orgId/users") + @ApiOperation({ summary: "Create an organization user without assigning positions" }) + createOrganizationUser( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Body() dto: CreateOrganizationUserDto, + ) { + return this.backofficeService.createOrganizationUser(organizationId, dto); + } + @Get("organizations/:orgId/employee-users/:userId/roles") @ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" }) getEmployeeUserRoles( diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts index 18b4e6947..90c1a7c79 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts @@ -1,5 +1,10 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { + Employee, + Organization, + UserCredential, +} from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; @@ -9,7 +14,16 @@ import { BackofficeController } from "./backoffice.controller"; import { BackofficeService } from "./backoffice.service"; @Module({ - imports: [TypeOrmModule.forFeature([Role, UserRole, User])], + imports: [ + TypeOrmModule.forFeature([ + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, + ]), + ], controllers: [BackofficeController], providers: [BackofficeService], exports: [BackofficeService], diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index b5206be7d..d6b68982f 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -4,21 +4,29 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; import { DataSource, In, IsNull, Repository } from "typeorm"; +import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; + const RESERVED_ROLE_KEYS = new Set([ "super_admin", "organization_admin", "unit_admin", ]); +const DEFAULT_USER_PASSWORD = "12345678"; @Injectable() export class BackofficeService { constructor( + @InjectRepository(Organization) + private readonly organizationRepository: Repository, @InjectRepository(Role) private readonly roleRepository: Repository, @InjectRepository(UserRole) @@ -28,6 +36,142 @@ export class BackofficeService { private readonly dataSource: DataSource, ) {} + async createOrganizationUser( + organizationId: string, + dto: CreateOrganizationUserDto, + ) { + const organizationExists = await this.organizationRepository.exists({ + where: { id: organizationId }, + }); + + if (!organizationExists) { + throw new NotFoundException("organization_not_found"); + } + + const email = dto.email.trim().toLowerCase(); + const username = dto.username.trim().toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + const name = { + en: dto.name.en.trim(), + ...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}), + }; + + const existingUsers = await this.userRepository.find({ + where: [{ email }, { username }], + select: { id: true, email: true, username: true }, + }); + + const emailUser = existingUsers.find((user) => user.email === email); + const usernameUser = existingUsers.find((user) => user.username === username); + + if (emailUser && usernameUser && emailUser.id !== usernameUser.id) { + throw new BadRequestException("email_or_username_already_in_use"); + } + + const existingUser = emailUser ?? usernameUser; + const hashedPassword = await hashPassword(DEFAULT_USER_PASSWORD); + + return this.dataSource.transaction(async (manager) => { + let user = existingUser; + + if (!user) { + user = await manager.getRepository(User).save( + manager.getRepository(User).create({ + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } else { + await manager.getRepository(User).update( + { id: user.id }, + { + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }, + ); + } + + const activeCredentialExists = await manager.getRepository(UserCredential).exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await manager.getRepository(UserCredential).insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + let employee = await manager.getRepository(Employee).findOne({ + where: { + userId: user.id, + organizationId, + isCurrent: true, + }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + + if (!employee) { + const insertResult = await manager.getRepository(Employee).insert({ + userId: user.id, + organizationId, + isCurrent: true, + name, + }); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: insertResult.identifiers[0]?.id as string }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } else { + await manager.getRepository(Employee).update( + { id: employee.id }, + { name }, + ); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: employee.id }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } + + if (!employee) { + throw new NotFoundException("employee_create_failed"); + } + + return employee; + }); + } + async getEmployeeUserRoles(organizationId: string, userId: string) { await this.assertUserBelongsToOrganization(organizationId, userId); diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts new file mode 100644 index 000000000..1623ac075 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; + +class CreateOrganizationUserNameDto { + @ApiProperty() + @IsString() + @MinLength(1) + en!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + am?: string; +} + +export class CreateOrganizationUserDto { + @ApiProperty() + @IsEmail() + email!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + username!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + phoneNumber?: string; + + @ApiProperty({ type: CreateOrganizationUserNameDto }) + @IsObject() + name!: CreateOrganizationUserNameDto; +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 85e14d9eb..d2096a645 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -8,6 +8,7 @@ import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; +import UsersPage from "./pages/dashboard/user-management/UsersPage"; import LoadingScreen from "./components/LoadingScreen"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; @@ -26,6 +27,10 @@ const sidebarItems: SidebarItem[] = [ href: "/dashboard/user-management", icon: , children: [ + { + label: "Users", + href: "/dashboard/user-management/users", + }, { label: "Employees", href: "/dashboard/user-management/employees", @@ -80,6 +85,10 @@ const DashboardShell = () => { href: "/dashboard/user-management", icon: , children: [ + { + label: "Users", + href: "/dashboard/user-management/users", + }, { label: "Employees", href: "/dashboard/user-management/employees", @@ -160,6 +169,7 @@ const App = () => { }> } /> } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx index 3d490fcf4..88fa34647 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx @@ -1433,6 +1433,7 @@ const UserManagementPage = () => { No organizations available. )} +