diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 3bfc004ea..50ae8103b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -7,6 +7,7 @@ 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 PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; import UsersPage from "./pages/dashboard/user-management/UsersPage"; @@ -42,6 +43,10 @@ const baseSidebarItems: SidebarItem[] = [ label: "Users", href: "/dashboard/user-management/users", }, + { + label: "Position Type", + href: "/dashboard/user-management/position-types", + }, { label: "Employees", href: "/dashboard/user-management/employees", @@ -161,6 +166,7 @@ const App = () => { } /> } /> + } /> } /> } /> } /> @@ -188,4 +194,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx new file mode 100644 index 000000000..177fccbd0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx @@ -0,0 +1,590 @@ +import { useEffect, useMemo, useState } from "react"; +import { isAxiosError } from "axios"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@edr/ui-common"; + +import { api } from "@/auth/http"; +import { useAuth } from "@/auth/useAuth"; + +interface LocaleText { + en?: string; + am?: string; +} + +interface OrganizationRecord { + id: string; + key: string; + name?: LocaleText; +} + +interface UnitRecord { + id: string; + key: string; + name?: LocaleText; +} + +interface PositionTypeRecord { + id: string; + key: string; + name?: LocaleText; + isSystem?: boolean; + unitId?: string | null; + createdAt?: string; + updatedAt?: string | null; +} + +interface PermissionRecord { + id: string; + key: string; + name?: LocaleText; +} + +interface ListResponse { + count?: number; + items?: T[]; + data?: T[]; +} + +const PAGE_SIZE = 1000; + +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 formatDate = (value?: string | null) => { + if (!value) { + return "-"; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "-"; + } + + return new Intl.DateTimeFormat("en", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); +}; + +const PositionTypesPage = () => { + const { user } = useAuth(); + const [organizations, setOrganizations] = useState([]); + const [units, setUnits] = useState([]); + const [positionTypes, setPositionTypes] = useState([]); + const [selectedOrgId, setSelectedOrgId] = useState(""); + const [selectedUnitId, setSelectedUnitId] = useState(""); + const [loadingOrganizations, setLoadingOrganizations] = useState(true); + const [loadingUnits, setLoadingUnits] = useState(false); + const [loadingPositionTypes, setLoadingPositionTypes] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [selectedPositionType, setSelectedPositionType] = useState(null); + const [positionTypePermissions, setPositionTypePermissions] = useState([]); + const [permissionsLoading, setPermissionsLoading] = useState(false); + const [permissionsError, setPermissionsError] = useState(null); + + const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin")); + const allowedOrgIds = useMemo( + () => + new Set( + (user?.employee ?? []) + .map((employee) => employee.organizationId) + .filter((organizationId): organizationId is string => Boolean(organizationId)), + ), + [user?.employee], + ); + + const visibleOrganizations = useMemo(() => { + if (isSuperAdmin) { + return organizations; + } + + return organizations.filter((organization) => allowedOrgIds.has(organization.id)); + }, [allowedOrgIds, isSuperAdmin, organizations]); + + const selectedOrganization = + visibleOrganizations.find((organization) => organization.id === selectedOrgId) ?? null; + const selectedUnit = units.find((unit) => unit.id === selectedUnitId) ?? null; + + useEffect(() => { + let isMounted = true; + + const loadOrganizations = async () => { + setLoadingOrganizations(true); + setErrorMessage(null); + + try { + const response = await api.get>("/organizations"); + + if (!isMounted) { + return; + } + + setOrganizations(getItems(response.data)); + } catch (error) { + if (!isMounted) { + return; + } + + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load organizations." + : "Unable to load organizations.", + ); + } finally { + if (isMounted) { + setLoadingOrganizations(false); + } + } + }; + + void loadOrganizations(); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + if (!visibleOrganizations.length) { + setSelectedOrgId(""); + setSelectedUnitId(""); + setUnits([]); + setPositionTypes([]); + return; + } + + if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) { + return; + } + + setSelectedOrgId(visibleOrganizations[0]?.id ?? ""); + }, [selectedOrgId, visibleOrganizations]); + + useEffect(() => { + if (!selectedOrgId) { + setUnits([]); + setSelectedUnitId(""); + setPositionTypes([]); + return; + } + + let isMounted = true; + + const loadUnits = async () => { + setLoadingUnits(true); + setErrorMessage(null); + setSelectedUnitId(""); + setPositionTypes([]); + + try { + const response = await api.get>(`/units/list/${selectedOrgId}`); + const items = getItems(response.data); + + if (!isMounted) { + return; + } + + setUnits(items); + setSelectedUnitId(items[0]?.id ?? ""); + } catch (error) { + if (!isMounted) { + return; + } + + setUnits([]); + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load units." + : "Unable to load units.", + ); + } finally { + if (isMounted) { + setLoadingUnits(false); + } + } + }; + + void loadUnits(); + + return () => { + isMounted = false; + }; + }, [selectedOrgId]); + + useEffect(() => { + if (!selectedUnitId) { + setPositionTypes([]); + return; + } + + let isMounted = true; + + const loadPositionTypes = async () => { + setLoadingPositionTypes(true); + setErrorMessage(null); + + try { + const response = await api.get>( + `/position-types/list-with-commons/${selectedUnitId}`, + { + params: { + skip: 0, + take: PAGE_SIZE, + orderBy: "createdAt:Desc", + }, + }, + ); + + if (!isMounted) { + return; + } + + setPositionTypes(getItems(response.data)); + } catch (error) { + if (!isMounted) { + return; + } + + setPositionTypes([]); + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load position types." + : "Unable to load position types.", + ); + } finally { + if (isMounted) { + setLoadingPositionTypes(false); + } + } + }; + + void loadPositionTypes(); + + return () => { + isMounted = false; + }; + }, [selectedUnitId]); + + useEffect(() => { + if (!selectedPositionType) { + setPositionTypePermissions([]); + setPermissionsError(null); + setPermissionsLoading(false); + return; + } + + let isMounted = true; + + const loadPermissions = async () => { + setPermissionsLoading(true); + setPermissionsError(null); + + try { + const response = await api.get>( + `/position-type-permissions/given-first/${selectedPositionType.id}`, + ); + + if (!isMounted) { + return; + } + + setPositionTypePermissions(getItems(response.data)); + } catch (error) { + if (!isMounted) { + return; + } + + setPositionTypePermissions([]); + setPermissionsError( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load position type permissions." + : "Unable to load position type permissions.", + ); + } finally { + if (isMounted) { + setPermissionsLoading(false); + } + } + }; + + void loadPermissions(); + + return () => { + isMounted = false; + }; + }, [selectedPositionType]); + + return ( +
+
+
+

+ User Management +

+
+
+

Position Type

+

+ Browse position types for a selected organization unit, including shared system entries. +

+
+
+ {positionTypes.length} position types +
+
+
+ +
+
+

+ Organization +

+ +
+ +
+

+ Unit +

+ +
+ +

+ {selectedOrganization && selectedUnit + ? `Showing position types for ${getLocaleLabel(selectedUnit.name, selectedUnit.key)} in ${getLocaleLabel(selectedOrganization.name, selectedOrganization.key)}.` + : "Select an organization and unit to load position types."} +

+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : loadingOrganizations || loadingUnits || loadingPositionTypes ? ( +
+ Loading position types... +
+ ) : !visibleOrganizations.length ? ( +
+ No organization scope is available for this account. +
+ ) : !selectedOrgId ? ( +
+ Select an organization to continue. +
+ ) : !units.length ? ( +
+ No units are available for the selected organization. +
+ ) : !selectedUnitId ? ( +
+ Select a unit to load position types. +
+ ) : positionTypes.length ? ( +
+ + + + + + + + + + + + + {positionTypes.map((positionType) => ( + setSelectedPositionType(positionType)} + className="cursor-pointer border-t border-border bg-background transition hover:bg-accent/20" + > + + + + + + + + ))} + +
NameKeyScopeUnit IDCreated AtUpdated At
+ {getLocaleLabel(positionType.name, positionType.key)} + {positionType.key} + {positionType.isSystem ? "System" : "Unit"} + + {positionType.unitId ?? "-"} + + {formatDate(positionType.createdAt)} + + {formatDate(positionType.updatedAt)} +
+
+ ) : ( +
+ No position types were found for the selected unit. +
+ )} +
+ + !open && setSelectedPositionType(null)} + > + + + + {selectedPositionType + ? getLocaleLabel(selectedPositionType.name, selectedPositionType.key) + : "Position type details"} + + + {selectedPositionType + ? `Review the permission set assigned to ${getLocaleLabel(selectedPositionType.name, selectedPositionType.key)}.` + : undefined} + + + + {selectedPositionType ? ( +
+
+
+

+ Position type +

+

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

+
+
+

+ Key +

+

+ {selectedPositionType.key} +

+
+
+

+ Scope +

+

+ {selectedPositionType.isSystem ? "System" : "Unit"} +

+
+
+

+ Unit ID +

+

+ {selectedPositionType.unitId ?? "-"} +

+
+
+ +
+
+

Permissions

+
+ {positionTypePermissions.length} permissions +
+
+ + {permissionsLoading ? ( +
+ Loading position type permissions... +
+ ) : permissionsError ? ( +
+ {permissionsError} +
+ ) : positionTypePermissions.length ? ( +
+ {positionTypePermissions.map((permission) => ( +
+
+

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

+

+ {permission.key} +

+
+
+ ))} +
+ ) : ( +
+ No permissions are assigned to this position type. +
+ )} +
+
+ ) : null} +
+
+
+ ); +}; + +export default PositionTypesPage;