feat(freight:backoffice): added org structure managemnet

This commit is contained in:
Michael Abebe
2026-05-25 15:50:09 +03:00
parent 2272255bef
commit 516d2c8b45
7 changed files with 1878 additions and 37 deletions

View File

@@ -16,6 +16,7 @@ import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
@Module({
imports: [
@@ -40,11 +41,16 @@ import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-set
FileUploadSettingsModule,
DropdownSettingsModule,
],
providers: [EdrOrgSeeder],
})
export class AppModule implements OnApplicationBootstrap {
constructor(private readonly seeder: DataSeeder) {}
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
) {}
async onApplicationBootstrap() {
await this.seeder.run();
await this.edrOrgSeeder.run();
}
}

View File

@@ -0,0 +1,82 @@
import { Injectable, Logger } from "@nestjs/common";
import {
Organization,
OrganizationConfiguration,
Role,
} from "@tria-plc/iamapi-common";
import { DataSource } from "typeorm";
const EDR_ORG_KEY = "edr_freight";
const EDR_ORG_NAME = { en: "EDR Freight" };
const SEED_FLAG = "SEED_EDR_ORG";
const EDR_ROLES = [
{
key: "edr_employee",
name: { en: "EDR Employee" },
},
{
key: "edr_customer",
name: { en: "EDR Customer" },
},
];
@Injectable()
export class EdrOrgSeeder {
private readonly logger = new Logger(EdrOrgSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true";
if (!shouldSeed) {
this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`);
return;
}
const roleRepository = this.dataSource.getRepository(Role);
const organizationRepository = this.dataSource.getRepository(Organization);
const organizationConfigurationRepository =
this.dataSource.getRepository(OrganizationConfiguration);
await roleRepository.upsert(EDR_ROLES, {
conflictPaths: { key: true },
});
this.logger.log("Ensured EDR roles 'edr_employee' and 'edr_customer'");
let organization = await organizationRepository.findOne({
where: { key: EDR_ORG_KEY },
select: { id: true, key: true },
});
if (!organization) {
const insertResult = await organizationRepository.insert({
key: EDR_ORG_KEY,
name: EDR_ORG_NAME,
isGovernmentOrganization: true,
});
organization = {
id: insertResult.identifiers[0]?.id as string,
key: EDR_ORG_KEY,
} as Organization;
this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`);
} else {
this.logger.log(`Ensured EDR organization '${EDR_ORG_KEY}'`);
}
await organizationConfigurationRepository.upsert({
organizationId: organization.id,
canCreateBranchByItself: true,
canStartReceivingRecord: true,
}, {
conflictPaths: { organizationId: true },
});
this.logger.log(
`Ensured organization configuration for '${EDR_ORG_KEY}'`,
);
}
}

View File

