From 2ccbfe324fd23cba9eec6809f9012d63f8e71d3c Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Thu, 28 May 2026 10:44:41 +0300 Subject: [PATCH 01/21] feat(freight:backoffice): Added permissions and roles pages under user-management dashboard --- apps/edr-freight-web/backoffice/src/App.tsx | 14 + .../user-management/PermissionsPage.tsx | 202 ++++++++++++ .../dashboard/user-management/RolesPage.tsx | 291 +++++++++++++++++- 3 files changed, 502 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6d28d8ac9..10a69ae5e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -5,6 +5,8 @@ import { LayoutDashboard, Network } from "lucide-react"; import { useAuth } from "./auth/useAuth"; import LoginPage from "./pages/auth/LoginPage"; import OverviewPage from "./pages/dashboard/OverviewPage"; +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 LoadingScreen from "./components/LoadingScreen"; @@ -18,6 +20,16 @@ const sidebarItems: SidebarItem[] = [ label: "User management", href: "/dashboard/user-management", icon: , + children: [ + { + label: "Permissions", + href: "/dashboard/user-management/permissions", + }, + { + label: "Roles", + href: "/dashboard/user-management/roles", + }, + ], }, ]; @@ -67,6 +79,8 @@ const App = () => { }> } /> } /> + } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx new file mode 100644 index 000000000..d0d9a7867 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from "react"; +import { isAxiosError } from "axios"; + +import { api } from "@/auth/http"; + +interface LocaleText { + en?: string; + am?: string; +} + +interface PermissionRecord { + id: string; + key: string; + name?: LocaleText; +} + +interface ListResponse { + count?: number; + items?: T[]; + data?: T[]; +} + +const PAGE_SIZE = 2000; + +const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => + value?.en ?? value?.am ?? fallback; + +const getItems = (payload: ListResponse | T[] | undefined | null) => { + if (!payload) { + return [] as T[]; + } + + if (Array.isArray(payload)) { + return payload; + } + + return payload.items ?? payload.data ?? []; +}; + +const sortPermissions = (items: PermissionRecord[]) => + [...items].sort((left, right) => + getLocaleLabel(left.name, left.key).localeCompare( + getLocaleLabel(right.name, right.key), + ), + ); + +const PermissionsPage = () => { + const [permissions, setPermissions] = useState([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + let isMounted = true; + + const loadPermissions = async () => { + setLoading(true); + setErrorMessage(null); + + try { + const response = await api.get>("/permissions", { + params: { + skip: 0, + take: PAGE_SIZE, + }, + }); + + if (!isMounted) { + return; + } + + const items = sortPermissions(getItems(response.data)); + + setPermissions(items); + setCount(response.data.count ?? items.length); + } catch (error) { + if (!isMounted) { + return; + } + + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load permissions." + : "Unable to load permissions.", + ); + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + void loadPermissions(); + + return () => { + isMounted = false; + }; + }, []); + + const hasMore = count > permissions.length; + + const handleLoadMore = async () => { + setLoadingMore(true); + setErrorMessage(null); + + try { + const response = await api.get>("/permissions", { + params: { + skip: permissions.length, + take: PAGE_SIZE, + }, + }); + + const nextItems = sortPermissions(getItems(response.data)); + + setPermissions((current) => [...current, ...nextItems]); + setCount(response.data.count ?? permissions.length + nextItems.length); + } catch (error) { + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load more permissions." + : "Unable to load more permissions.", + ); + } finally { + setLoadingMore(false); + } + }; + + return ( +
+
+
+

+ User Management +

+
+
+

Permissions

+

+ Browse the full IAM permission catalog for the freight backoffice environment. +

+
+
+ {count || permissions.length} permissions +
+
+
+ + {loading ? ( +
+ Loading permissions... +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : permissions.length ? ( +
+ {permissions.map((permission) => ( +
+
+

+ {getLocaleLabel(permission.name, permission.key)} +

+

+ {permission.key} +

+
+
+ ))} +
+ ) : ( +
+ No permissions are available in the system. +
+ )} + + {hasMore ? ( +
+

+ Showing {permissions.length} of {count} permissions. +

+ +
+ ) : null} +
+
+ ); +}; + +export default PermissionsPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx index fd02787cf..862df0119 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx @@ -1,11 +1,292 @@ -import FeaturePlaceholder from "@/components/FeaturePlaceholder"; +import { useEffect, useState } from "react"; +import { isAxiosError } from "axios"; +import { Shield } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@edr/ui-common"; + +import { api } from "@/auth/http"; + +interface LocaleText { + en?: string; + am?: string; +} + +interface RoleRecord { + id: string; + key: string; + name?: LocaleText; +} + +interface PermissionRecord { + id: string; + key: string; + name?: LocaleText; +} + +interface ListResponse { + items?: T[]; + data?: T[]; +} + +const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => + value?.en ?? value?.am ?? fallback; + +const getItems = (payload: ListResponse | T[] | undefined | null) => { + if (!payload) { + return [] as T[]; + } + + if (Array.isArray(payload)) { + return payload; + } + + return payload.items ?? payload.data ?? []; +}; const RolesPage = () => { + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + const [selectedRole, setSelectedRole] = useState(null); + const [rolePermissions, setRolePermissions] = useState([]); + const [rolePermissionsLoading, setRolePermissionsLoading] = useState(false); + const [rolePermissionsError, setRolePermissionsError] = useState(null); + + useEffect(() => { + let isMounted = true; + + const loadRoles = async () => { + setLoading(true); + setErrorMessage(null); + + try { + const response = await api.get>("/roles"); + + if (!isMounted) { + return; + } + + setRoles( + getItems(response.data).sort((left, right) => + getLocaleLabel(left.name, left.key).localeCompare( + getLocaleLabel(right.name, right.key), + ), + ), + ); + } catch (error) { + if (!isMounted) { + return; + } + + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load roles." + : "Unable to load roles.", + ); + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + void loadRoles(); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + if (!selectedRole) { + setRolePermissions([]); + setRolePermissionsError(null); + setRolePermissionsLoading(false); + return; + } + + let isMounted = true; + + const loadRolePermissions = async () => { + setRolePermissionsLoading(true); + setRolePermissionsError(null); + + try { + const response = await api.get>( + `/role-permissions/given-first/${selectedRole.id}`, + ); + + if (!isMounted) { + return; + } + + setRolePermissions( + getItems(response.data).sort((left, right) => + getLocaleLabel(left.name, left.key).localeCompare( + getLocaleLabel(right.name, right.key), + ), + ), + ); + } catch (error) { + if (!isMounted) { + return; + } + + setRolePermissionsError( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load role details." + : "Unable to load role details.", + ); + } finally { + if (isMounted) { + setRolePermissionsLoading(false); + } + } + }; + + void loadRolePermissions(); + + return () => { + isMounted = false; + }; + }, [selectedRole]); + return ( - +
+
+
+

+ User Management +

+
+
+

Roles

+

+ Browse freight backoffice roles and their internal keys in a simple grid view. +

+
+
+ {roles.length} roles +
+
+
+ + {loading ? ( +
+ Loading roles... +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : roles.length ? ( +
+ {roles.map((role) => ( + + ))} +
+ ) : ( +
+ No roles available. +
+ )} +
+ + !open && setSelectedRole(null)}> + + + {selectedRole ? getLocaleLabel(selectedRole.name, selectedRole.key) : "Role details"} + + {selectedRole + ? `Review the permission set assigned to ${getLocaleLabel(selectedRole.name, selectedRole.key)}.` + : undefined} + + + + {selectedRole ? ( +
+
+
+

+ Role name +

+

+ {getLocaleLabel(selectedRole.name, selectedRole.key)} +

+
+
+

+ Role key +

+

{selectedRole.key}

+
+
+ +
+
+

Permissions

+
+ {rolePermissions.length} permissions +
+
+ + {rolePermissionsLoading ? ( +
+ Loading role details... +
+ ) : rolePermissionsError ? ( +
+ {rolePermissionsError} +
+ ) : rolePermissions.length ? ( +
+ {rolePermissions.map((permission) => ( +
+

+ {getLocaleLabel(permission.name, permission.key)} +

+

+ {permission.key} +

+
+ ))} +
+ ) : ( +
+ No permissions are assigned to this role. +
+ )} +
+
+ ) : null} +
+
+
); }; From 6841c3f4d834ef9d4af6c6d36d6511e51299a756 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Thu, 28 May 2026 10:57:20 +0300 Subject: [PATCH 02/21] feat(freight:backoffice): group roles by apps --- .../user-management/PermissionsPage.tsx | 91 ++++++++++++++++--- 1 file changed, 77 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx index d0d9a7867..10c5aba25 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx @@ -1,5 +1,12 @@ import { useEffect, useState } from "react"; import { isAxiosError } from "axios"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@edr/ui-common"; import { api } from "@/auth/http"; @@ -12,6 +19,13 @@ interface PermissionRecord { id: string; key: string; name?: LocaleText; + applicationId?: string | null; +} + +interface ApplicationRecord { + id: string; + key: string; + name?: LocaleText; } interface ListResponse { @@ -21,6 +35,8 @@ interface ListResponse { } const PAGE_SIZE = 2000; +const ALL_APPLICATIONS_VALUE = "all"; +const SYSTEM_APPLICATION_VALUE = "system"; const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => value?.en ?? value?.am ?? fallback; @@ -46,6 +62,8 @@ const sortPermissions = (items: PermissionRecord[]) => const PermissionsPage = () => { const [permissions, setPermissions] = useState([]); + const [applications, setApplications] = useState([]); + const [selectedApplication, setSelectedApplication] = useState(ALL_APPLICATIONS_VALUE); const [count, setCount] = useState(0); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -54,26 +72,35 @@ const PermissionsPage = () => { useEffect(() => { let isMounted = true; - const loadPermissions = async () => { + const loadPageData = async () => { setLoading(true); setErrorMessage(null); try { - const response = await api.get>("/permissions", { - params: { - skip: 0, - take: PAGE_SIZE, - }, - }); + const [permissionsResponse, applicationsResponse] = await Promise.all([ + api.get>("/permissions", { + params: { + skip: 0, + take: PAGE_SIZE, + }, + }), + api.get>("/applications"), + ]); if (!isMounted) { return; } - const items = sortPermissions(getItems(response.data)); + const items = sortPermissions(getItems(permissionsResponse.data)); + const applicationItems = [...getItems(applicationsResponse.data)].sort((left, right) => + getLocaleLabel(left.name, left.key).localeCompare( + getLocaleLabel(right.name, right.key), + ), + ); setPermissions(items); - setCount(response.data.count ?? items.length); + setApplications(applicationItems); + setCount(permissionsResponse.data.count ?? items.length); } catch (error) { if (!isMounted) { return; @@ -91,7 +118,7 @@ const PermissionsPage = () => { } }; - void loadPermissions(); + void loadPageData(); return () => { isMounted = false; @@ -99,6 +126,17 @@ const PermissionsPage = () => { }, []); const hasMore = count > permissions.length; + const filteredPermissions = permissions.filter((permission) => { + if (selectedApplication === ALL_APPLICATIONS_VALUE) { + return true; + } + + if (selectedApplication === SYSTEM_APPLICATION_VALUE) { + return !permission.applicationId; + } + + return permission.applicationId === selectedApplication; + }); const handleLoadMore = async () => { setLoadingMore(true); @@ -142,11 +180,36 @@ const PermissionsPage = () => {

- {count || permissions.length} permissions + {filteredPermissions.length} permissions
+
+
+

+ Application +

+ +
+

+ Filter the IAM permission catalog by application, or view the shared system permissions that do not belong to any application. +

+
+ {loading ? (
Loading permissions... @@ -155,9 +218,9 @@ const PermissionsPage = () => {
{errorMessage}
- ) : permissions.length ? ( + ) : filteredPermissions.length ? (
- {permissions.map((permission) => ( + {filteredPermissions.map((permission) => (
{
) : (
- No permissions are available in the system. + No permissions match the selected application.
)} From d08bc6d3fe8e723b64c7cb4a07dee9d084aff8d6 Mon Sep 17 00:00:00 2001 From: marshal Date: Thu, 28 May 2026 11:34:24 +0300 Subject: [PATCH 03/21] add service-types and cargo-types CRUD modules with pagination, filtering --- apps/edr-freight-api/package.json | 1 + apps/edr-freight-api/pnpm-lock.yaml | 206 ++++++++++++++++ apps/edr-freight-api/src/app.module.ts | 4 + ...8427600000-AddServiceTypesAndCargoTypes.ts | 228 ++++++++++++++++++ .../cargo-types/cargo-types.controller.ts | 64 +++++ .../modules/cargo-types/cargo-types.module.ts | 23 ++ .../cargo-types/cargo-types.repository.ts | 31 +++ .../cargo-types/cargo-types.service.ts | 135 +++++++++++ .../cargo-types/dto/create-cargo-type.dto.ts | 35 +++ .../cargo-types/dto/filter-cargo-type.dto.ts | 51 ++++ .../cargo-types/dto/update-cargo-type.dto.ts | 5 + .../cargo-types/entities/cargo-type.entity.ts | 36 +++ .../cargo-types.repository.interface.ts | 14 ++ .../dto/create-service-type.dto.ts | 51 ++++ .../dto/filter-service-type.dto.ts | 46 ++++ .../dto/update-service-type.dto.ts | 5 + .../entities/service-type.entity.ts | 34 +++ .../service-types.repository.interface.ts | 14 ++ .../service-types/service-types.controller.ts | 64 +++++ .../service-types/service-types.module.ts | 23 ++ .../service-types/service-types.repository.ts | 30 +++ .../service-types/service-types.service.ts | 111 +++++++++ 22 files changed, 1211 insertions(+) create mode 100644 apps/edr-freight-api/pnpm-lock.yaml create mode 100644 apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts create mode 100644 apps/edr-freight-api/src/modules/cargo-types/cargo-types.controller.ts create mode 100644 apps/edr-freight-api/src/modules/cargo-types/cargo-types.module.ts create mode 100644 apps/edr-freight-api/src/modules/cargo-types/cargo-types.repository.ts create mode 100644 apps/edr-freight-api/src/modules/cargo-types/cargo-types.service.ts create mode 100644 apps/edr-freight-api/src/modules/cargo-types/dto/create-cargo-type.dto.ts create mode 100644 apps/edr-freight-api/src/modules/cargo-types/dto/filter-cargo-type.dto.ts create mode 100644 apps/edr-freight-api/src/modules/cargo-types/dto/update-cargo-type.dto.ts create mode 100644 apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts create mode 100644 apps/edr-freight-api/src/modules/cargo-types/interfaces/cargo-types.repository.interface.ts create mode 100644 apps/edr-freight-api/src/modules/service-types/dto/create-service-type.dto.ts create mode 100644 apps/edr-freight-api/src/modules/service-types/dto/filter-service-type.dto.ts create mode 100644 apps/edr-freight-api/src/modules/service-types/dto/update-service-type.dto.ts create mode 100644 apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts create mode 100644 apps/edr-freight-api/src/modules/service-types/interfaces/service-types.repository.interface.ts create mode 100644 apps/edr-freight-api/src/modules/service-types/service-types.controller.ts create mode 100644 apps/edr-freight-api/src/modules/service-types/service-types.module.ts create mode 100644 apps/edr-freight-api/src/modules/service-types/service-types.repository.ts create mode 100644 apps/edr-freight-api/src/modules/service-types/service-types.service.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 3b80db1bf..5114e20da 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -18,6 +18,7 @@ "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", + "@nestjs/mapped-types": "^2.1.1", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/swagger": "^11.4.2", diff --git a/apps/edr-freight-api/pnpm-lock.yaml b/apps/edr-freight-api/pnpm-lock.yaml new file mode 100644 index 000000000..7a7a3beb2 --- /dev/null +++ b/apps/edr-freight-api/pnpm-lock.yaml @@ -0,0 +1,206 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@edr/api-common': + specifier: workspace:* + version: link:../../packages/api-common + '@edr/types': + specifier: workspace:* + version: link:../../packages/types + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': + specifier: ^2.1.1 + version: 2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../../packages/config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + +packages: + + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@nestjs/common@11.1.24': + resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + +snapshots: + + '@borewit/text-codec@0.2.2': {} + + '@lukeed/csprng@1.1.0': {} + + '@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + ieee754@1.2.1: {} + + iterare@1.2.1: {} + + load-esm@1.0.3: {} + + ms@2.1.3: {} + + reflect-metadata@0.2.2: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tslib@2.8.1: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d1fdc085f..8403cb0bf 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -18,6 +18,8 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { OtpModule } from './modules/otp/otp.module'; +import { ServiceTypesModule } from "./modules/service-types/service-types.module"; +import { CargoTypesModule } from "./modules/cargo-types/cargo-types.module"; import { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-settings.service"; @Module({ @@ -44,6 +46,8 @@ import { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-se FileUploadSettingsModule, DropdownSettingsModule, OtpModule, + ServiceTypesModule, + CargoTypesModule, ], }) export class AppModule implements OnApplicationBootstrap { diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts new file mode 100644 index 000000000..54ce5b656 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -0,0 +1,228 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from "typeorm"; + +export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInterface { + name = "AddServiceTypesAndCargoTypes1748427600000"; + + public async up(queryRunner: QueryRunner): Promise { + // Create service_types table + await queryRunner.createTable( + new Table({ + name: "service_types", + schema: "freight", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "uuid_generate_v4()", + }, + { + name: "service_name", + type: "varchar", + length: "255", + isNullable: false, + }, + { + name: "description", + type: "text", + isNullable: true, + }, + { + name: "can_be_booked_alone", + type: "boolean", + default: true, + isNullable: false, + }, + { + name: "includes_first_mile", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "includes_last_mile", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "includes_customs", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "priority_bonus_points", + type: "int", + default: 0, + isNullable: false, + }, + { + name: "is_active", + type: "boolean", + default: true, + isNullable: false, + }, + { + name: "display_order", + type: "int", + default: 1, + isNullable: false, + }, + { + name: "created_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "updated_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "deleted_at", + type: "timestamptz", + isNullable: true, + }, + ], + }), + true, + ); + + // Create indexes for service_types + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + + // Create cargo_types table + await queryRunner.createTable( + new Table({ + name: "cargo_types", + schema: "freight", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "uuid_generate_v4()", + }, + { + name: "cargo_type_name", + type: "varchar", + length: "255", + isNullable: false, + }, + { + name: "parent_group_id", + type: "uuid", + isNullable: true, + }, + { + name: "show_free_text_box", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "requires_director_approval", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "is_active", + type: "boolean", + default: true, + isNullable: false, + }, + { + name: "display_order", + type: "int", + default: 1, + isNullable: false, + }, + { + name: "created_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "updated_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "deleted_at", + type: "timestamptz", + isNullable: true, + }, + ], + }), + true, + ); + + // Create indexes for cargo_types + await queryRunner.createIndex( + "freight.cargo_types", + new TableIndex({ + name: "IDX_CARGO_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + await queryRunner.createIndex( + "freight.cargo_types", + new TableIndex({ + name: "IDX_CARGO_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + await queryRunner.createIndex( + "freight.cargo_types", + new TableIndex({ + name: "IDX_CARGO_TYPES_PARENT_GROUP_ID", + columnNames: ["parent_group_id"], + }), + ); + + // Create self-referencing foreign key for cargo_types + await queryRunner.createForeignKey( + "freight.cargo_types", + new TableForeignKey({ + name: "FK_CARGO_TYPES_PARENT_GROUP", + columnNames: ["parent_group_id"], + referencedSchema: "freight", + referencedTableName: "cargo_types", + referencedColumnNames: ["id"], + onDelete: "SET NULL", + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop foreign key first + await queryRunner.dropForeignKey("freight.cargo_types", "FK_CARGO_TYPES_PARENT_GROUP"); + + // Drop cargo_types table + await queryRunner.dropTable("freight.cargo_types", true); + + // Drop service_types table + await queryRunner.dropTable("freight.service_types", true); + } +} diff --git a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.controller.ts new file mode 100644 index 000000000..03b9e6594 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.controller.ts @@ -0,0 +1,64 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { CargoTypesService } from "./cargo-types.service"; +import { CreateCargoTypeDto } from "./dto/create-cargo-type.dto"; +import { FilterCargoTypeDto } from "./dto/filter-cargo-type.dto"; +import { UpdateCargoTypeDto } from "./dto/update-cargo-type.dto"; + +@ApiTags("cargo-types") +@Controller("cargo-types") +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class CargoTypesController { + constructor(private readonly service: CargoTypesService) {} + + @Get() + @ApiOperation({ + summary: "List cargo types", + description: "Paginated list with optional filtering by isActive, requiresDirectorApproval, parentGroupId, and name search.", + }) + findAll(@Query() filter: FilterCargoTypeDto) { + return this.service.findAll(filter); + } + + @Get(":id") + @ApiOperation({ summary: "Get a cargo type by ID", description: "Returns the cargo type with parent and children relations." }) + findOne(@Param("id", ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: "Create a new cargo type" }) + create(@Body() dto: CreateCargoTypeDto) { + return this.service.create(dto); + } + + @Patch(":id") + @ApiOperation({ summary: "Update a cargo type" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateCargoTypeDto, + ) { + return this.service.update(id, dto); + } + + @Delete(":id") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Soft-delete a cargo type" }) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.module.ts b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.module.ts new file mode 100644 index 000000000..a79759dfd --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { CargoType } from "./entities/cargo-type.entity"; +import { CARGO_TYPES_REPOSITORY } from "./interfaces/cargo-types.repository.interface"; +import { CargoTypesRepository } from "./cargo-types.repository"; +import { CargoTypesController } from "./cargo-types.controller"; +import { CargoTypesService } from "./cargo-types.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([CargoType])], + controllers: [CargoTypesController], + providers: [ + CargoTypesRepository, + { + provide: CARGO_TYPES_REPOSITORY, + useExisting: CargoTypesRepository, + }, + CargoTypesService, + ], + exports: [CargoTypesService], +}) +export class CargoTypesModule {} diff --git a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.repository.ts new file mode 100644 index 000000000..4f71c61a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.repository.ts @@ -0,0 +1,31 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { FindManyOptions, Repository } from "typeorm"; + +import { CargoType } from "./entities/cargo-type.entity"; +import { ICargoTypesRepository } from "./interfaces/cargo-types.repository.interface"; + +@Injectable() +export class CargoTypesRepository + extends BaseRepository + implements ICargoTypesRepository +{ + constructor( + @InjectRepository(CargoType) + repository: Repository, + ) { + super(repository); + } + + override findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { parent: true, children: true }, + }); + } + + override findAndCount(options?: FindManyOptions): Promise<[CargoType[], number]> { + return this.repository.findAndCount(options); + } +} diff --git a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.service.ts b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.service.ts new file mode 100644 index 000000000..2ad4339c5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.service.ts @@ -0,0 +1,135 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { ILike } from "typeorm"; + +import { CreateCargoTypeDto } from "./dto/create-cargo-type.dto"; +import { FilterCargoTypeDto } from "./dto/filter-cargo-type.dto"; +import { UpdateCargoTypeDto } from "./dto/update-cargo-type.dto"; +import { CargoType } from "./entities/cargo-type.entity"; +import { + CARGO_TYPES_REPOSITORY, + ICargoTypesRepository, +} from "./interfaces/cargo-types.repository.interface"; + +@Injectable() +export class CargoTypesService { + constructor( + @Inject(CARGO_TYPES_REPOSITORY) + private readonly repository: ICargoTypesRepository, + ) {} + + /** + * Find all cargo types with pagination and optional filtering. + */ + async findAll(filter: FilterCargoTypeDto): Promise<{ + data: CargoType[]; + meta: { + total: number; + page: number; + pageSize: number; + totalPages: number; + }; + }> { + const where: Record = {}; + + if (filter.isActive !== undefined) { + where.isActive = filter.isActive; + } + + if (filter.requiresDirectorApproval !== undefined) { + where.requiresDirectorApproval = filter.requiresDirectorApproval; + } + + if (filter.parentGroupId !== undefined) { + where.parentGroupId = filter.parentGroupId; + } + + if (filter.search) { + where.cargoTypeName = ILike(`%${filter.search}%`); + } + + const [data, total] = await this.repository.findAndCount({ + where, + order: { [filter.sortBy!]: filter.sortOrder }, + skip: (filter.page! - 1) * filter.pageSize!, + take: filter.pageSize, + relations: { parent: true }, + }); + + return { + data, + meta: { + total, + page: filter.page!, + pageSize: filter.pageSize!, + totalPages: Math.ceil(total / filter.pageSize!), + }, + }; + } + + /** + * Get a single cargo type by ID. + */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) { + throw new NotFoundException(`Cargo type ${id} not found`); + } + return entity; + } + + /** + * Create a new cargo type. + */ + async create(dto: CreateCargoTypeDto): Promise { + // Validate parent exists if provided + if (dto.parentGroupId) { + const parent = await this.repository.findById(dto.parentGroupId); + if (!parent) { + throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); + } + } + + return this.repository.create({ + cargoTypeName: dto.cargoTypeName, + parentGroupId: dto.parentGroupId ?? null, + showFreeTextBox: dto.showFreeTextBox ?? false, + requiresDirectorApproval: dto.requiresDirectorApproval ?? false, + isActive: dto.isActive ?? true, + displayOrder: dto.displayOrder ?? 1, + }); + } + + /** + * Update an existing cargo type. + */ + async update(id: string, dto: UpdateCargoTypeDto): Promise { + await this.findById(id); + + // Validate parent exists if provided + const parentGroupId = dto.parentGroupId; + if (parentGroupId) { + const parent = await this.repository.findById(parentGroupId); + if (!parent) { + throw new NotFoundException(`Parent cargo type ${parentGroupId} not found`); + } + // Prevent circular reference + if (parentGroupId === id) { + throw new ConflictException("A cargo type cannot be its own parent"); + } + } + + const updated = await this.repository.update(id, dto); + if (!updated) { + throw new NotFoundException(`Cargo type ${id} not found`); + } + return this.findById(id); + } + + /** + * Soft-delete a cargo type. + */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/cargo-types/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/cargo-types/dto/create-cargo-type.dto.ts new file mode 100644 index 000000000..add968ed5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargo-types/dto/create-cargo-type.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from "class-validator"; + +export class CreateCargoTypeDto { + @ApiProperty({ description: "Cargo type name", maxLength: 255 }) + @IsString() + @MaxLength(255) + cargoTypeName!: string; + + @ApiPropertyOptional({ description: "Parent cargo type group ID (UUID) for hierarchical structure" }) + @IsOptional() + @IsUUID() + parentGroupId?: string; + + @ApiPropertyOptional({ description: "Show free text box for this cargo type", default: false }) + @IsOptional() + @IsBoolean() + showFreeTextBox?: boolean = false; + + @ApiPropertyOptional({ description: "Requires director approval for this cargo type", default: false }) + @IsOptional() + @IsBoolean() + requiresDirectorApproval?: boolean = false; + + @ApiPropertyOptional({ description: "Is the cargo type active", default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean = true; + + @ApiPropertyOptional({ description: "Display order for UI", default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number = 1; +} diff --git a/apps/edr-freight-api/src/modules/cargo-types/dto/filter-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/cargo-types/dto/filter-cargo-type.dto.ts new file mode 100644 index 000000000..69a6141a9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargo-types/dto/filter-cargo-type.dto.ts @@ -0,0 +1,51 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator"; + +export class FilterCargoTypeDto { + @ApiPropertyOptional({ description: "Filter by active status" }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + isActive?: boolean; + + @ApiPropertyOptional({ description: "Filter by requires director approval" }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + requiresDirectorApproval?: boolean; + + @ApiPropertyOptional({ description: "Filter by parent group ID (or 'root' for top-level only)" }) + @IsOptional() + @IsUUID() + parentGroupId?: string; + + @ApiPropertyOptional({ description: "Search by cargo type name (case-insensitive)" }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: ["cargoTypeName", "displayOrder", "createdAt"], default: "displayOrder" }) + @IsOptional() + @IsIn(["cargoTypeName", "displayOrder", "createdAt"]) + sortBy?: string = "displayOrder"; + + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) + @IsOptional() + @IsIn(["ASC", "DESC"]) + sortOrder?: "ASC" | "DESC" = "ASC"; + + @ApiPropertyOptional({ description: "Page number", default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: "Items per page", default: 20, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number = 20; +} diff --git a/apps/edr-freight-api/src/modules/cargo-types/dto/update-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/cargo-types/dto/update-cargo-type.dto.ts new file mode 100644 index 000000000..638422fd7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargo-types/dto/update-cargo-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreateCargoTypeDto } from "./create-cargo-type.dto"; + +export class UpdateCargoTypeDto extends PartialType(CreateCargoTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts new file mode 100644 index 000000000..752e9e965 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, ManyToOne, OneToMany, JoinColumn } from "typeorm"; + +@Entity({ schema: "freight", name: "cargo_types" }) +@Index(["is_active"]) +@Index(["display_order"]) +@Index(["parent_group_id"]) +export class CargoType extends BaseEntity { + @Column({ name: "cargo_type_name", type: "varchar", length: 255, nullable: false }) + cargoTypeName!: string; + + @Column({ name: "parent_group_id", type: "uuid", nullable: true }) + parentGroupId?: string | null; + + @Column({ name: "show_free_text_box", type: "boolean", default: false }) + showFreeTextBox!: boolean; + + @Column({ name: "requires_director_approval", type: "boolean", default: false }) + requiresDirectorApproval!: boolean; + + @Column({ name: "is_active", type: "boolean", default: true }) + isActive!: boolean; + + @Column({ name: "display_order", type: "int", default: 1 }) + displayOrder!: number; + + @ManyToOne(() => CargoType, (cargoType) => cargoType.children, { + nullable: true, + onDelete: "SET NULL", + }) + @JoinColumn({ name: "parent_group_id" }) + parent?: CargoType | null; + + @OneToMany(() => CargoType, (cargoType) => cargoType.parent) + children?: CargoType[]; +} diff --git a/apps/edr-freight-api/src/modules/cargo-types/interfaces/cargo-types.repository.interface.ts b/apps/edr-freight-api/src/modules/cargo-types/interfaces/cargo-types.repository.interface.ts new file mode 100644 index 000000000..21f501511 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargo-types/interfaces/cargo-types.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from "typeorm"; + +import { CargoType } from "../entities/cargo-type.entity"; + +export interface ICargoTypesRepository { + findById(id: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[CargoType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const CARGO_TYPES_REPOSITORY = Symbol("CARGO_TYPES_REPOSITORY"); diff --git a/apps/edr-freight-api/src/modules/service-types/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/service-types/dto/create-service-type.dto.ts new file mode 100644 index 000000000..c4da74794 --- /dev/null +++ b/apps/edr-freight-api/src/modules/service-types/dto/create-service-type.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from "class-validator"; + +export class CreateServiceTypeDto { + @ApiProperty({ description: "Service type name", maxLength: 255 }) + @IsString() + @MaxLength(255) + serviceName!: string; + + @ApiPropertyOptional({ description: "Detailed description of the service" }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: "Can be booked alone", default: true }) + @IsOptional() + @IsBoolean() + canBeBookedAlone?: boolean = true; + + @ApiPropertyOptional({ description: "Includes first mile service", default: false }) + @IsOptional() + @IsBoolean() + includesFirstMile?: boolean = false; + + @ApiPropertyOptional({ description: "Includes last mile service", default: false }) + @IsOptional() + @IsBoolean() + includesLastMile?: boolean = false; + + @ApiPropertyOptional({ description: "Includes customs clearance", default: false }) + @IsOptional() + @IsBoolean() + includesCustoms?: boolean = false; + + @ApiPropertyOptional({ description: "Priority bonus points for booking priority", default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + priorityBonusPoints?: number = 0; + + @ApiPropertyOptional({ description: "Is the service type active", default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean = true; + + @ApiPropertyOptional({ description: "Display order for UI", default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number = 1; +} diff --git a/apps/edr-freight-api/src/modules/service-types/dto/filter-service-type.dto.ts b/apps/edr-freight-api/src/modules/service-types/dto/filter-service-type.dto.ts new file mode 100644 index 000000000..cd0992c96 --- /dev/null +++ b/apps/edr-freight-api/src/modules/service-types/dto/filter-service-type.dto.ts @@ -0,0 +1,46 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; + +export class FilterServiceTypeDto { + @ApiPropertyOptional({ description: "Filter by active status" }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + isActive?: boolean; + + @ApiPropertyOptional({ description: "Filter by can be booked alone" }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + canBeBookedAlone?: boolean; + + @ApiPropertyOptional({ description: "Search by service name (case-insensitive)" }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: ["serviceName", "displayOrder", "createdAt"], default: "displayOrder" }) + @IsOptional() + @IsIn(["serviceName", "displayOrder", "createdAt"]) + sortBy?: string = "displayOrder"; + + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) + @IsOptional() + @IsIn(["ASC", "DESC"]) + sortOrder?: "ASC" | "DESC" = "ASC"; + + @ApiPropertyOptional({ description: "Page number", default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: "Items per page", default: 20, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number = 20; +} diff --git a/apps/edr-freight-api/src/modules/service-types/dto/update-service-type.dto.ts b/apps/edr-freight-api/src/modules/service-types/dto/update-service-type.dto.ts new file mode 100644 index 000000000..25cd87146 --- /dev/null +++ b/apps/edr-freight-api/src/modules/service-types/dto/update-service-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreateServiceTypeDto } from "./create-service-type.dto"; + +export class UpdateServiceTypeDto extends PartialType(CreateServiceTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts new file mode 100644 index 000000000..986add2c1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +@Entity({ schema: "freight", name: "service_types" }) +@Index(["is_active"]) +@Index(["display_order"]) +export class ServiceType extends BaseEntity { + @Column({ name: "service_name", type: "varchar", length: 255, nullable: false }) + serviceName!: string; + + @Column({ name: "description", type: "text", nullable: true }) + description?: string | null; + + @Column({ name: "can_be_booked_alone", type: "boolean", default: true }) + canBeBookedAlone!: boolean; + + @Column({ name: "includes_first_mile", type: "boolean", default: false }) + includesFirstMile!: boolean; + + @Column({ name: "includes_last_mile", type: "boolean", default: false }) + includesLastMile!: boolean; + + @Column({ name: "includes_customs", type: "boolean", default: false }) + includesCustoms!: boolean; + + @Column({ name: "priority_bonus_points", type: "int", default: 0 }) + priorityBonusPoints!: number; + + @Column({ name: "is_active", type: "boolean", default: true }) + isActive!: boolean; + + @Column({ name: "display_order", type: "int", default: 1 }) + displayOrder!: number; +} diff --git a/apps/edr-freight-api/src/modules/service-types/interfaces/service-types.repository.interface.ts b/apps/edr-freight-api/src/modules/service-types/interfaces/service-types.repository.interface.ts new file mode 100644 index 000000000..8d4efd672 --- /dev/null +++ b/apps/edr-freight-api/src/modules/service-types/interfaces/service-types.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from "typeorm"; + +import { ServiceType } from "../entities/service-type.entity"; + +export interface IServiceTypesRepository { + findById(id: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[ServiceType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const SERVICE_TYPES_REPOSITORY = Symbol("SERVICE_TYPES_REPOSITORY"); diff --git a/apps/edr-freight-api/src/modules/service-types/service-types.controller.ts b/apps/edr-freight-api/src/modules/service-types/service-types.controller.ts new file mode 100644 index 000000000..90203a493 --- /dev/null +++ b/apps/edr-freight-api/src/modules/service-types/service-types.controller.ts @@ -0,0 +1,64 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { CreateServiceTypeDto } from "./dto/create-service-type.dto"; +import { FilterServiceTypeDto } from "./dto/filter-service-type.dto"; +import { UpdateServiceTypeDto } from "./dto/update-service-type.dto"; +import { ServiceTypesService } from "./service-types.service"; + +@ApiTags("service-types") +@Controller("service-types") +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class ServiceTypesController { + constructor(private readonly service: ServiceTypesService) {} + + @Get() + @ApiOperation({ + summary: "List service types", + description: "Paginated list with optional filtering by isActive, canBeBookedAlone, and name search.", + }) + findAll(@Query() filter: FilterServiceTypeDto) { + return this.service.findAll(filter); + } + + @Get(":id") + @ApiOperation({ summary: "Get a service type by ID" }) + findOne(@Param("id", ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: "Create a new service type" }) + create(@Body() dto: CreateServiceTypeDto) { + return this.service.create(dto); + } + + @Patch(":id") + @ApiOperation({ summary: "Update a service type" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateServiceTypeDto, + ) { + return this.service.update(id, dto); + } + + @Delete(":id") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Soft-delete a service type" }) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/service-types/service-types.module.ts b/apps/edr-freight-api/src/modules/service-types/service-types.module.ts new file mode 100644 index 000000000..db4408af1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/service-types/service-types.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { ServiceType } from "./entities/service-type.entity"; +import { SERVICE_TYPES_REPOSITORY } from "./interfaces/service-types.repository.interface"; +import { ServiceTypesRepository } from "./service-types.repository"; +import { ServiceTypesController } from "./service-types.controller"; +import { ServiceTypesService } from "./service-types.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([ServiceType])], + controllers: [ServiceTypesController], + providers: [ + ServiceTypesRepository, + { + provide: SERVICE_TYPES_REPOSITORY, + useExisting: ServiceTypesRepository, + }, + ServiceTypesService, + ], + exports: [ServiceTypesService], +}) +export class ServiceTypesModule {} diff --git a/apps/edr-freight-api/src/modules/service-types/service-types.repository.ts b/apps/edr-freight-api/src/modules/service-types/service-types.repository.ts new file mode 100644 index 000000000..299610e28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/service-types/service-types.repository.ts @@ -0,0 +1,30 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { FindManyOptions, Repository } from "typeorm"; + +import { ServiceType } from "./entities/service-type.entity"; +import { IServiceTypesRepository } from "./interfaces/service-types.repository.interface"; + +@Injectable() +export class ServiceTypesRepository + extends BaseRepository + implements IServiceTypesRepository +{ + constructor( + @InjectRepository(ServiceType) + repository: Repository, + ) { + super(repository); + } + + override findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + }); + } + + override findAndCount(options?: FindManyOptions): Promise<[ServiceType[], number]> { + return this.repository.findAndCount(options); + } +} diff --git a/apps/edr-freight-api/src/modules/service-types/service-types.service.ts b/apps/edr-freight-api/src/modules/service-types/service-types.service.ts new file mode 100644 index 000000000..b625eb72d --- /dev/null +++ b/apps/edr-freight-api/src/modules/service-types/service-types.service.ts @@ -0,0 +1,111 @@ +import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { ILike } from "typeorm"; + +import { CreateServiceTypeDto } from "./dto/create-service-type.dto"; +import { FilterServiceTypeDto } from "./dto/filter-service-type.dto"; +import { UpdateServiceTypeDto } from "./dto/update-service-type.dto"; +import { ServiceType } from "./entities/service-type.entity"; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from "./interfaces/service-types.repository.interface"; + +@Injectable() +export class ServiceTypesService { + constructor( + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly repository: IServiceTypesRepository, + ) {} + + /** + * Find all service types with pagination and optional filtering. + */ + async findAll(filter: FilterServiceTypeDto): Promise<{ + data: ServiceType[]; + meta: { + total: number; + page: number; + pageSize: number; + totalPages: number; + }; + }> { + const where: Record = {}; + + if (filter.isActive !== undefined) { + where.isActive = filter.isActive; + } + + if (filter.canBeBookedAlone !== undefined) { + where.canBeBookedAlone = filter.canBeBookedAlone; + } + + if (filter.search) { + where.serviceName = ILike(`%${filter.search}%`); + } + + const [data, total] = await this.repository.findAndCount({ + where, + order: { [filter.sortBy!]: filter.sortOrder }, + skip: (filter.page! - 1) * filter.pageSize!, + take: filter.pageSize, + }); + + return { + data, + meta: { + total, + page: filter.page!, + pageSize: filter.pageSize!, + totalPages: Math.ceil(total / filter.pageSize!), + }, + }; + } + + /** + * Get a single service type by ID. + */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) { + throw new NotFoundException(`Service type ${id} not found`); + } + return entity; + } + + /** + * Create a new service type. + */ + async create(dto: CreateServiceTypeDto): Promise { + return this.repository.create({ + serviceName: dto.serviceName, + description: dto.description ?? null, + canBeBookedAlone: dto.canBeBookedAlone ?? true, + includesFirstMile: dto.includesFirstMile ?? false, + includesLastMile: dto.includesLastMile ?? false, + includesCustoms: dto.includesCustoms ?? false, + priorityBonusPoints: dto.priorityBonusPoints ?? 0, + isActive: dto.isActive ?? true, + displayOrder: dto.displayOrder ?? 1, + }); + } + + /** + * Update an existing service type. + */ + async update(id: string, dto: UpdateServiceTypeDto): Promise { + await this.findById(id); + const updated = await this.repository.update(id, dto); + if (!updated) { + throw new NotFoundException(`Service type ${id} not found`); + } + return this.findById(id); + } + + /** + * Soft-delete a service type. + */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} From c0fc66729b67262a2e948077ccb80a1807532513 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Thu, 28 May 2026 11:44:58 +0300 Subject: [PATCH 04/21] feat(freight:backoffice): Added employees page --- apps/edr-freight-web/backoffice/src/App.tsx | 6 + .../user-management/EmployeesPage.tsx | 321 ++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 10a69ae5e..7947060ef 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -5,6 +5,7 @@ import { LayoutDashboard, Network } from "lucide-react"; import { useAuth } from "./auth/useAuth"; import LoginPage from "./pages/auth/LoginPage"; import OverviewPage from "./pages/dashboard/OverviewPage"; +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"; @@ -21,6 +22,10 @@ const sidebarItems: SidebarItem[] = [ href: "/dashboard/user-management", icon: , children: [ + { + label: "Employees", + href: "/dashboard/user-management/employees", + }, { label: "Permissions", href: "/dashboard/user-management/permissions", @@ -79,6 +84,7 @@ const App = () => { }> } /> } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx new file mode 100644 index 000000000..2e54608b0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx @@ -0,0 +1,321 @@ +import { useEffect, useState } from "react"; +import { isAxiosError } from "axios"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@edr/ui-common"; + +import { api } from "@/auth/http"; +import { useAuth } from "@/auth/useAuth"; + +interface LocaleText { + en?: string; + am?: string; +} + +interface EmployeeUserRecord { + id: string; + name?: LocaleText; + email?: string; + phoneNumber?: string; + username?: string; +} + +interface EmployeePositionSummary { + id: string; + position?: { + id: string; + name?: LocaleText; + key?: string; + }; +} + +interface EmployeeRecord { + id: string; + name?: LocaleText; + status?: string; + user?: EmployeeUserRecord; + employeePositions?: EmployeePositionSummary[]; +} + +interface ListResponse { + count?: number; + items?: T[]; + data?: T[]; +} + +const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => + value?.en ?? value?.am ?? fallback; + +const getItems = (payload: ListResponse | T[] | undefined | null) => { + if (!payload) { + return [] as T[]; + } + + if (Array.isArray(payload)) { + return payload; + } + + return payload.items ?? payload.data ?? []; +}; + +const getEmployeeDisplayName = (employee: EmployeeRecord) => + getLocaleLabel( + employee.name ?? employee.user?.name, + employee.user?.email ?? employee.user?.username ?? employee.id, + ); + +const EmployeesPage = () => { + const { user } = useAuth(); + const [employees, setEmployees] = useState([]); + const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + const [selectedEmployee, setSelectedEmployee] = useState(null); + + useEffect(() => { + let isMounted = true; + const organizationIds = Array.from( + new Set( + (user?.employee ?? []) + .map((employee) => employee.organizationId) + .filter((organizationId): organizationId is string => Boolean(organizationId)), + ), + ); + + const loadEmployees = async () => { + setLoading(true); + setErrorMessage(null); + + if (!organizationIds.length) { + setEmployees([]); + setErrorMessage("No employee organization scope is available for this account."); + setLoading(false); + return; + } + + try { + const responses = await Promise.all( + organizationIds.map((organizationId) => + api.get>( + `/employees/${organizationId}/by-organization`, + { + params: { + skip: 0, + take: 1000, + }, + }, + ), + ), + ); + + if (!isMounted) { + return; + } + + const uniqueEmployees = Array.from( + new Map( + responses + .flatMap((response) => getItems(response.data)) + .map((employee) => [employee.id, employee]), + ).values(), + ); + + setEmployees( + uniqueEmployees.sort((left, right) => + getEmployeeDisplayName(left).localeCompare(getEmployeeDisplayName(right)), + ), + ); + } catch (error) { + if (!isMounted) { + return; + } + + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load employees." + : "Unable to load employees.", + ); + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + void loadEmployees(); + + return () => { + isMounted = false; + }; + }, [user]); + + return ( +
+
+
+

+ User Management +

+
+
+

Employees

+

+ Browse employees within your accessible scope and open a record to review contact and position details. +

+
+
+ {employees.length} employees +
+
+
+ + {loading ? ( +
+ Loading employees... +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : employees.length ? ( +
+ + + + + + + + + + + + + {employees.map((employee) => { + const positions = employee.employeePositions?.map((item) => getLocaleLabel(item.position?.name, item.position?.key ?? "Unnamed")) ?? []; + + return ( + setSelectedEmployee(employee)} + className="cursor-pointer border-t border-border bg-background transition hover:bg-accent/20" + > + + + + + + + + ); + })} + +
NameUsernameEmailPhoneStatusPositions
{getEmployeeDisplayName(employee)}{employee.user?.username ?? "-"}{employee.user?.email ?? "-"}{employee.user?.phoneNumber ?? "-"}{employee.status ?? "-"}{positions.join(", ") || "-"}
+
+ ) : ( +
+ No employees are available in your scope. +
+ )} +
+ + !open && setSelectedEmployee(null)}> + + + + {selectedEmployee ? getEmployeeDisplayName(selectedEmployee) : "Employee details"} + + + {selectedEmployee + ? "Review the employee profile, contact information, and assigned positions." + : undefined} + + + + {selectedEmployee ? ( +
+
+
+

+ Employee name +

+

+ {getEmployeeDisplayName(selectedEmployee)} +

+
+
+

+ Status +

+

+ {selectedEmployee.status ?? "-"} +

+
+
+

+ Username +

+

{selectedEmployee.user?.username ?? "-"}

+
+
+

+ Email +

+

{selectedEmployee.user?.email ?? "-"}

+
+
+

+ Phone +

+

{selectedEmployee.user?.phoneNumber ?? "-"}

+
+
+

+ Employee ID +

+

{selectedEmployee.id}

+
+
+ +
+
+

Assigned positions

+
+ {selectedEmployee.employeePositions?.length ?? 0} positions +
+
+ + {selectedEmployee.employeePositions?.length ? ( +
+ {selectedEmployee.employeePositions.map((item) => ( +
+

+ {getLocaleLabel(item.position?.name, item.position?.key ?? "Unnamed")} +

+

+ {item.position?.key ?? "-"} +

+
+ ))} +
+ ) : ( +
+ No positions are assigned to this employee. +
+ )} +
+
+ ) : null} +
+
+
+ ); +}; + +export default EmployeesPage; From e1d05d54756244e4c96b7cbe40e5c25cc2d7fbbe Mon Sep 17 00:00:00 2001 From: marshal Date: Thu, 28 May 2026 11:52:29 +0300 Subject: [PATCH 05/21] add service-types and cargo-types CRUD modules with pagination, filtering --- apps/edr-freight-api/src/app.module.ts | 1 - .../src/modules/bookings/dto/update-booking.dto.ts | 2 +- .../src/modules/customers/dto/update-customer.dto.ts | 2 +- .../src/modules/customers2/dto/update-customer.dto.ts | 2 +- .../modules/dropdown-settings/dto/update-dropdown-option.dto.ts | 2 +- .../dropdown-settings/dto/update-dropdown-setting.dto.ts | 2 +- .../file-upload-settings/dto/update-file-upload-field.dto.ts | 2 +- .../file-upload-settings/dto/update-file-upload-setting.dto.ts | 2 +- 8 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a770c2adf..6b0f072e8 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -20,7 +20,6 @@ import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-set import { OtpModule } from './modules/otp/otp.module'; import { ServiceTypesModule } from "./modules/service-types/service-types.module"; import { CargoTypesModule } from "./modules/cargo-types/cargo-types.module"; -import { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-settings.service"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts index b97a624fb..2b97debc6 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts @@ -1,4 +1,4 @@ -import { PartialType } from "@nestjs/swagger"; +import { PartialType } from "@nestjs/mapped-types"; import { CreateBookingDto } from "./create-booking.dto"; diff --git a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts index 3651f4b44..f8cefe046 100644 --- a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts +++ b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts @@ -1,5 +1,5 @@ // src/modules/customers/dto/update-customer.dto.ts -import { PartialType } from '@nestjs/swagger'; +import { PartialType } from '@nestjs/mapped-types'; import { CreateCustomerDto } from './create-customer.dto'; export class UpdateCustomerDto extends PartialType(CreateCustomerDto) { diff --git a/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts index 94b49d0fd..499d0ef9b 100644 --- a/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts +++ b/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts @@ -1,4 +1,4 @@ -import { PartialType } from "@nestjs/swagger"; +import { PartialType } from "@nestjs/mapped-types"; import { CreateCustomerDto } from "./create-customer.dto"; diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts index aaa34dd2e..bbe0a3ecc 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts @@ -1,4 +1,4 @@ -import { PartialType } from "@nestjs/swagger"; +import { PartialType } from "@nestjs/mapped-types"; import { CreateDropdownOptionDto } from "./create-dropdown-option.dto"; diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts index 8ad26229f..35908c419 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts @@ -1,4 +1,4 @@ -import { OmitType, PartialType } from "@nestjs/swagger"; +import { OmitType, PartialType } from "@nestjs/mapped-types"; import { CreateDropdownSettingDto } from "./create-dropdown-setting.dto"; diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts index dae3ca4ca..abef2b5b7 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts @@ -1,4 +1,4 @@ -import { PartialType } from "@nestjs/swagger"; +import { PartialType } from "@nestjs/mapped-types"; import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto"; diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts index d8b6eb8ca..f055f4ebf 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts @@ -1,4 +1,4 @@ -import { OmitType, PartialType } from "@nestjs/swagger"; +import { OmitType, PartialType } from "@nestjs/mapped-types"; import { CreateFileUploadSettingDto } from "./create-file-upload-setting.dto"; From 3b53042cf86006f916c59c9912ffbdb3e2e51970 Mon Sep 17 00:00:00 2001 From: marshal Date: Thu, 28 May 2026 11:58:04 +0300 Subject: [PATCH 06/21] add service-types and cargo-types CRUD modules with pagination, filtering --- .../src/modules/cargo-types/entities/cargo-type.entity.ts | 6 +++--- .../modules/service-types/entities/service-type.entity.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts index 752e9e965..14589ae4b 100644 --- a/apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts @@ -2,9 +2,9 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity, Index, ManyToOne, OneToMany, JoinColumn } from "typeorm"; @Entity({ schema: "freight", name: "cargo_types" }) -@Index(["is_active"]) -@Index(["display_order"]) -@Index(["parent_group_id"]) +@Index(["isActive"]) +@Index(["displayOrder"]) +@Index(["parentGroupId"]) export class CargoType extends BaseEntity { @Column({ name: "cargo_type_name", type: "varchar", length: 255, nullable: false }) cargoTypeName!: string; diff --git a/apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts index 986add2c1..756ece6e0 100644 --- a/apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts +++ b/apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts @@ -2,8 +2,8 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity, Index } from "typeorm"; @Entity({ schema: "freight", name: "service_types" }) -@Index(["is_active"]) -@Index(["display_order"]) +@Index(["isActive"]) +@Index(["displayOrder"]) export class ServiceType extends BaseEntity { @Column({ name: "service_name", type: "varchar", length: 255, nullable: false }) serviceName!: string; From 8039f70deb23f67c915e26ccd971035f2e5d8f10 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 26 May 2026 10:09:27 +0300 Subject: [PATCH 07/21] refactor(booking): Migrate new booking logic to react-query, deprecate mock service and draft IDs --- .../src/pages/bookings/NewBookingPage.tsx | 96 +++++-------------- .../pages/bookings/new-booking-form/schema.ts | 14 +-- .../new-booking-form/step1-contract-type.tsx | 20 +--- 3 files changed, 27 insertions(+), 103 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 49d233167..da777d491 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,11 +1,11 @@ import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@edr/ui-common"; import Breadcrumbs from "@/components/Breadcrumbs"; -import { addBooking } from "./bookings.mock"; import { getCurrentCustomer } from "@/lib/currentCustomer"; import { api } from "@/services/api"; import type { CreateBookingPayload } from "@/services/bookings.service"; @@ -33,10 +33,18 @@ import { export default function NewBookingPage() { const navigate = useNavigate(); + const queryClient = useQueryClient(); const [step, setStep] = useState(1); const [renewalValidating, setRenewalValidating] = useState(false); const [renewalValid, setRenewalValid] = useState(null); - const [submitted, setSubmitted] = useState(false); + const createMutation = useMutation({ + mutationFn: (payload: CreateBookingPayload) => + api.bookings.create.call(payload), + onSuccess: (booking) => { + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + setTimeout(() => navigate(`/bookings/${booking.id}`), 2500); + }, + }); const form = useForm({ defaultValues: initialBookingFormValues, @@ -48,8 +56,6 @@ export default function NewBookingPage() { const destinationYard = form.watch("destinationYard"); const containers = form.watch("containers"); const previousContractRef = form.watch("previousContractRef"); - const contractId = - form.watch("draftContractId") || form.watch("previousContractRef"); const direction = useMemo( () => getRouteDirection(originYard, destinationYard), @@ -113,7 +119,7 @@ export default function NewBookingPage() { setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); } - function handleSubmit(data: BookingFormValues) { + const handleSubmit = form.handleSubmit((data) => { if (data.contractType === "renewal" && renewalValid !== true) { form.setError("previousContractRef", { type: "manual", @@ -124,15 +130,7 @@ export default function NewBookingPage() { } const me = getCurrentCustomer(); - const reference = - data.draftContractId || - data.previousContractRef || - `EDR-DRAFT-${Date.now()}`; - - const qtyCount = - data.cargoType === "container" - ? data.containers.reduce((acc, c) => acc + Number(c.qty || 0), 0) - : 1; + const reference = data.previousContractRef; const totalWeight = data.cargoType === "container" @@ -142,48 +140,13 @@ export default function NewBookingPage() { ) : Number(data.cargoWeight || 0); - const description = - data.cargoType === "container" - ? data.containers.map((c) => `${c.qty} × ${c.type}`).join(", ") - : data.freightType === "bulk" - ? `Bulk - ${data.bulkCommodity === "Others" ? data.bulkCommodityOther : data.bulkCommodity}` - : `Break-Bulk - ${data.breakBulkType === "Others" ? data.breakBulkTypeOther : data.breakBulkType}`; - - const newBooking = { - id: Date.now(), - reference, - customerId: me.id, - customer: me.company, - cargoType: (data.cargoType === "container" - ? "Containerized" - : "Bulk") as any, - originStation: data.originYard, - destinationStation: data.destinationYard, - transportMode: (data.serviceType === "rail" - ? "Rail" - : "Multimodal") as any, - containerType: (data.cargoType === "container" && - data.containers[0]?.type === "40ft" - ? "40FT" - : "20FT") as any, - containerCount: qtyCount, - weightTons: totalWeight, - requestedDate: new Date().toISOString().slice(0, 10), - priority: (data.isHazardous ? "High" : "Normal") as any, - cargoDescription: description, - specialInstructions: data.notes || "Standard handling required", - status: "Pending" as any, - }; - - addBooking(newBooking); - - // Call API using api.bookings.create.call const apiPayload = { reference, customerId: String(me.id), scheduledDate: new Date().toISOString().slice(0, 10), totalAmount: 0, - contractType: data.contractType.toUpperCase(), + contractType: + data.contractType.toUpperCase() as CreateBookingPayload["contractType"], previousContractId: data.previousContractRef || undefined, serviceType: data.serviceType === "rail" ? "RAIL_ONLY" : "RAIL_AND_FORWARDING", @@ -197,8 +160,8 @@ export default function NewBookingPage() { : undefined, equipmentReturn: data.equipmentReturn === "with_return" - ? "WITH_RETURN" - : "WITHOUT_RETURN", + ? ("WITH_RETURN" as const) + : ("WITHOUT_RETURN" as const), originStation: data.originYard, destinationStation: data.destinationYard, cargoTotalWeightVgm: totalWeight, @@ -220,31 +183,18 @@ export default function NewBookingPage() { ...(data.cargoType === "container" && data.containers.length > 0 ? { containers: data.containers.map((c) => ({ - type: c.type === "40ft" ? "40FT" as const : "20FT" as const, + type: c.type === "40ft" ? ("40FT" as const) : ("20FT" as const), qty: Number(c.qty || 1), vgm: Number(c.vgm || 0), })), } : {}), - }; + } satisfies CreateBookingPayload; - api.bookings.create - .call(apiPayload as CreateBookingPayload) - .then((created) => { - console.log("Successfully created booking via API:", created); - }) - .catch((err) => { - console.warn( - "API call failed (expected if API server is offline), falling back to mock storage:", - err, - ); - }); + createMutation.mutate(apiPayload); + }); - setSubmitted(true); - setTimeout(() => navigate("/bookings"), 2500); - } - - if (submitted) { + if (createMutation.isSuccess && createMutation.data) { return (
@@ -257,7 +207,7 @@ export default function NewBookingPage() { notified once approved.

- {contractId} + {createMutation.data.reference}

@@ -268,7 +218,7 @@ export default function NewBookingPage() {
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 1549606ed..2a34eb843 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 @@ -151,7 +151,6 @@ export const bookingFormSchema = z .object({ contractType: z.enum(["new", "renewal"], "Select a contract type."), previousContractRef: z.string(), - draftContractId: z.string(), serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."), firstMileEnabled: z.boolean(), pickUpAddress: z.string(), @@ -162,7 +161,7 @@ export const bookingFormSchema = z destinationYard: z.string(), cargoType: z.enum(["container", "bulk"], "Select a cargo type."), cargoWeight: z.string(), - freightType: z.enum(["bulk", "break_bulk", ""]).default(""), + freightType: z.enum(["bulk", "break_bulk"]), bulkCommodity: z.string(), bulkCommodityOther: z.string(), breakBulkType: z.string(), @@ -188,14 +187,6 @@ export const bookingFormSchema = z termsAccepted: z.boolean(), }) .superRefine((data, ctx) => { - if (data.contractType === "new" && !data.draftContractId.trim()) { - ctx.addIssue({ - code: "custom", - path: ["draftContractId"], - message: "A draft contract ID is required.", - }); - } - if (data.contractType === "renewal" && !data.previousContractRef.trim()) { ctx.addIssue({ code: "custom", @@ -360,7 +351,6 @@ export type BookingFormValues = z.infer; export const initialBookingFormValues: Partial = { previousContractRef: "", - draftContractId: "", firstMileEnabled: false, pickUpAddress: "", lastMileEnabled: false, @@ -383,7 +373,7 @@ export const initialBookingFormValues: Partial = { }; export const stepFields: Record> = { - 1: ["contractType", "previousContractRef", "draftContractId"], + 1: ["contractType", "previousContractRef"], 2: ["serviceType"], 3: [ "firstMileEnabled", 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 1343bdb2e..e8e045e5c 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,7 @@ import { Controller, type UseFormReturn } from "react-hook-form"; import { FileText, Loader2, RefreshCw } from "lucide-react"; -import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common"; -import { type BookingFormValues, genContractId } from "./schema"; +import { Button, Field, FieldLabel, Input } from "@edr/ui-common"; +import { type BookingFormValues } from "./schema"; import { AlertBox, OptionCard, OptionFieldError, StepHeader } from "./shared"; type BookingForm = UseFormReturn; @@ -18,9 +18,7 @@ export function Step1ContractType({ onValidate: () => void; }) { const contractType = form.watch("contractType"); - const draftContractId = form.watch("draftContractId"); const previousContractRef = form.watch("previousContractRef"); - const errors = form.formState.errors; return (
@@ -40,12 +38,6 @@ export function Step1ContractType({ onClick={() => { field.onChange("new"); form.clearErrors(["contractType", "previousContractRef"]); - if (!draftContractId) { - form.setValue("draftContractId", genContractId(), { - shouldDirty: true, - shouldValidate: true, - }); - } }} >
@@ -55,11 +47,6 @@ export function Step1ContractType({

Blank contract form. A draft ID is auto-generated.

- {field.value === "new" && draftContractId && ( -

- {draftContractId} -

- )}
- )} /> From 67d9d13591b18b9b1ea53b9f00dc2ced4cc4677b Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 26 May 2026 10:10:47 +0300 Subject: [PATCH 08/21] chore: sync Ibooking --- packages/types/src/freight/index.ts | 45 ++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index b6d1e6dfa..48d3f87af 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -108,11 +108,54 @@ export interface IConsignment extends BaseEntity { export interface IBooking extends BaseEntity { reference: string; customerId: string; - trainId?: string; + trainId?: string | null; status: BookingStatus; scheduledDate: string; totalAmount: number; paymentStatus: PaymentStatus; + + contractType: "NEW" | "RENEWAL"; + previousContractId?: string | null; + serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING"; + + firstMileEnabled: boolean; + firstMilePickupAddress?: string | null; + lastMileEnabled: boolean; + lastMileDeliveryAddress?: string | null; + + equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN"; + originStation: string; + destinationStation: string; + cargoTotalWeightVgm: number; + + freightType: "BULK" | "BREAK_BULK"; + freightSubtype?: string | null; + + isHazardous: boolean; + isRefrigerated: boolean; + + tradeDirection: "IMPORT" | "EXPORT"; + paymentCurrency: string; + allowConsolidation: boolean; + consolidationPartnerId?: string | null; + + startDate?: string | null; + endDate?: string | null; + financialTerms?: string | null; + + containers?: Array<{ type: string; qty: number; vgm: number }> | null; + + versionNumber: number; + priorityScore: number; + + approvedByStaffId?: string | null; + approvedByStaffAt?: string | null; + signedByDirectorId?: string | null; + signedByDirectorAt?: string | null; + signedByCeoId?: string | null; + signedByCeoAt?: string | null; + + files?: Array<{ id: string; name: string; url: string; mimeType: string }>; } export interface IInvoice extends BaseEntity { From 6b2a34151190d376a06dd12ef67f500a7a1daaac Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 10:32:46 +0300 Subject: [PATCH 09/21] refactor(services): Consolidate user authentication logic and add customer API --- .../portal/src/services/account.ts | 92 ------------------ .../portal/src/services/api.ts | 95 +++++++++++++++++++ .../portal/src/services/auth.service.ts | 78 +++++++++++++++ apps/edr-freight-web/portal/src/types/auth.ts | 65 +++++++++++++ .../portal/src/types/createUser.ts | 10 -- .../src/types/generateVerificationCode.ts | 5 - 6 files changed, 238 insertions(+), 107 deletions(-) delete mode 100644 apps/edr-freight-web/portal/src/services/account.ts create mode 100644 apps/edr-freight-web/portal/src/services/auth.service.ts create mode 100644 apps/edr-freight-web/portal/src/types/auth.ts delete mode 100644 apps/edr-freight-web/portal/src/types/createUser.ts delete mode 100644 apps/edr-freight-web/portal/src/types/generateVerificationCode.ts diff --git a/apps/edr-freight-web/portal/src/services/account.ts b/apps/edr-freight-web/portal/src/services/account.ts deleted file mode 100644 index 6e97e2dd0..000000000 --- a/apps/edr-freight-web/portal/src/services/account.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { URL_CONSTANTS } from "@/constants/URLS"; -import { CreateUserPayload } from "@/types/createUser"; -import { VerificationCodePayload } from "@/types/generateVerificationCode"; -import { UserTypeRequest } from "@/types/userTypeRequest"; -import { client } from "@/utils/api"; -import { ApiResponse } from "@edr/types"; -import { GenerateVerifcationCodePayload } from "node_modules/@tria-plc/iamui-common/dist/types/shared/services/authService"; - -// ----------------------------------------------------------------------------- -// API -// ----------------------------------------------------------------------------- - -export const createUser = async ( - body: CreateUserPayload -) => { - const res = - await client.post< - ApiResponse - >( - URL_CONSTANTS.USERS.SIGN_UP, - body - ); - - return res.data; -}; - -export const getMyInfo = async () => { - const res = - await client.get< - ApiResponse - >( - URL_CONSTANTS.USERS.ME - ); - - return res.data; -}; - -export const generateVerificationCode = async ( - body: VerificationCodePayload -) => { - const res = - await client.patch< - ApiResponse - >( - URL_CONSTANTS.USERS.GENERATE_VERIFICATION_CODE, - body - ); - - return res.data.data; -}; - -export const setPassword = async ( - body: any -) => { - const res = - await client.patch< - ApiResponse - >( - URL_CONSTANTS.USERS.SET_PASSWORD, - body - ); - - return res.data.data; -}; - -export const createOTP = async ( - body: any -) => { - const res = - await client.post< - ApiResponse - >( - URL_CONSTANTS.OTP.SEND, - body - ); - - return res.data; -}; - -export const verifyOTP = async ( - body: any -) => { - const res = - await client.post< - ApiResponse - >( - URL_CONSTANTS.OTP.VERIFY, - body - ); - - return res.data; -}; \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index ba2b227c6..d05807090 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -13,6 +13,8 @@ import { consignmentsService } from "./consignments.service"; import { trackingService } from "./tracking.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service"; import { dropdownSettingsService } from "./dropdownSettings.service"; +import { authService } from "./auth.service"; +import { customersService } from "./customers.service"; import { CreateDropdownOptionDto, CreateDropdownSettingDto, @@ -21,12 +23,105 @@ import { UpdateDropdownOptionDto, UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; +import { + CreateCustomerDto, + Customer, + UpdateCustomerDto, +} from "@/types/customers"; +import type { + AuthUser, + GenerateVerificationCodePayload, + LoginPayload, + LoginResponse, + OtpPayload, + OtpResponse, + SetPasswordPayload, + SignupPayload, + SignupResponse, +} from "@/types/auth"; // --------------------------------------------------------------------------- // API definition // --------------------------------------------------------------------------- export const api = { + auth: { + login: endpoint( + "auth", + "login", + authService.login, + ), + createUser: endpoint( + "auth", + "createUser", + authService.createUser, + ), + getMyInfo: endpoint( + "auth", + "getMyInfo", + authService.getMyInfo, + ), + generateVerificationCode: endpoint( + "auth", + "generateVerificationCode", + authService.generateVerificationCode, + ), + setPassword: endpoint( + "auth", + "setPassword", + authService.setPassword, + ), + sendOTP: endpoint( + "auth", + "sendOTP", + authService.sendOTP, + ), + verifyOTP: endpoint( + "auth", + "verifyOTP", + authService.verifyOTP, + ), + logout: endpoint( + "auth", + "logout", + authService.logout, + ), + }, + + customers: { + list: endpoint( + "customers", + "list", + customersService.list, + ), + + get: endpoint<{ id: string }, Customer>("customers", "get", ({ id }) => + customersService.getById(id), + ), + + create: endpoint( + "customers", + "create", + customersService.create, + ), + + update: endpoint<{ id: string; dto: UpdateCustomerDto }, Customer>( + "customers", + "update", + ({ id, dto }) => customersService.update(id, dto), + ), + + remove: endpoint<{ id: string }, void>("customers", "remove", ({ id }) => + customersService.remove(id), + ), + + getByUserId: endpoint<{ id: string }, Customer>( + "customers", + "getByUserId", + ({ id }) => customersService.getByUserId(id), + ), + }, + bookings: { list: endpoint>( "bookings", diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts new file mode 100644 index 000000000..bb162fbea --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -0,0 +1,78 @@ +import { URL_CONSTANTS } from "@/constants/URLS"; +import { client } from "@/utils/api"; +import { ApiResponse } from "@edr/types"; +import type { + AuthUser, + GenerateVerificationCodePayload, + LoginPayload, + LoginResponse, + OtpPayload, + OtpResponse, + SetPasswordPayload, + SignupPayload, + SignupResponse, +} from "@/types/auth"; + +export const authService = { + login: async (body: LoginPayload) => { + const res = await client.post>( + URL_CONSTANTS.AUTH.LOGIN, + body, + ); + return res.data.data; + }, + + createUser: async (body: SignupPayload) => { + const res = await client.post>( + URL_CONSTANTS.USERS.SIGN_UP, + body, + ); + return res.data.data; + }, + + getMyInfo: async () => { + const res = await client.get>( + URL_CONSTANTS.USERS.ME, + ); + return res.data.data; + }, + + generateVerificationCode: async (body: GenerateVerificationCodePayload) => { + const res = await client.patch>( + URL_CONSTANTS.USERS.GENERATE_VERIFICATION_CODE, + body, + ); + return res.data.data; + }, + + setPassword: async (body: SetPasswordPayload) => { + const res = await client.patch>( + URL_CONSTANTS.USERS.SET_PASSWORD, + body, + ); + return res.data.data; + }, + + sendOTP: async (body: OtpPayload) => { + const res = await client.post>( + URL_CONSTANTS.OTP.SEND, + body, + ); + return res.data.data; + }, + + verifyOTP: async (body: OtpPayload) => { + const res = await client.post>( + URL_CONSTANTS.OTP.VERIFY, + body, + ); + return res.data.data; + }, + + logout: async () => { + const res = await client.patch>( + URL_CONSTANTS.AUTH.LOGOUT, + ); + return res.data.data; + }, +}; diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts new file mode 100644 index 000000000..08318417d --- /dev/null +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -0,0 +1,65 @@ +export interface AuthUser { + id: string; + name: { am: string; en: string }; + email: string; + roles: string[]; + status: string; + employee: any[]; + userType: string; + username: string; + permissions: string[]; + phoneNumber: string; + sharepointId: string | null; + hasSetPassword: boolean; + hasFinishedRegistration: boolean; + hasFinishedDMSOnboarding: boolean; +} + +export interface SignupPayload { + email: string; + username: string; + phoneNumber: string; + userType: string; + name: { en: string; am: string }; +} + +export interface SignupResponse { + token: string; + refreshToken: string; + otp: string; + userId: string; +} + +export interface OtpPayload { + phone: string; + otp: string; +} + +export interface OtpResponse { + success: boolean; + message: string; +} + +export interface SetPasswordPayload { + newPassword: string; + confirmPassword: string; + userId: string; + email: string; + verificationCode: string; +} + +export interface GenerateVerificationCodePayload { + email: string; + phoneNumber: string; + type: string; +} + +export interface LoginPayload { + email: string; + password: string; +} + +export interface LoginResponse { + token: string; + refreshToken: string; +} diff --git a/apps/edr-freight-web/portal/src/types/createUser.ts b/apps/edr-freight-web/portal/src/types/createUser.ts deleted file mode 100644 index 53bd6c8e9..000000000 --- a/apps/edr-freight-web/portal/src/types/createUser.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type CreateUserPayload = { - email: string; - username: string; - phoneNumber: string; - userType: string; - name: { - en: string; - am?: string; - }; -}; \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/types/generateVerificationCode.ts b/apps/edr-freight-web/portal/src/types/generateVerificationCode.ts deleted file mode 100644 index 24f0e206d..000000000 --- a/apps/edr-freight-web/portal/src/types/generateVerificationCode.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type VerificationCodePayload = { - email: string; - phoneNumber: string; - type: string; -}; \ No newline at end of file From 06c976d6d9251fe25baf0894fba6afd18715db22 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 10:35:31 +0300 Subject: [PATCH 10/21] chore: urls --- .../portal/src/constants/URLS.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index f22b31e39..6bce94b12 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -1,21 +1,25 @@ export const URL_CONSTANTS = { AUTH: { - LOGIN: "/auth/login", - REGISTER: "/auth/register", + LOGIN: "/api/auth/login", + REGISTER: "/api/auth/register", REFRESH_TOKEN: "/auth/refresh-token", - LOGOUT: "/auth/logout", + LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", }, USERS: { - SIGN_UP: "/api/auth/signup", - GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code", BASE: "/users", BY_ID: (id: string | number) => `/users/${id}`, + SIGN_UP: "/api/auth/signup", SET_PASSWORD: "/api/auth/set-password", - ME: "/api/auth/me" + ME: "/api/auth/me", + GENERATE_VERIFICATION_CODE: "/users/generate-verification-code", }, + OTP: { + SEND: "/api/otp/send", + VERIFY: "/api/otp/verify", + }, ROLES: { BASE: "/roles", BY_ID: (id: string | number) => `/roles/${id}`, @@ -68,11 +72,11 @@ export const URL_CONSTANTS = { BY_ID: (id: string | number) => `/customers/${id}`, BOOKINGS: (id: string | number) => `/customers/${id}/bookings`, }, - + CUSTOMERS_API: { BASE: "/api/customers", BY_ID: (id: string) => `/api/customers/${id}`, - BY_USER_ID: (id: string) => `/api/customers/user/${id}` + BY_USER_ID: (id: string) => `/api/customers/user/${id}`, }, BOOKINGS: { @@ -81,9 +85,4 @@ export const URL_CONSTANTS = { CANCEL: (id: string | number) => `/bookings/${id}/cancel`, CONFIRM: (id: string | number) => `/bookings/${id}/confirm`, }, - - OTP: { - SEND: "/api/otp/send", - VERIFY: "/api/otp/verify", - } -}; \ No newline at end of file +}; From 6dac547a74f84a7f166ec052fcf8af092cce5268 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 10:36:43 +0300 Subject: [PATCH 11/21] feat(utils): Introduce Result type and API error extraction utility --- .../portal/src/utils/result.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/edr-freight-web/portal/src/utils/result.ts diff --git a/apps/edr-freight-web/portal/src/utils/result.ts b/apps/edr-freight-web/portal/src/utils/result.ts new file mode 100644 index 000000000..3e9627445 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/result.ts @@ -0,0 +1,29 @@ +export type Result = + | { success: true; data: T } + | { success: false; error: E }; + +export type ApiError = { + code: string; + message: string; + statusCode?: number; +}; + +export function extractApiError(err: unknown): ApiError { + if (err && typeof err === "object") { + const obj = err as Record; + const response = obj.response as Record | undefined; + if (response) { + const statusCode = response.status as number | undefined; + const data = response.data as Record | undefined; + return { + code: (data?.error as string) || (data?.message as string) || "api_error", + message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", + statusCode, + }; + } + if (obj.message && typeof obj.message === "string") { + return { code: "client_error", message: obj.message }; + } + } + return { code: "unknown_error", message: "An unexpected error occurred" }; +} From 64356a20e606efba97b649c7ebf4dd556c5ee4db Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 10:37:03 +0300 Subject: [PATCH 12/21] feat(frieght): setup useAuth --- .../portal/src/hooks/useAuth.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/edr-freight-web/portal/src/hooks/useAuth.ts diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts new file mode 100644 index 000000000..2bd7b6383 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -0,0 +1,141 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/services/api"; +import type { + GenerateVerificationCodePayload, + LoginPayload, + LoginResponse, + OtpPayload, + OtpResponse, + SetPasswordPayload, + SignupPayload, + SignupResponse, +} from "@/types/auth"; +import type { Result } from "@/utils/result"; +import { extractApiError } from "@/utils/result"; + +const useAuth = () => { + const queryClient = useQueryClient(); + + const authQuery = useQuery(api.auth.getMyInfo.queryOptions()); + + const customerQuery = useQuery( + api.customers.getByUserId.queryOptions({ + input: { id: authQuery.data?.id ?? "" }, + enabled: !!authQuery.data?.id, + retry: false, + }), + ); + + const isPending = authQuery.isPending; + + const login = async ( + payload: LoginPayload, + ): Promise> => { + try { + const res = await api.auth.login.call(payload); + await queryClient.invalidateQueries({ + queryKey: api.auth.getMyInfo.queryKey(), + }); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const signup = async ( + payload: SignupPayload, + ): Promise> => { + try { + const res = await api.auth.createUser.call(payload); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const setPassword = async ( + payload: SetPasswordPayload, + ): Promise> => { + try { + await api.auth.setPassword.call(payload); + return { success: true, data: undefined }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const verifyOTP = async ( + payload: OtpPayload, + ): Promise> => { + try { + const res = await api.auth.verifyOTP.call(payload); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const sendOTP = async ( + payload: OtpPayload, + ): Promise> => { + try { + const res = await api.auth.sendOTP.call(payload); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const generateVerificationCode = async ( + payload: GenerateVerificationCodePayload, + ): Promise> => { + try { + const res = await api.auth.generateVerificationCode.call(payload); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const logout = async () => { + try { + await api.auth.logout.call(); + } catch { + // proceed with client-side cleanup even if server call fails + } + [ + "auth-token", + "refresh-token", + "auth-user", + "current-position-id", + "selected-position-id", + ].forEach((name) => { + document.cookie = `${name}=; Max-Age=0; path=/`; + }); + localStorage.clear(); + queryClient.clear(); + window.location.href = "/auth"; + }; + + const invalidate = async () => { + await Promise.all([authQuery.refetch(), customerQuery.refetch()]); + }; + + return { + isPending, + user: authQuery.data ?? null, + customer: customerQuery.data ?? null, + login, + signup, + setPassword, + verifyOTP, + sendOTP, + generateVerificationCode, + logout, + invalidate, + authQuery, + customerQuery, + }; +}; + +export default useAuth; From 53e8b40123a0f55b7c0fc18c47789e4789b0fcab Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 11:38:22 +0300 Subject: [PATCH 13/21] refactor(auth): Remove IAM library, implement custom cookie and local storage auth --- .../portal/src/constants/URLS.ts | 2 +- .../portal/src/hooks/useAuth.ts | 90 ++++++++---- apps/edr-freight-web/portal/src/main.tsx | 43 +----- .../portal/src/services/auth.service.ts | 12 ++ apps/edr-freight-web/portal/src/utils/api.ts | 129 +++++++++++++++--- 5 files changed, 190 insertions(+), 86 deletions(-) diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 6bce94b12..84db5e2c2 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -2,7 +2,7 @@ export const URL_CONSTANTS = { AUTH: { LOGIN: "/api/auth/login", REGISTER: "/api/auth/register", - REFRESH_TOKEN: "/auth/refresh-token", + REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", }, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 2bd7b6383..5b626721c 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -1,22 +1,37 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@/services/api"; import type { - GenerateVerificationCodePayload, LoginPayload, LoginResponse, - OtpPayload, - OtpResponse, - SetPasswordPayload, SignupPayload, SignupResponse, + OtpResponse, } from "@/types/auth"; import type { Result } from "@/utils/result"; import { extractApiError } from "@/utils/result"; +function setCookie(name: string, value: string, days: number) { + const expires = new Date(); + expires.setDate(expires.getDate() + days); + document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`; +} + +function getCookie(name: string): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.split("=")[1]; +} + const useAuth = () => { const queryClient = useQueryClient(); - const authQuery = useQuery(api.auth.getMyInfo.queryOptions()); + const authQuery = useQuery( + api.auth.getMyInfo.queryOptions({ + enabled: !!getCookie("auth-token"), + retry: false, + }), + ); const customerQuery = useQuery( api.customers.getByUserId.queryOptions({ @@ -26,16 +41,17 @@ const useAuth = () => { }), ); - const isPending = authQuery.isPending; + const hasToken = !!getCookie("auth-token"); + const isPending = authQuery.isPending && hasToken; const login = async ( payload: LoginPayload, ): Promise> => { try { const res = await api.auth.login.call(payload); - await queryClient.invalidateQueries({ - queryKey: api.auth.getMyInfo.queryKey(), - }); + setCookie("auth-token", res.token, 7); + setCookie("refresh-token", res.refreshToken, 7); + await authQuery.refetch(); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; @@ -47,39 +63,59 @@ const useAuth = () => { ): Promise> => { try { const res = await api.auth.createUser.call(payload); + localStorage.setItem("auth-token", `auth-token=${res.token}; path=/`); + localStorage.setItem("userId", res.userId); + const otpCode = res.otp?.split(" ")?.[6] ?? ""; + localStorage.setItem("otp", otpCode); + localStorage.setItem("otp-phone", payload.phoneNumber); + localStorage.setItem("otp-email", payload.email); + api.auth.sendOTP + .call({ phone: payload.phoneNumber, otp: otpCode }) + .catch(() => { }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; } }; - const setPassword = async ( - payload: SetPasswordPayload, - ): Promise> => { + const setPassword = async (data: { + newPassword: string; + confirmPassword: string; + }): Promise> => { try { - await api.auth.setPassword.call(payload); + const userId = localStorage.getItem("userId") ?? ""; + const email = localStorage.getItem("otp-email") ?? ""; + const verificationCode = localStorage.getItem("otp") ?? ""; + await api.auth.setPassword.call({ + newPassword: data.newPassword, + confirmPassword: data.confirmPassword, + userId, + email, + verificationCode, + }); + ["userId", "otp", "otp-phone", "otp-email"].forEach((k) => + localStorage.removeItem(k), + ); return { success: true, data: undefined }; } catch (err) { return { success: false, error: extractApiError(err) }; } }; - const verifyOTP = async ( - payload: OtpPayload, - ): Promise> => { + const verifyOTP = async (otp: string): Promise> => { try { - const res = await api.auth.verifyOTP.call(payload); + const phone = localStorage.getItem("otp-phone") ?? ""; + const res = await api.auth.verifyOTP.call({ phone, otp }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; } }; - const sendOTP = async ( - payload: OtpPayload, - ): Promise> => { + const sendOTP = async (otp: string): Promise> => { try { - const res = await api.auth.sendOTP.call(payload); + const phone = localStorage.getItem("otp-phone") ?? ""; + const res = await api.auth.sendOTP.call({ phone, otp }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; @@ -87,10 +123,16 @@ const useAuth = () => { }; const generateVerificationCode = async ( - payload: GenerateVerificationCodePayload, + type: string, ): Promise> => { try { - const res = await api.auth.generateVerificationCode.call(payload); + const email = localStorage.getItem("otp-email") ?? ""; + const phoneNumber = localStorage.getItem("otp-phone") ?? ""; + const res = await api.auth.generateVerificationCode.call({ + email, + phoneNumber, + type, + }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; @@ -114,7 +156,7 @@ const useAuth = () => { }); localStorage.clear(); queryClient.clear(); - window.location.href = "/auth"; + window.location.href = "/login"; }; const invalidate = async () => { diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 45014a8d5..8c4fbeb96 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -2,18 +2,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import "@tria-plc/iamui-common/styles.css"; import "@edr/ui-common/styles.css"; import "../index.css"; import "@edr/ui-common/theme.css"; import App from "./App"; -import { - AuthProvider, - configureIam, - UserProvider, - axiosInstance, -} from "@tria-plc/iamui-common"; // Purge cookies that were stored as the literal string "undefined" before the // envelope interceptor fix. Without this, stale sessions would keep sending @@ -29,36 +22,6 @@ import { }); const queryClient = new QueryClient(); -window.__IAM_CONFIG__ = { - apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`, - postLoginPath: "/", -}; - -// Unwrap the StandardResponse envelope ({ success, data, timestamp }) that the -// freight API's ResponseTransformInterceptor adds to every response, so that -// iamui-common can read response.data.token / response.data fields as expected. -axiosInstance.interceptors.response.use((response) => { - if ( - response.data && - typeof response.data === "object" && - "success" in response.data && - "data" in response.data - ) { - response.data = response.data.data; - } - return response; -}); -window.__USER_MANAGEMENT_BRANDING__ = { - organizationName: "EDR Platform", - appName: "EDR Portal", - moduleBasePath: "/user-management", - backToAppPath: "/", - backToAppLabel: "Back to dashboard", -}; - -configureIam({ - apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`, -}); const rootElement = document.getElementById("root"); @@ -70,11 +33,7 @@ createRoot(document.getElementById("root")!).render( - - - - - + , diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index bb162fbea..856d417cc 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -69,6 +69,18 @@ export const authService = { return res.data.data; }, + refreshToken: async () => { + const refreshTokenCookie = document.cookie + .split("; ") + .find((row) => row.startsWith("refresh-token=")) + ?.split("=")[1]; + const res = await client.post>( + URL_CONSTANTS.AUTH.REFRESH_TOKEN, + { refreshToken: refreshTokenCookie }, + ); + return res.data.data; + }, + logout: async () => { const res = await client.patch>( URL_CONSTANTS.AUTH.LOGOUT, diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 5fe426a0c..d152e6aad 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -2,38 +2,129 @@ import { UseQueryOptions, QueryObserverOptions, } from "@tanstack/react-query"; -import axios from "axios"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import { URL_CONSTANTS } from "@/constants/URLS"; -// --------------------------------------------------------------------------- -// Axios client -// --------------------------------------------------------------------------- - -export const client = axios.create({ +const client = axios.create({ baseURL: import.meta.env.VITE_API_URL, }); +function getCookie(name: string): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.split("=")[1]; +} + +function setCookie(name: string, value: string, days: number) { + const expires = new Date(); + expires.setDate(expires.getDate() + days); + document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`; +} + +function clearAuthCookies() { + ["auth-token", "refresh-token", "auth-user", "current-position-id", "selected-position-id"].forEach( + (name) => { + document.cookie = `${name}=; Max-Age=0; path=/`; + }, + ); +} + // Attach auth token to every request client.interceptors.request.use((config) => { - // TODO: replace with secure storage (cookie/localStorage/auth provider) - const token = document.cookie - .split("; ") - .find((row) => row.startsWith("auth-token=")) - ?.split("=")[1]; - + const token = getCookie("auth-token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } - return config; }); -// Handle auth errors globally +// Token refresh state +let isRefreshing = false; +let failedQueue: { + resolve: (token: string) => void; + reject: (error: unknown) => void; +}[] = []; + +function processQueue(error: unknown, token?: string) { + failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error); + } else { + resolve(token!); + } + }); + failedQueue = []; +} + +// Handle auth errors globally with token refresh client.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { - window.location.href = "/auth"; + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; + + // Don't intercept if: + // - no response (network error) + // - status is not 401 + // - already retried + // - it's the refresh endpoint itself + if ( + !error.response || + error.response.status !== 401 || + originalRequest._retry || + originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN + ) { + return Promise.reject(error); } - return Promise.reject(error); - } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return client(originalRequest); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = getCookie("refresh-token"); + + if (!refreshToken) { + isRefreshing = false; + clearAuthCookies(); + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + return Promise.reject(error); + } + + try { + const { data } = await client.post<{ data: { token: string; refreshToken: string } }>( + URL_CONSTANTS.AUTH.REFRESH_TOKEN, + { refreshToken }, + ); + const { token, refreshToken: newRefreshToken } = data.data; + setCookie("auth-token", token, 7); + setCookie("refresh-token", newRefreshToken, 7); + originalRequest.headers.Authorization = `Bearer ${token}`; + processQueue(null, token); + return client(originalRequest); + } catch (refreshError) { + processQueue(refreshError, undefined); + clearAuthCookies(); + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + }, ); + +export { client }; +export type { UseQueryOptions, QueryObserverOptions }; From b30a04edce5f5ee14444417bf62e6ef29fbc36ad Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 11:41:23 +0300 Subject: [PATCH 14/21] refactor(auth): Transition main app to custom authentication system and local useAuth hook --- apps/edr-freight-web/portal/src/App.tsx | 44 +- .../portal/src/components/auth/AuthLayout.tsx | 86 +++ .../portal/src/pages/accounts/LoginPage.tsx | 163 +++++ .../src/pages/accounts/SetPasswordPage.tsx | 519 ++++---------- .../portal/src/pages/accounts/SignupPage.tsx | 650 +++++------------- .../pages/accounts/VerificationOtpPage.tsx | 573 ++++----------- 6 files changed, 696 insertions(+), 1339 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 968f0925d..a546a43d8 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -19,6 +19,7 @@ import { UserCircle, FileUp, MapPinned, + Loader2, } from "lucide-react"; import BookingsPage from "./pages/bookings/BookingsPage"; @@ -31,12 +32,7 @@ import TrackingPage from "./pages/tracking/TrackingPage"; import BillingPage from "./pages/billing/BillingPage"; import TrainsPage from "./pages/trains/TrainsPage"; import DashboardPage from "./pages/dashboard/DashboardPage"; -import { - IamLoginPage, - LoadingScreen, - useAuth, - useAuthUser, -} from "@tria-plc/iamui-common"; +import useAuth from "./hooks/useAuth"; import CustomersPage from "./pages/customers/CustomersPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import NewCustomerPage from "./pages/customers/NewCustomerPage"; @@ -48,6 +44,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import SignupPage from "./pages/accounts/SignupPage"; import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; +import LoginPage from "./pages/accounts/LoginPage"; import Station from "./components/stations/Station"; const sidebarItems: SidebarItem[] = [ @@ -72,22 +69,26 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, loading } = useAuth(); - const { logout } = useAuthUser(); + const { user, isPending, logout } = useAuth(); - if (loading) { - return ; + console.log({ user, isPending }); + if (isPending) { + return ( +
+ +
+ ); } - if (user) { + if (!user) { return ( } /> + } /> } /> } /> } /> - } /> - {/* } /> */} + } /> ); } @@ -95,21 +96,6 @@ const App = () => { const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; - const handleLogout = () => { - logout(); - [ - "auth-token", - "refresh-token", - "auth-user", - "current-position-id", - "selected-position-id", - ].forEach((name) => { - document.cookie = `${name}=; Max-Age=0; path=/`; - }); - localStorage.clear(); - window.location.replace("/auth"); - }; - return ( { enableThemeToggle userName={displayName} userEmail={userEmail} - onLogout={handleLogout} + onLogout={logout} > } /> diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx new file mode 100644 index 000000000..c4b908a09 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from "react"; +import { ShieldCheck, Train } from "lucide-react"; + +export interface AuthLayoutProps { + children: ReactNode; + left: { + badge: string; + title: string; + description: string; + features: string[]; + stats: { + label: string; + value: string; + footer: string; + progress: string; + }; + }; +} + +export default function AuthLayout({ children, left }: AuthLayoutProps) { + return ( +
+
+
+
+
+
+
+ +
+
+

EDR Freight

+

Railway Logistics Platform

+
+
+
+
+ {left.badge} +
+

{left.title}

+

{left.description}

+
+
+ {left.features.map((item) => ( +
+
+ +
+ {item} +
+ ))} +
+
+
+
+
+

{left.stats.label}

+

{left.stats.value}

+
+
{left.stats.footer}
+
+
+
+
+
+
+
+
+
+
+ +
+
+

EDR Freight

+

Railway Logistics Platform

+
+
+
+ {children} +
+
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx new file mode 100644 index 000000000..0af8d0af2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowRight, Mail, Phone, Loader2 } from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import AuthLayout from "@/components/auth/AuthLayout"; + +type LoginMethod = "email" | "phone"; + +export default function LoginPage() { + const navigate = useNavigate(); + const { login } = useAuth(); + const [method, setMethod] = useState("email"); + const [identifier, setIdentifier] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const result = await login({ email: identifier, password }); + if (result.success) { + navigate("/"); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setLoading(false); + } + }; + + return ( + +
+
+ +
+

Welcome back

+

+ Enter your credentials to access your portal +

+
+ + +
+ + +
+ +
+ + setIdentifier(e.target.value)} + required + disabled={loading} + className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60" + /> +
+ +
+
+ + +
+ setPassword(e.target.value)} + required + disabled={loading} + className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60" + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +

+ Don't have an account?{" "} + +

+ +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index 83a9cd78d..268cf836e 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -1,418 +1,153 @@ -import { setPassword } from "@/services/account"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; -import { - ArrowRight, - LockKeyhole, - ShieldCheck, - Train, - Eye, - EyeOff, -} from "lucide-react"; import { useState } from "react"; -import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; - -// ----------------------------------------------------------------------------- -// Schema -// ----------------------------------------------------------------------------- +import { ArrowRight, Eye, EyeOff, LockKeyhole } from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import AuthLayout from "@/components/auth/AuthLayout"; const passwordSchema = z .object({ - password: z - .string() - .min( - 8, - "Password must be at least 8 characters" - ), - - confirmPassword: z - .string() - .min( - 8, - "Confirm password is required" - ), + password: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string().min(8, "Confirm password is required"), }) - .refine( - (data) => - data.password === - data.confirmPassword, - { - message: - "Passwords do not match", - path: ["confirmPassword"], - } - ); + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); -type FormData = z.infer< - typeof passwordSchema ->; - -// ----------------------------------------------------------------------------- -// Component -// ----------------------------------------------------------------------------- +type FormData = z.infer; export default function SetPasswordPage() { - const [ - showPassword, - setShowPassword, - ] = useState(false); - - const [ - showConfirmPassword, - setShowConfirmPassword, - ] = useState(false); + const navigate = useNavigate(); + const { setPassword } = useAuth(); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); const { register, handleSubmit, formState: { errors }, - reset, } = useForm({ - resolver: - zodResolver(passwordSchema), - - defaultValues: { - password: "", - confirmPassword: "", - }, + resolver: zodResolver(passwordSchema), + defaultValues: { password: "", confirmPassword: "" }, }); - const naviagte = useNavigate(); - - // --------------------------------------------------------------------------- - // Mutation - // --------------------------------------------------------------------------- - - const setPasswordMutation = - useMutation({ - mutationFn: async ( - data: FormData - ) => setPassword({ - newPassword: data?.password, - confirmPassword: data?.confirmPassword, - userId: localStorage.getItem("userId"), - email: localStorage.getItem("otp-email"), - verificationCode: localStorage.getItem("otp"), - }), - - onSuccess: () => { - naviagte("/auth"); - reset(); - }, - }); - - // --------------------------------------------------------------------------- - // Submit - // --------------------------------------------------------------------------- - - const onSubmit = async ( - data: FormData - ) => { + const onSubmit = async (data: FormData) => { + setError(null); + setLoading(true); try { - await setPasswordMutation.mutateAsync( - data - ); - } catch (err) { - console.error(err); + const result = await setPassword({ + newPassword: data.password, + confirmPassword: data.confirmPassword, + }); + if (result.success) { + navigate("/auth"); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setLoading(false); } }; - // --------------------------------------------------------------------------- - // UI - // --------------------------------------------------------------------------- - return ( -
-
- {/* ------------------------------------------------------------------ */} - {/* Left Side */} - {/* ------------------------------------------------------------------ */} - -
-
- -
- {/* Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Hero */} -
-
- Account Security -
- -

- Set your secure - password -

- -

- Create a strong - password to secure - your EDR Freight - account and protect - railway logistics - operations and shipment - data. -

-
- - {/* Features */} -
- {[ - "Enterprise-grade security", - "Protected account access", - "Secure freight operations", - "Advanced authentication system", - ].map((item) => ( -
-
- -
- - - {item} - -
- ))} -
-
- - {/* Stats */} -
-
-
-

- Security Protection -

- -

- 256-bit -

-
- -
- Encrypted -
-
- -
-
-
-
-
- - {/* ------------------------------------------------------------------ */} - {/* Right Side */} - {/* ------------------------------------------------------------------ */} - -
-
- {/* Mobile Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Card */} -
- {/* Header */} -
-
- -
- -

- Set Password -

- -

- Create a secure - password for your - EDR Freight account. -

-
- - {/* Success */} - {setPasswordMutation.isSuccess && ( -
- Password updated - successfully. -
- )} - - {/* Error */} - {setPasswordMutation.isError && ( -
- Failed to set - password. Please try - again. -
- )} - - {/* Form */} -
- {/* Password */} -
- - -
- - - -
- - {errors.password && ( -

- { - errors.password - .message - } -

- )} -
- - {/* Confirm Password */} -
- - -
- - - -
- - {errors.confirmPassword && ( -

- { - errors - .confirmPassword - .message - } -

- )} -
- - {/* Submit */} - -
-
-
+ +
+
+
+

Set Password

+

+ Create a secure password for your EDR Freight account. +

-
+ + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + +
+ {errors.password &&

{errors.password.message}

} +
+ +
+ +
+ + +
+ {errors.confirmPassword && ( +

{errors.confirmPassword.message}

+ )} +
+ + +
+ ); -} \ No newline at end of file +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 2b9b7f135..05affbbe8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,534 +1,194 @@ -import { userType } from "@/enums/userType"; -import { createOTP, createUser } from "@/services/account"; - -import { CreateUserPayload } from "@/types/createUser"; - -import { zodResolver } from "@hookform/resolvers/zod"; - -import { useMutation } from "@tanstack/react-query"; - -import { - ArrowRight, - ShieldCheck, - Train, - UserPlus, -} from "lucide-react"; - -import { useForm } from "react-hook-form"; - +import { useState } from "react"; import { useNavigate } from "react-router-dom"; - +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; - -// ----------------------------------------------------------------------------- -// Schema -// ----------------------------------------------------------------------------- +import { ArrowRight, UserPlus } from "lucide-react"; +import { userType } from "@/enums/userType"; +import useAuth from "@/hooks/useAuth"; +import type { SignupPayload } from "@/types/auth"; +import AuthLayout from "@/components/auth/AuthLayout"; const userSchema = z.object({ - email: z - .string() - .email("Invalid email address"), - - username: z - .string() - .min( - 3, - "Username must be at least 3 characters" - ), - - countryCode: z - .string() - .min( - 1, - "Country code is required" - ), - - phone: z - .string() - .min( - 9, - "Phone number is too short" - ) - .max( - 9, - "Phone number is too long" - ), - + email: z.string().email("Invalid email address"), + username: z.string().min(3, "Username must be at least 3 characters"), + countryCode: z.string().min(1, "Country code is required"), + phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"), userType: z.string(), - name: z.object({ - en: z - .string() - .min(2, "Name is required"), - + en: z.string().min(2, "Name is required"), am: z.string().nullable(), }), }); -type FormData = z.infer< - typeof userSchema ->; - -// ----------------------------------------------------------------------------- -// Component -// ----------------------------------------------------------------------------- +type FormData = z.infer; export default function SignupPage() { const navigate = useNavigate(); + const { signup } = useAuth(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); const { register, handleSubmit, formState: { errors }, - reset, } = useForm({ - resolver: - zodResolver(userSchema), - + resolver: zodResolver(userSchema), defaultValues: { email: "", username: "", countryCode: "+251", phone: "", - userType: - userType.individual, - - name: { - en: "", - am: "", - }, + userType: userType.individual, + name: { en: "", am: "" }, }, }); - // --------------------------------------------------------------------------- - // Create User Mutation - // --------------------------------------------------------------------------- - - const createUserMutation = - useMutation({ - mutationFn: ( - user: CreateUserPayload - ) => createUser(user), - - onSuccess: () => { - reset(); - }, - }); - - // --------------------------------------------------------------------------- - // Submit - // --------------------------------------------------------------------------- - - const onSubmit = async ( - data: FormData - ) => { + const onSubmit = async (data: FormData) => { + setError(null); + setLoading(true); try { - const normalizedPhone = - data.phone.startsWith( - "0" - ) - ? data.phone.slice(1) - : data.phone; - - const fullPhoneNumber = `${data.countryCode - }${normalizedPhone}`; - - const payload: CreateUserPayload = - { + const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone; + const payload: SignupPayload = { email: data.email, - - username: - data.username, - - phoneNumber: - fullPhoneNumber, - - userType: - data.userType, - - name: { - en: data.name.en, - am: - data.name.am || - "", - }, + username: data.username, + phoneNumber: `${data.countryCode}${normalizedPhone}`, + userType: data.userType, + name: { en: data.name.en, am: data.name.am ?? "" }, }; - - const res = - await createUserMutation.mutateAsync( - payload - ); - - if (res?.success) { - // save auth token - // document.cookie = `auth-token=${res.data?.token}; path=/`; - localStorage.setItem( - "auth-token", - `auth-token=${res.data?.token}; path=/` - ); - localStorage.setItem( - "userId",res.data?.userId - ); - localStorage.setItem( - "otp",res.data?.otp?.split(" ")?.[6] - ); - createOTP({ phone: payload.phoneNumber, otp:res.data?.otp?.split(" ")?.[6] }) - // save phone for otp page - localStorage.setItem( - "otp-phone", - payload.phoneNumber - ); - // save phone for set password page - - localStorage.setItem( - "otp-email", - payload.email - ); - // navigate otp page + const result = await signup(payload); + if (result.success) { navigate("/otp"); + } else { + setError(result.error.message); } - } catch (err) { - console.error(err); + } catch { + setError("An unexpected error occurred"); + } finally { + setLoading(false); } }; - // --------------------------------------------------------------------------- - // UI - // --------------------------------------------------------------------------- - return ( -
-
- {/* ------------------------------------------------------------------ */} - {/* Left Side */} - {/* ------------------------------------------------------------------ */} - -
-
- -
- {/* Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Hero */} -
-
- Smart Freight - Operations -
- -

- Create your freight - operations account -

- -

- Join EDR Freight to - manage shipments, - monitor railway - operations, track - consignments, and - streamline logistics - workflows across - Ethiopia and - Djibouti. -

-
- - {/* Features */} -
- {[ - "Real-time shipment tracking", - "Secure logistics management", - "Enterprise-grade operations", - "Multi-corridor freight monitoring", - ].map((item) => ( -
-
- -
- - - {item} - -
- ))} -
-
- - {/* Stats */} -
-
-
-

- Active Corridors -

- -

- 24+ -

-
- -
- Operational -
-
- -
-
-
-
-
- - {/* ------------------------------------------------------------------ */} - {/* Right Side */} - {/* ------------------------------------------------------------------ */} - -
-
- {/* Mobile Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Form Card */} -
- {/* Header */} -
-
- -
- -

- Create Account -

- -

- Register to access - EDR Freight - services and railway - logistics operations. -

-
- - {/* Success */} - {createUserMutation.isSuccess && ( -
- Account created - successfully. -
- )} - - {/* Error */} - {createUserMutation.isError && ( -
- Failed to create - account. Please try - again. -
- )} - - {/* Form */} -
- {/* Full Name */} -
- - - - - {errors.name?.en && ( -

- { - errors.name.en - .message - } -

- )} -
- - {/* Username */} -
- - - - - {errors.username && ( -

- { - errors.username - .message - } -

- )} -
- - {/* Email */} -
- - - - - {errors.email && ( -

- { - errors.email - .message - } -

- )} -
- - {/* Phone */} -
- - -
- - - -
- - {(errors.countryCode || - errors.phone) && ( -

- {errors - .countryCode - ?.message || - errors.phone - ?.message} -

- )} -
- - {/* Submit */} - - - {/* Footer */} -

- Already have an - account? - - -

-
-
-
+ +
+
+
+

Create Account

+

+ Register to access EDR Freight services and railway logistics operations. +

-
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + + {errors.name?.en &&

{errors.name.en.message}

} +
+ +
+ + + {errors.username &&

{errors.username.message}

} +
+ +
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ +
+ + +
+ {(errors.countryCode || errors.phone) && ( +

+ {errors.countryCode?.message || errors.phone?.message} +

+ )} +
+ + + +

+ Already have an account? + +

+
+ ); -} \ No newline at end of file +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx index 091406df3..183980e4f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx @@ -1,70 +1,28 @@ -import { verificationCodeType } from "@/enums/verificationCodeType"; - -import { - generateVerificationCode, - verifyOTP, -} from "@/services/account"; - -import { zodResolver } from "@hookform/resolvers/zod"; - -import { useMutation } from "@tanstack/react-query"; - +import { useState } from "react"; import { useNavigate } from "react-router-dom"; - -import { - ArrowRight, - ShieldCheck, - Train, - MailCheck, - RotateCw, -} from "lucide-react"; - import { useForm } from "react-hook-form"; - +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; - -// ----------------------------------------------------------------------------- -// Schema -// ----------------------------------------------------------------------------- +import { ArrowRight, MailCheck, RotateCw } from "lucide-react"; +import { verificationCodeType } from "@/enums/verificationCodeType"; +import useAuth from "@/hooks/useAuth"; +import AuthLayout from "@/components/auth/AuthLayout"; const otpSchema = z.object({ - code: z - .string() - .regex( - /^\d{6}$/, - "OTP must be exactly 6 digits" - ), + code: z.string().regex(/^\d{6}$/, "OTP must be exactly 6 digits"), }); -type FormData = z.infer< - typeof otpSchema ->; - -// ----------------------------------------------------------------------------- -// Component -// ----------------------------------------------------------------------------- +type FormData = z.infer; export default function VerificationOtpPage() { - const navigate = - useNavigate(); + const navigate = useNavigate(); + const { verifyOTP, generateVerificationCode } = useAuth(); + const [verifying, setVerifying] = useState(false); + const [resending, setResending] = useState(false); + const [error, setError] = useState(null); + const [resentMessage, setResentMessage] = useState(null); - // --------------------------------------------------------------------------- - // Local Storage Data - // --------------------------------------------------------------------------- - - const phone = - localStorage.getItem( - "otp-phone" - ) || ""; - - const email = - localStorage.getItem( - "otp-email" - ) || ""; - - // --------------------------------------------------------------------------- - // Form - // --------------------------------------------------------------------------- + const phone = localStorage.getItem("otp-phone") || ""; const { register, @@ -72,383 +30,152 @@ export default function VerificationOtpPage() { formState: { errors }, watch, } = useForm({ - resolver: - zodResolver(otpSchema), - - defaultValues: { - code: "", - }, + resolver: zodResolver(otpSchema), + defaultValues: { code: "" }, }); - const otpValue = - watch("code"); + const otpValue = watch("code"); - // --------------------------------------------------------------------------- - // Verify Mutation - // --------------------------------------------------------------------------- - - const verifyMutation = - useMutation({ - mutationFn: async ( - data: { - phone: string; - otp: string; - } - ) => verifyOTP(data), - - onSuccess: () => { - navigate( - "/set-password" - ); - }, - }); - - // --------------------------------------------------------------------------- - // Resend Mutation - // --------------------------------------------------------------------------- - - const resendMutation = - useMutation({ - mutationFn: async () => { - return generateVerificationCode( - { - email, - phoneNumber: - phone, - - type: - verificationCodeType.setPassword, - } - ); - }, - }); - - // --------------------------------------------------------------------------- - // Submit - // --------------------------------------------------------------------------- - - const onSubmit = async ( - data: FormData - ) => { + const onSubmit = async (data: FormData) => { + setError(null); + setVerifying(true); try { - await verifyMutation.mutateAsync( - { - phone, - otp: data.code, - } - ); - } catch (err) { - console.error(err); + const result = await verifyOTP(data.code); + if (result.success) { + navigate("/set-password"); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setVerifying(false); } }; - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- + const handleResend = async () => { + setResentMessage(null); + setResending(true); + try { + const result = await generateVerificationCode(verificationCodeType.setPassword); + if (result.success) { + setResentMessage("New OTP code sent successfully."); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setResending(false); + } + }; - const maskedPhone = - phone.length > 4 - ? `${phone.slice( - 0, - 7 - )}******` - : phone; - - // --------------------------------------------------------------------------- - // UI - // --------------------------------------------------------------------------- + const maskedPhone = phone.length > 4 ? `${phone.slice(0, 7)}******` : phone; return ( -
-
- {/* ------------------------------------------------------------------ */} - {/* Left Side */} - {/* ------------------------------------------------------------------ */} - -
-
- -
- {/* Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Hero */} -
-
- Secure - Verification -
- -

- Verify your - account securely -

- -

- Enter the - verification code - sent to your phone - number to continue - using EDR Freight - logistics services. -

-
- - {/* Features */} -
- {[ - "Secure OTP verification", - "Protected account access", - "Fast identity confirmation", - "Enterprise-grade security", - ].map((item) => ( -
-
- -
- - - {item} - -
- ))} -
-
- - {/* Footer Stats */} -
-
-
-

- Verification - Security -

- -

- 99.9% -

-
- -
- Protected -
-
- -
-
-
-
+ +
+
+
- - {/* ------------------------------------------------------------------ */} - {/* Right Side */} - {/* ------------------------------------------------------------------ */} - -
-
- {/* Mobile Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* OTP Card */} -
- {/* Header */} -
-
- -
- -

- OTP Verification -

- -

- Enter the - 6-digit code sent - to: -

- -
-

- {maskedPhone} -

-
-
- - {/* Success */} - {verifyMutation.isSuccess && ( -
- Verification - successful. -
- )} - - {/* Error */} - {verifyMutation.isError && ( -
- Invalid OTP - code. Please try - again. -
- )} - - {/* Resend Success */} - {resendMutation.isSuccess && ( -
- New OTP code sent - successfully. -
- )} - - {/* Form */} -
- {/* OTP */} -
- - - - -
- {errors.code ? ( -

- { - errors.code - .message - } -

- ) : ( -

- Enter the OTP - sent to your - phone -

- )} - - - { - otpValue.length - } - /6 - -
-
- - {/* Verify Button */} - - - {/* Resend */} - - - {/* Footer */} -

- Didn’t receive - the code? - - -

-
-
-
+

OTP Verification

+

Enter the 6-digit code sent to:

+
+

{maskedPhone}

-
+ + {error && ( +
+ {error} +
+ )} + + {resentMessage && ( +
+ {resentMessage} +
+ )} + +
+
+ + +
+ {errors.code ? ( +

{errors.code.message}

+ ) : ( +

Enter the OTP sent to your phone

+ )} + {otpValue.length}/6 +
+
+ + + + + +

+ Didn't receive the code? + +

+
+
); -} \ No newline at end of file +} From e84451ca6050f43b02dd801729256740f7fe48eb Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 13:40:07 +0300 Subject: [PATCH 15/21] fix: unused import causing error --- apps/edr-freight-api/src/app.module.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 6b0f072e8..f2ca61569 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,7 +23,6 @@ import { CargoTypesModule } from "./modules/cargo-types/cargo-types.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; - @Module({ imports: [ ConfigModule.forRoot({ @@ -58,7 +57,7 @@ export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, - ) {} + ) { } async onApplicationBootstrap() { await this.seeder.run(); From 6457b692b8f5585e0c676752368e4ca41012bbcf Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 13:42:45 +0300 Subject: [PATCH 16/21] chore: update create customer dto --- packages/types/src/freight/index.ts | 30 ++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 48d3f87af..da109ecda 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -64,17 +64,29 @@ export interface ICustomer extends BaseEntity { } export interface CreateCustomerDto { - name: string; + userId: string; + firstName: string; + lastName: string; email: string; phone: string; - company?: string; - customerType?: CustomerType; - status?: CustomerStatus; - tinNumber?: string; - city?: string; - country?: string; - address?: string; - taxId?: string; + companyName: string; + companyEmail: string; + companyPhone: string; + companyLocation: string; + companyAddress: string; + contactPersonName: string; + contactPersonPhone: string; + tinNumber: string; + vatNumber: string; + fanNumber: string; + generalManagerName: string; + generalManagerEmail: string; + generalManagerPhone: string; + poaName?: string; + poaPhone?: string; + poaAddress?: string; + poaEmail?: string; + poaLocation?: string; notes?: string; } From 10287b4bfdd316b4362816fe272ea5c401da48a8 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 13:47:36 +0300 Subject: [PATCH 17/21] style(auth): Apply updated design system guidelines to all authentication forms --- .../portal/src/components/auth/AuthLayout.tsx | 40 ++--- .../portal/src/pages/accounts/LoginPage.tsx | 147 ++++++++-------- .../src/pages/accounts/SetPasswordPage.tsx | 130 ++++++++------ .../portal/src/pages/accounts/SignupPage.tsx | 162 ++++++++++-------- .../pages/accounts/VerificationOtpPage.tsx | 111 +++++++----- 5 files changed, 324 insertions(+), 266 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx index c4b908a09..310cac6ca 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -21,7 +21,7 @@ export default function AuthLayout({ children, left }: AuthLayoutProps) { return (
-
+
@@ -29,16 +29,22 @@ export default function AuthLayout({ children, left }: AuthLayoutProps) {
-

EDR Freight

-

Railway Logistics Platform

+

EDR Freight

+

+ Railway Logistics Platform +

-
+
{left.badge}
-

{left.title}

-

{left.description}

+

+ {left.title} +

+

+ {left.description} +

{left.features.map((item) => ( @@ -51,33 +57,21 @@ export default function AuthLayout({ children, left }: AuthLayoutProps) { ))}
-
-
-
-

{left.stats.label}

-

{left.stats.value}

-
-
{left.stats.footer}
-
-
-
-
-
-
+

EDR Freight

-

Railway Logistics Platform

+

+ Railway Logistics Platform +

-
- {children} -
+
{children}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 0af8d0af2..c3238f3d4 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -3,6 +3,14 @@ import { useNavigate } from "react-router-dom"; import { ArrowRight, Mail, Phone, Loader2 } from "lucide-react"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; type LoginMethod = "email" | "phone"; @@ -46,116 +54,117 @@ export default function LoginPage() { "Enterprise-grade operations", "Multi-corridor freight monitoring", ], - stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" }, + stats: { + label: "Active Corridors", + value: "24+", + footer: "Operational", + progress: "w-[95%]", + }, }} > -
-
- +
+
+
-

Welcome back

-

+

Welcome back

+

Enter your credentials to access your portal

-
+
- - +
-
- - setIdentifier(e.target.value)} - required - disabled={loading} - className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60" - /> -
+ + + + {method === "email" ? "Email Address" : "Phone Number"} + + setIdentifier(e.target.value)} + required + disabled={loading} + /> + -
-
- - -
- setPassword(e.target.value)} - required - disabled={loading} - className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60" - /> -
+ +
+ Password + +
+ setPassword(e.target.value)} + required + disabled={loading} + /> +
+
{error && ( -
+
{error}
)} - +

Don't have an account?{" "} - +

diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index 268cf836e..c8f2a7184 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -3,9 +3,17 @@ import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { ArrowRight, Eye, EyeOff, LockKeyhole } from "lucide-react"; +import { ArrowRight, Eye, EyeOff, LockKeyhole, Loader2 } from "lucide-react"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; const passwordSchema = z .object({ @@ -72,81 +80,91 @@ export default function SetPasswordPage() { stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" }, }} > -
-
- +
+
+
-

Set Password

-

- Create a secure password for your EDR Freight account. +

Set Password

+

+ Create a secure password for your account.

{error && ( -
+
{error}
)} -
-
- -
- - -
- {errors.password &&

{errors.password.message}

} -
+ + + + Password +
+ + +
+ +
-
- -
- - -
- {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
+ + Confirm Password +
+ + +
+ +
+
- +
); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 05affbbe8..0b4523453 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -3,11 +3,19 @@ import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { ArrowRight, UserPlus } from "lucide-react"; +import { ArrowRight, UserPlus, Loader2 } from "lucide-react"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; import AuthLayout from "@/components/auth/AuthLayout"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; const userSchema = z.object({ email: z.string().email("Invalid email address"), @@ -86,107 +94,115 @@ export default function SignupPage() { stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" }, }} > -
-
- +
+
+
-

Create Account

-

- Register to access EDR Freight services and railway logistics operations. +

Create Account

+

+ Register to access EDR Freight services.

{error && ( -
+
{error}
)} -
-
- - - {errors.name?.en &&

{errors.name.en.message}

} -
- -
- - - {errors.username &&

{errors.username.message}

} -
- -
- - - {errors.email &&

{errors.email.message}

} -
- -
- -
- + + + Full Name + - -
- {(errors.countryCode || errors.phone) && ( -

- {errors.countryCode?.message || errors.phone?.message} -

- )} -
+ + -
+ + + Phone Number +
+ + +
+ +
+ + + +

Already have an account? - +

diff --git a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx index 183980e4f..a09417c01 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx @@ -3,10 +3,18 @@ import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { ArrowRight, MailCheck, RotateCw } from "lucide-react"; +import { ArrowRight, MailCheck, RotateCw, Loader2 } from "lucide-react"; import { verificationCodeType } from "@/enums/verificationCodeType"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; const otpSchema = z.object({ code: z.string().regex(/^\d{6}$/, "OTP must be exactly 6 digits"), @@ -88,92 +96,105 @@ export default function VerificationOtpPage() { stats: { label: "Verification Security", value: "99.9%", footer: "Protected", progress: "w-[99%]" }, }} > -
-
- +
+
+
-

OTP Verification

-

Enter the 6-digit code sent to:

-
-

{maskedPhone}

+

OTP Verification

+

Enter the 6-digit code sent to:

+
+

{maskedPhone}

{error && ( -
+
{error}
)} {resentMessage && ( -
+
{resentMessage}
)} -
-
- - -
- {errors.code ? ( -

{errors.code.message}

- ) : ( -

Enter the OTP sent to your phone

- )} - {otpValue.length}/6 -
-
+ + + + Verification Code + +
+ {errors.code ? ( + + ) : ( +

Enter the OTP sent to your phone

+ )} + {otpValue.length}/6 +
+
+
- + - +

Didn't receive the code? - +

From edfd885b4caaefd9da2f7e6010a035e5c01d384d Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 13:49:49 +0300 Subject: [PATCH 18/21] feat(auth): Implement phone number login and introduce PhoneInput component --- .../portal/src/components/auth/PhoneInput.tsx | 43 +++++++++++++++++++ .../portal/src/pages/accounts/LoginPage.tsx | 42 ++++++++++++------ .../portal/src/pages/accounts/SignupPage.tsx | 29 ++++--------- 3 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx diff --git a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx new file mode 100644 index 000000000..e490a28ef --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx @@ -0,0 +1,43 @@ +import { Field, FieldLabel, FieldError, Input } from "@edr/ui-common"; + +interface PhoneInputProps { + disabled?: boolean; + countryCode?: React.ComponentProps; + phone?: React.ComponentProps; + countryCodeError?: { message?: string }; + phoneError?: { message?: string }; + label?: string; +} + +export default function PhoneInput({ + disabled, + countryCode: countryCodeProps, + phone: phoneProps, + countryCodeError, + phoneError, + label = "Phone Number", +}: PhoneInputProps) { + return ( + + {label} +
+ + +
+ +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index c3238f3d4..4cfc64619 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -11,6 +11,7 @@ import { FieldError, FieldGroup, } from "@edr/ui-common"; +import PhoneInput from "@/components/auth/PhoneInput"; type LoginMethod = "email" | "phone"; @@ -19,6 +20,8 @@ export default function LoginPage() { const { login } = useAuth(); const [method, setMethod] = useState("email"); const [identifier, setIdentifier] = useState(""); + const [countryCode, setCountryCode] = useState("+251"); + const [phoneNumber, setPhoneNumber] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -28,7 +31,10 @@ export default function LoginPage() { setError(null); setLoading(true); try { - const result = await login({ email: identifier, password }); + const loginId = method === "email" + ? identifier + : `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`; + const result = await login({ email: loginId, password }); if (result.success) { navigate("/"); } else { @@ -97,19 +103,31 @@ export default function LoginPage() {
- - - {method === "email" ? "Email Address" : "Phone Number"} - - setIdentifier(e.target.value)} - required + {method === "email" ? ( + + Email Address + setIdentifier(e.target.value)} + required + disabled={loading} + /> + + ) : ( + ) => setCountryCode(e.target.value), + }} + phone={{ + value: phoneNumber, + onChange: (e: React.ChangeEvent) => setPhoneNumber(e.target.value), + }} /> - + )}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 0b4523453..43f1abb21 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -8,6 +8,7 @@ import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; import AuthLayout from "@/components/auth/AuthLayout"; +import PhoneInput from "@/components/auth/PhoneInput"; import { Button, Input, @@ -150,27 +151,13 @@ export default function SignupPage() {
- - Phone Number -
- - -
- -
+
@@ -164,31 +93,24 @@ export default function MyPortalPage() { // ------------------------------------------------------------ const activeBookings = myBookings.filter( - (b) => - b.status === "Confirmed" || - b.status === "In Transit" + (b) => b.status === "Confirmed" || b.status === "In Transit", ); - const activeShipments = myShipments.filter( - (s) => s.status === "In Transit" - ); + const activeShipments = myShipments.filter((s) => s.status === "In Transit"); const outstandingInvoices = myInvoices.filter( - (i) => i.status === "Sent" || i.status === "Overdue" + (i) => i.status === "Sent" || i.status === "Overdue", ); const totalOutstanding = outstandingInvoices.reduce( - (sum, i) => - i.currency === "USD" ? sum + i.amount : sum, - 0 + (sum, i) => (i.currency === "USD" ? sum + i.amount : sum), + 0, ); const totalSpent = myInvoices.reduce( (sum, i) => - i.status === "Paid" && i.currency === "USD" - ? sum + i.amount - : sum, - 0 + i.status === "Paid" && i.currency === "USD" ? sum + i.amount : sum, + 0, ); // ------------------------------------------------------------ @@ -198,13 +120,11 @@ export default function MyPortalPage() { return (
- {/* HERO */}
-
{customer.companyName?.charAt(0)} @@ -244,7 +164,6 @@ export default function MyPortalPage() { {/* KPI */}
-
-

- {label} -

+

{label}

-

- {value} -

+

{value}

-

- {sub} -

+

{sub}

+ {content} ); } - return ( -
- {content} -
- ); -} \ No newline at end of file + return
{content}
; +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index d05807090..ac1a708da 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -81,11 +81,7 @@ export const api = { "verifyOTP", authService.verifyOTP, ), - logout: endpoint( - "auth", - "logout", - authService.logout, - ), + logout: endpoint("auth", "logout", authService.logout), }, customers: { @@ -115,7 +111,7 @@ export const api = { customersService.remove(id), ), - getByUserId: endpoint<{ id: string }, Customer>( + getByUserId: endpoint<{ id: string }, Customer | null>( "customers", "getByUserId", ({ id }) => customersService.getByUserId(id), From 3e77f27a96fb9a1170cebfb52899edb941265edb Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 28 May 2026 15:53:57 +0300 Subject: [PATCH 21/21] feat(onboarding): Introduce multi-step customer onboarding page, enhancing AuthLayout with dynamic class props and improving customer service for typed creation and graceful 404 handling. --- .../portal/src/components/auth/AuthLayout.tsx | 21 +- .../src/pages/accounts/OnboardingPage.tsx | 520 ++++++++++++++++++ .../portal/src/services/customers.service.ts | 31 +- 3 files changed, 555 insertions(+), 17 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx index 310cac6ca..9d023a3ec 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -1,8 +1,11 @@ import type { ReactNode } from "react"; import { ShieldCheck, Train } from "lucide-react"; +import { cn } from "@/lib/utils"; export interface AuthLayoutProps { children: ReactNode; + parentClassName?: string; + contentClassName?: string; left: { badge: string; title: string; @@ -17,10 +20,15 @@ export interface AuthLayoutProps { }; } -export default function AuthLayout({ children, left }: AuthLayoutProps) { +export default function AuthLayout({ + children, + parentClassName, + contentClassName, + left, +}: AuthLayoutProps) { return (
-
+
@@ -58,8 +66,13 @@ export default function AuthLayout({ children, left }: AuthLayoutProps) {
-
-
+
+
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx new file mode 100644 index 000000000..cea9d1774 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -0,0 +1,520 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + ArrowRight, + ArrowLeft, + Building2, + User, + FileText, + CheckCircle2, + Loader2, +} from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; +import type { CreateCustomerDto } from "@/types/customers"; +import AuthLayout from "@/components/auth/AuthLayout"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; + +type OnboardingStep = "company" | "personnel" | "poa"; + +const onboardingSchema = z.object({ + companyName: z.string().min(1, "Company name is required"), + companyEmail: z.string().email("Invalid email address"), + companyPhone: z.string().min(1, "Company phone is required"), + companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyLocation: z.string().min(1, "Location is required"), + companyAddress: z.string().min(1, "Address is required"), + tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), + vatNumber: z + .string() + .min(1, "VAT number is required") + .length(10, "VAT number must be exactly 10 digits"), + fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonPhone: z.string().min(1, "Contact person phone is required"), + contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerName: z.string().min(1, "GM name is required"), + generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerPhone: z.string().min(1, "GM phone is required"), + generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + poaName: z.string().optional(), + poaPhone: z.string().optional(), + poaPhoneCountryCode: z.string().optional(), + poaAddress: z.string().optional(), + poaEmail: z.string().optional(), + poaLocation: z.string().optional(), +}); + +type FormData = z.infer; + +const stepFields: Record = { + company: [ + "companyName", + "companyEmail", + "companyPhone", + "companyPhoneCountryCode", + "companyLocation", + "companyAddress", + "tinNumber", + "vatNumber", + "fanNumber", + ], + personnel: [ + "contactPersonName", + "contactPersonPhone", + "contactPersonPhoneCountryCode", + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", + "generalManagerPhoneCountryCode", + ], + poa: [], +}; + +export default function OnboardingPage() { + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [step, setStep] = useState("company"); + + const { + register, + handleSubmit, + trigger, + formState: { errors }, + } = useForm({ + resolver: zodResolver(onboardingSchema), + defaultValues: { + companyName: "", + companyEmail: "", + companyPhone: "", + companyPhoneCountryCode: "+251", + companyLocation: "", + companyAddress: "", + tinNumber: "", + vatNumber: "", + fanNumber: "", + contactPersonName: "", + contactPersonPhone: "", + contactPersonPhoneCountryCode: "+251", + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + generalManagerPhoneCountryCode: "+251", + poaName: "", + poaPhone: "", + poaPhoneCountryCode: "+251", + poaAddress: "", + poaEmail: "", + poaLocation: "", + }, + }); + + const createCustomerMutation = useMutation({ + mutationFn: (payload: CreateCustomerDto) => + api.customers.create.call(payload), + onSuccess: () => { + if (user) + queryClient.invalidateQueries({ + queryKey: api.customers.getByUserId.queryKey({ id: user.id }), + }); + }, + }); + + const nextStep = async () => { + if (step === "poa") { + handleSubmit(onSubmit)(); + return; + } + const fields = stepFields[step]; + const isValid = await trigger(fields); + if (!isValid) return; + setStep(step === "company" ? "personnel" : "poa"); + }; + + const prevStep = () => { + if (step === "personnel") setStep("company"); + else if (step === "poa") setStep("personnel"); + }; + + const onSubmit = async (data: FormData) => { + const nameParts = (user?.name?.en ?? "").split(" "); + const payload: CreateCustomerDto = { + userId: user!.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user!.email, + phone: user!.phoneNumber, + companyName: data.companyName, + companyEmail: data.companyEmail, + companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyLocation: data.companyLocation, + companyAddress: data.companyAddress, + contactPersonName: data.contactPersonName, + contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + tinNumber: data.tinNumber, + vatNumber: data.vatNumber, + fanNumber: data.fanNumber, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + poaName: data.poaName || undefined, + poaPhone: + data.poaPhone && data.poaPhoneCountryCode + ? `${data.poaPhoneCountryCode}${data.poaPhone}` + : undefined, + poaAddress: data.poaAddress || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + }; + createCustomerMutation.mutate(payload); + }; + + return ( + +
+
+
+ } + active={step === "company"} + completed={step !== "company"} + /> + } + active={step === "personnel"} + completed={step === "poa"} + /> + } + active={step === "poa"} + completed={false} + /> +
+

+ {step === "company" && "Step 1 of 3 — Company Information"} + {step === "personnel" && "Step 2 of 3 — Personnel Details"} + {step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"} +

+
+ +
+ + {step === "company" && ( + <> + + Company Name + + + + +
+ + Company Email + + + + + +
+ +
+ + Location + + + + + + Address + + + +
+ +
+ + TIN Number (10 digits) + + + + + + VAT Number + + + +
+ + + FAN Number (16 digits) + + + + + )} + + {step === "personnel" && ( + <> +

+ Personal details are pulled from your account. Contact and + management info is collected below. +

+ +
+

+ Contact Person +

+
+ + Name + + + + + +
+
+ +
+ +
+

+ General Manager +

+
+ + Name + + + + + + Email + + + + + +
+
+ + )} + + {step === "poa" && ( + <> +

+ Power of Attorney details are optional. Skip if not applicable. +

+ + + PoA Name + + + +
+ + PoA Email + + + + +
+ +
+ + PoA Location + + + + + PoA Address + + +
+ + )} +
+ +
+ + + +
+
+ + ); +} + +function StepIcon({ + icon, + active, + completed, +}: { + icon: React.ReactNode; + active: boolean; + completed: boolean; +}) { + return ( +
+ {completed ? : icon} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/services/customers.service.ts b/apps/edr-freight-web/portal/src/services/customers.service.ts index 3781859fa..3d809709d 100644 --- a/apps/edr-freight-web/portal/src/services/customers.service.ts +++ b/apps/edr-freight-web/portal/src/services/customers.service.ts @@ -7,6 +7,7 @@ import type { Customer, UpdateCustomerDto, } from "@/types/customers"; +import { isAxiosError } from "axios"; const BASE = URL_CONSTANTS.CUSTOMERS_API.BASE; @@ -23,22 +24,26 @@ export const customersService = { return unwrap(response.data); }, - getByUserId: async (userId: string): Promise => { - const response = await client.get>( - URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId), - ); + getByUserId: async (userId: string): Promise => { + try { + const response = await client.get>( + URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId), + ); + return unwrap(response.data); + } catch (e) { + if (isAxiosError(e) && e.response?.status === 404) { + return null; + } + throw e; + } + }, + + create: async (payload: CreateCustomerDto): Promise => { + const response = await client.post>(BASE, payload); return unwrap(response.data); }, - create: async (payload: any): Promise => { - const response = await client.post>(BASE, payload); - return unwrap(response.data); - }, - - update: async ( - id: string, - payload: UpdateCustomerDto, - ): Promise => { + update: async (id: string, payload: UpdateCustomerDto): Promise => { const response = await client.patch>( URL_CONSTANTS.CUSTOMERS_API.BY_ID(id), payload,