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.
+
+ )}
+
+
+
+
);
};
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 ? (
+
+
+
+
+ | Name |
+ Username |
+ Email |
+ Phone |
+ Status |
+ Positions |
+
+
+
+ {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"
+ >
+ | {getEmployeeDisplayName(employee)} |
+ {employee.user?.username ?? "-"} |
+ {employee.user?.email ?? "-"} |
+ {employee.user?.phoneNumber ?? "-"} |
+ {employee.status ?? "-"} |
+ {positions.join(", ") || "-"} |
+
+ );
+ })}
+
+
+
+ ) : (
+
+ No employees are available in your scope.
+
+ )}
+
+
+
+
+ );
+};
+
+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() {