@@ -1,6 +1,6 @@
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import { LayoutDashboard, ShieldCheck, Users, Building2 } from "lucide-react";
import { LayoutDashboard, ShieldCheck, Users, Network } from "lucide-react";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
@@ -8,6 +8,7 @@ import OverviewPage from "./pages/dashboard/OverviewPage";
import UsersPage from "./pages/dashboard/user-management/UsersPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
import DepartmentsPage from "./pages/dashboard/user-management/DepartmentsPage";
import OrgStructurePage from "./pages/dashboard/org-structure/OrgStructurePage";
import LoadingScreen from "./components/LoadingScreen";
const sidebarItems: SidebarItem[] = [
@@ -30,13 +31,13 @@ const sidebarItems: SidebarItem[] = [
label: "Roles",
href: "/dashboard/user-management/roles",
},
{
label: "Departments",
href: "/dashboard/user-management/departments",
icon: <Building2 />,
},
],
},
{
label: "Org structure",
href: "/dashboard/org-structure",
icon: <Network />,
},
];
const DashboardShell = () => {
@@ -90,10 +91,10 @@ const App = () => {
/>
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route
path="user-management/departments"
element={<DepartmentsPage />}
/>
<Route path="user-management/departments" element={<DepartmentsPage />} />
<Route path="org-structure" element={<OrgStructurePage />} />
<Route path="org-structure/units/:unitId" element={<OrgStructurePage />} />
<Route path="org-structure/units/:unitId/:section" element={<OrgStructurePage />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>

142
orgstructure.md Normal file
View File

@@ -0,0 +1,142 @@
# IAM Org Structure
## Core Model
### Organization
- Top-level tenant or company.
- Entity: `Organization`
- Has: `units`, `positions`, `employees`
- Supports hierarchy through `parent` and `branches`
### Unit
- Organizational subdivision under an organization.
- Entity: `Unit`
- Belongs to `organizationId`
- Supports hierarchy through `parentUnit` and `subUnits`
- Has: `positions`, `positionTypes`, `employees`, `employeePositions`
### Department
- In the IAM UI, a department is effectively a `Position`.
- There is no separate backend `Department` entity in this package.
- In the org tree UI:
- Organization -> Unit -> Department ~= Position
- Sub-department ~= subPosition
### Position
- The actual backend model behind the UI's department concept.
- Entity: `Position`
- Belongs to: `unitId`, `organizationId`
- Supports hierarchy through `parentPositionId` and `subPositions`
- Has assigned people through `employeePositions`
- Can have direct permissions through `positionPermission`
- Also linked to a `positionType`
### Position Type
- Template or category for positions.
- Entity: `PositionType`
- Example seeded concepts include things like employee, team leader, director, deputy.
- Can carry permissions through `position_type_permissions`
### Employee
- Org-scoped representation of a person inside an organization and unit.
- Entity: `Employee`
- Links a `User` into `organizationId` and `unitId`
- Has `employeePositions[]` for actual assignments
- Uses `isCurrent` and `status` to indicate active records
### EmployeePosition
- Assignment join between `Employee` and `Position`.
- Entity: `EmployeePosition`
- Holds the active working context:
- `isCurrent`
- `status`
- `isDelegate`
- `delegatorId`
- `startDate` and `endDate`
- This is the position context the auth layer ultimately uses
### User
- Global identity record.
- Entity: `User`
- Has login/account fields like `username`, `email`, `phoneNumber`
- Has:
- `userRoles[]`
- `employee[]`
- A single user can have multiple employee records and multiple org assignments
### Role
- RBAC grouping of permissions.
- Entity: `Role`
- Assigned to users via `UserRole`
- Has permissions via `RolePermission`
### Permission
- Atomic authorization capability.
- Entity: `Permission`
- Main fields include `key`, `name`, and optional `applicationKey`
- Can be granted through:
1. `role_permissions`
2. `position_permissions`
3. `position_type_permissions`
## Relationship Summary
1. `Organization` contains many `Unit` records.
2. `Unit` contains many `Position` records.
3. The UI calls those positions departments.
4. `User` is the identity.
5. `Employee` links that user to an organization and unit.
6. `EmployeePosition` links the employee to one or more positions.
7. `Role` is assigned directly to the user via `UserRole`.
8. `Permission` can come from the user's roles, the position itself, or the position type.
## Runtime Permission Model
At login, IAM builds a session `userInfo` payload that includes:
- `roles`: from `userRoles`
- `permissions`: flattened from role permissions
- `employee.positions[].permissions`: combined from:
- direct `positionPermission`
- inherited `positionTypePermissions`
This means authorization has two practical layers:
1. User-level permissions from roles
2. Position-context permissions from the active position and its type
## Active Context During Requests
The auth guard uses request headers to decide which employee position is the current working context.
Important headers include:
- `x-current-position-id`
- `x-delegator-position-id`
- `x-current-project-id`
- `x-organization-unit-id`
That selected context becomes the active `request.user.employee.position` and is also used for auditing.
## Practical Mental Model
Use this simplified model when reasoning about IAM:
1. A `User` is the account.
2. An `Employee` is that user inside an organization.
3. A `Position` is the department-like slot in the org tree.
4. An `EmployeePosition` says which employee occupies which position.
5. A `Role` gives broad user-level permissions.
6. A `Position` and `PositionType` give contextual working permissions.
## UI Mapping
In `@tria-plc/iamui-common` user management:
- Organizations -> `Organization`
- Units -> `Unit`
- Departments -> `Position`
- Sub-departments -> child `Position`
- Team members/employees -> `Employee` plus `EmployeePosition`
- Roles -> `Role`
- Permissions -> `Permission`

View File

@@ -37,7 +37,7 @@ function getInitialTheme(): Theme {
}
const iconButtonClass =
"inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] dark:border-slate-700 dark:text-slate-300 dark:hover:border-[#10B981]/40 dark:hover:bg-[#10B981]/20 dark:hover:text-white";
"inline-flex h-10 w-10 items-center justify-center rounded-xl border border-border bg-card text-foreground transition hover:border-[#10B981]/30 hover:bg-accent hover:text-accent-foreground";
const DashboardLayout = ({
title,
@@ -112,7 +112,7 @@ const DashboardLayout = ({
aria-label={
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981] dark:text-slate-300 dark:hover:bg-[#10B981]/20 dark:hover:text-white"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-foreground transition hover:bg-accent hover:text-accent-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
@@ -132,7 +132,7 @@ const DashboardLayout = ({
headerExtra={themeToggleButton}
/>
<div className="flex flex-1 flex-col">
<header className="flex h-16 items-center justify-between border-b px-6 ">
<header className="flex h-16 items-center justify-between border-b border-border bg-background px-6 text-foreground">
<div className="text-base font-medium">{title}</div>
<div className="flex items-center gap-2">
@@ -159,16 +159,16 @@ const DashboardLayout = ({
aria-haspopup="menu"
aria-expanded={isUserMenuOpen}
onClick={() => setIsUserMenuOpen((open) => !open)}
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5 aria-expanded:border-[#10B981]/30 aria-expanded:bg-[#10B981]/10 dark:hover:border-[#10B981]/30 dark:hover:bg-[#10B981]/10 dark:aria-expanded:border-[#10B981]/40 dark:aria-expanded:bg-[#10B981]/20"
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#10B981]/20 hover:bg-accent aria-expanded:border-[#10B981]/30 aria-expanded:bg-accent"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#10B981] text-xs font-semibold text-white">
{initials}
</div>
<span className="hidden text-sm font-medium text-slate-700 md:block dark:text-slate-200">
<span className="hidden text-sm font-medium text-foreground md:block">
{userName}
</span>
<ChevronDown
className={`h-4 w-4 text-slate-400 transition dark:text-slate-500 ${isUserMenuOpen ? "rotate-180 text-[#33578D]" : ""
className={`h-4 w-4 text-muted-foreground transition ${isUserMenuOpen ? "rotate-180 text-[#10B981]" : ""
}`}
/>
</button>
@@ -176,14 +176,14 @@ const DashboardLayout = ({
{isUserMenuOpen ? (
<div
role="menu"
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800"
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-border bg-card py-1 shadow-lg"
>
<div className="border-b border-slate-100 px-4 py-3 dark:border-slate-700">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
<div className="border-b border-border px-4 py-3">
<p className="text-sm font-semibold text-card-foreground">
{userName}
</p>
{userEmail ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
<p className="text-xs text-muted-foreground">
{userEmail}
</p>
) : null}
@@ -192,7 +192,7 @@ const DashboardLayout = ({
href="#profile"
role="menuitem"
onClick={() => setIsUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 transition hover:bg-[#10B981]/10 hover:text-[#10B981] dark:text-slate-300 dark:hover:bg-[#10B981]/20 dark:hover:text-white"
className="flex items-center gap-2 px-4 py-2 text-sm text-card-foreground transition hover:bg-accent hover:text-accent-foreground"
>
<User className="h-4 w-4" />
Profile

View File

@@ -83,33 +83,37 @@ const Sidebar = ({
item.children?.some((child) =>
activePath.startsWith(child.href.toLowerCase()),
) ?? false;
const isActive =
activePath === itemHref ||
activePath.startsWith(`${itemHref}/`) ||
childActive;
const isCurrentItem = hasChildren
? activePath === itemHref
: activePath === itemHref || activePath.startsWith(`${itemHref}/`);
const isSectionActive = childActive && !isCurrentItem;
return (
<div key={item.href} className="flex flex-col gap-1">
<div
className={clsx(
"group flex items-center gap-2 rounded-md transition",
isActive
isCurrentItem
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
: isSectionActive
? "bg-sidebar-accent/70 text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
)}
>
<a
href={item.href}
onClick={(event) => navigateTo(event, item.href)}
aria-current={isActive ? "page" : undefined}
aria-current={isCurrentItem ? "page" : undefined}
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm font-medium"
>
{item.icon ? (
<span
className={clsx(
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
isActive
isCurrentItem
? "text-white"
: isSectionActive
? "text-[#10B981] dark:text-emerald-300"
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
)}
>
@@ -131,9 +135,11 @@ const Sidebar = ({
}
className={clsx(
"mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md transition",
isActive
isCurrentItem
? "text-white/90 hover:bg-white/10"
: "text-slate-500 hover:bg-sidebar-accent dark:text-slate-400",
: isSectionActive
? "text-[#10B981] hover:bg-sidebar-accent/80 dark:text-emerald-300"
: "text-slate-500 hover:bg-sidebar-accent/60 dark:text-slate-400",
)}
>
<ChevronDown
@@ -161,7 +167,7 @@ const Sidebar = ({
"rounded-md px-3 py-2 text-sm transition",
childActiveHref
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
)}
>
{child.label}