From 516d2c8b4504657bf7fd2c9486e56bcbdfcba0a7 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Mon, 25 May 2026 15:50:09 +0300 Subject: [PATCH] feat(freight:backoffice): added org structure managemnet --- apps/edr-freight-api/src/app.module.ts | 8 +- .../src/seed/edr-org.seeder.ts | 82 + apps/edr-freight-web/backoffice/src/App.tsx | 21 +- .../org-structure/OrgStructurePage.tsx | 1604 +++++++++++++++++ orgstructure.md | 142 ++ .../src/components/Layout/DashboardLayout.tsx | 28 +- .../src/components/Layout/Sidebar.tsx | 30 +- 7 files changed, 1878 insertions(+), 37 deletions(-) create mode 100644 apps/edr-freight-api/src/seed/edr-org.seeder.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/org-structure/OrgStructurePage.tsx create mode 100644 orgstructure.md diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 60ca553f7..141c2b181 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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(); } } diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts new file mode 100644 index 000000000..63b464090 --- /dev/null +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -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}'`, + ); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ce4d05489..113dc1eb8 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , - }, ], }, + { + label: "Org structure", + href: "/dashboard/org-structure", + icon: , + }, ]; const DashboardShell = () => { @@ -90,10 +91,10 @@ const App = () => { /> } /> } /> - } - /> + } /> + } /> + } /> + } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/org-structure/OrgStructurePage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/org-structure/OrgStructurePage.tsx new file mode 100644 index 000000000..fdfc522af --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/org-structure/OrgStructurePage.tsx @@ -0,0 +1,1604 @@ +import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@edr/ui-common"; +import { + Building2, + ChevronRight, + FolderTree, + Network, + Plus, + RefreshCw, + Save, + Search, + Trash2, + UserPlus, + Users, +} from "lucide-react"; + +import { api } from "@/auth/http"; + +interface LocaleText { + en?: string; + am?: string; +} + +interface OrganizationRecord { + id: string; + key: string; + name: LocaleText; +} + +interface UnitRecord { + id: string; + key: string; + name: LocaleText; + parentUnitId?: string | null; +} + +interface PositionRecord { + id: string; + key: string; + name: LocaleText; + parentPositionId?: string | null; + rank?: number; +} + +interface EmployeeUserRecord { + id: string; + name?: LocaleText; + email?: string; + username?: string; +} + +interface EmployeePositionSummary { + id: string; + position?: { + id: string; + name?: LocaleText; + }; +} + +interface EmployeeRecord { + id: string; + name?: LocaleText; + user?: EmployeeUserRecord; + status?: string; + employeePositions?: EmployeePositionSummary[]; +} + +interface PositionTreeNode extends PositionRecord { + children: PositionTreeNode[]; +} + +interface PositionFormState { + id?: string; + nameEn: string; + nameAm: string; + key: string; + parentPositionId: string; +} + +interface EmployeeInviteFormState { + nameEn: string; + nameAm: string; + email: string; + username: string; + phoneNumber: string; +} + +interface ListResponse { + count?: number; + items?: T[]; + data?: T[]; +} + +const ORG_KEY = "edr_freight"; +const emptyInviteForm: EmployeeInviteFormState = { + nameEn: "", + nameAm: "", + email: "", + username: "", + phoneNumber: "", +}; +const emptyPositionForm: PositionFormState = { + nameEn: "", + nameAm: "", + key: "", + parentPositionId: "", +}; + +const inputClassName = + "w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950"; +const buttonClassName = + "inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60"; + +const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => { + if (!value) { + return fallback; + } + + return 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 toInternalKey = (value: string) => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .replace(/_+/g, "_"); + +const buildTree = (positions: PositionRecord[]) => { + const nodes = new Map(); + + positions.forEach((position) => { + nodes.set(position.id, { ...position, children: [] }); + }); + + const roots: PositionTreeNode[] = []; + + nodes.forEach((node) => { + if (node.parentPositionId) { + const parent = nodes.get(node.parentPositionId); + + if (parent) { + parent.children.push(node); + return; + } + } + + roots.push(node); + }); + + const sortNodes = (items: PositionTreeNode[]) => { + items.sort((left, right) => { + const rankDiff = (left.rank ?? 0) - (right.rank ?? 0); + if (rankDiff !== 0) { + return rankDiff; + } + + return getLocaleLabel(left.name, left.key).localeCompare( + getLocaleLabel(right.name, right.key), + ); + }); + + items.forEach((item) => sortNodes(item.children)); + }; + + sortNodes(roots); + + return roots; +}; + +const Field = ({ label, children }: { label: string; children: ReactNode }) => ( + +); + +const ReadOnlyField = ({ label, value }: { label: string; value: string }) => ( +
+ {label} +
+ {value || "-"} +
+
+); + +const TreeNode = ({ + node, + depth = 0, + selectedPositionId, + onSelect, +}: { + node: PositionTreeNode; + depth?: number; + selectedPositionId: string | null; + onSelect: (node: PositionRecord) => void; +}) => { + const hasChildren = node.children.length > 0; + const isSelected = selectedPositionId === node.id; + + return ( +
  • + + + {hasChildren ? ( +
      + {node.children.map((child) => ( + + ))} +
    + ) : null} +
  • + ); +}; + +const OrgStructurePage = () => { + const navigate = useNavigate(); + const { unitId, section } = useParams(); + const activeSection = section === "employees" ? "employees" : "positions"; + + const [organization, setOrganization] = useState(null); + const [units, setUnits] = useState([]); + const [positions, setPositions] = useState([]); + const [orgEmployees, setOrgEmployees] = useState([]); + const [positionMembers, setPositionMembers] = useState([]); + const [selectedPositionId, setSelectedPositionId] = useState(null); + const [loading, setLoading] = useState(true); + const [membersLoading, setMembersLoading] = useState(false); + const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + const [unitSearch, setUnitSearch] = useState(""); + const [isCreateUnitOpen, setIsCreateUnitOpen] = useState(false); + const [isCreatePositionOpen, setIsCreatePositionOpen] = useState(false); + const [isInviteEmployeeOpen, setIsInviteEmployeeOpen] = useState(false); + const [isUnitEditing, setIsUnitEditing] = useState(false); + const [isPositionEditing, setIsPositionEditing] = useState(false); + const [unitNameEn, setUnitNameEn] = useState(""); + const [unitNameAm, setUnitNameAm] = useState(""); + const [unitFormKey, setUnitFormKey] = useState(""); + const [createPositionMode, setCreatePositionMode] = useState<"create-root" | "create-child">( + "create-root", + ); + const [positionForm, setPositionForm] = useState(emptyPositionForm); + const [inviteForm, setInviteForm] = useState(emptyInviteForm); + const [submitting, setSubmitting] = useState(false); + + const selectedUnit = useMemo( + () => units.find((item) => item.id === unitId) ?? null, + [unitId, units], + ); + const tree = useMemo(() => buildTree(positions), [positions]); + const selectedPosition = useMemo( + () => positions.find((item) => item.id === selectedPositionId) ?? null, + [positions, selectedPositionId], + ); + const filteredUnits = useMemo(() => { + const query = unitSearch.trim().toLowerCase(); + if (!query) { + return units; + } + + return units.filter((item) => { + const label = getLocaleLabel(item.name, item.key).toLowerCase(); + return label.includes(query) || item.key.toLowerCase().includes(query); + }); + }, [unitSearch, units]); + const parentOptions = useMemo( + () => positions.filter((item) => item.id !== selectedPositionId), + [positions, selectedPositionId], + ); + const selectedPositionParent = useMemo( + () => positions.find((item) => item.id === selectedPosition?.parentPositionId) ?? null, + [positions, selectedPosition], + ); + + const resetMessages = () => { + setActionError(null); + setActionSuccess(null); + }; + + const resetUnitForm = useCallback(() => { + setUnitNameEn(""); + setUnitNameAm(""); + setUnitFormKey(""); + }, []); + + const syncUnitFormFromSelectedUnit = useCallback((unit: UnitRecord | null) => { + setUnitNameEn(unit?.name.en ?? ""); + setUnitNameAm(unit?.name.am ?? ""); + setUnitFormKey(unit?.key ?? ""); + }, []); + + const syncPositionFormForCreate = useCallback( + (mode: "create-root" | "create-child") => { + setCreatePositionMode(mode); + setPositionForm({ + ...emptyPositionForm, + parentPositionId: mode === "create-child" && selectedPosition ? selectedPosition.id : "", + }); + }, + [selectedPosition], + ); + + const syncPositionFormFromSelectedPosition = useCallback((position: PositionRecord | null) => { + if (!position) { + setPositionForm(emptyPositionForm); + return; + } + + setPositionForm({ + id: position.id, + nameEn: position.name.en ?? "", + nameAm: position.name.am ?? "", + key: position.key, + parentPositionId: position.parentPositionId ?? "", + }); + }, []); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const organizationsResponse = await api.get>("/organizations"); + const organizations = getItems(organizationsResponse.data); + const currentOrganization = organizations.find((item) => item.key === ORG_KEY); + + if (!currentOrganization) { + throw new Error("EDR Freight organization has not been seeded yet."); + } + + const [unitsResponse, employeesResponse] = await Promise.all([ + api.get>(`/units/list/${currentOrganization.id}`), + api.get>(`/employees/${currentOrganization.id}/by-organization`), + ]); + + const currentUnits = getItems(unitsResponse.data); + let unitPositions: PositionRecord[] = []; + + if (unitId) { + const targetUnit = currentUnits.find((item) => item.id === unitId); + if (targetUnit) { + const positionsResponse = await api.get>( + `/positions/list/${targetUnit.id}`, + ); + unitPositions = getItems(positionsResponse.data); + } + } + + setOrganization(currentOrganization); + setUnits(currentUnits); + setPositions(unitPositions); + setOrgEmployees(getItems(employeesResponse.data)); + } catch (loadError) { + setError( + loadError instanceof Error + ? loadError.message + : "Failed to load organization structure.", + ); + setOrganization(null); + setUnits([]); + setPositions([]); + setOrgEmployees([]); + } finally { + setLoading(false); + } + }, [unitId]); + + const loadPositionMembers = useCallback(async (positionId: string | null) => { + if (!positionId) { + setPositionMembers([]); + return; + } + + setMembersLoading(true); + + try { + const response = await api.get>( + `/positions/current/${positionId}/employees`, + ); + setPositionMembers(getItems(response.data)); + } catch { + setPositionMembers([]); + } finally { + setMembersLoading(false); + } + }, []); + + useEffect(() => { + void loadData(); + }, [loadData]); + + useEffect(() => { + if (!selectedUnit) { + resetUnitForm(); + setSelectedPositionId(null); + setIsUnitEditing(false); + setIsPositionEditing(false); + setIsCreateUnitOpen(false); + setIsCreatePositionOpen(false); + setIsInviteEmployeeOpen(false); + return; + } + + syncUnitFormFromSelectedUnit(selectedUnit); + setIsUnitEditing(false); + setIsPositionEditing(false); + setIsCreatePositionOpen(false); + setIsInviteEmployeeOpen(false); + }, [resetUnitForm, selectedUnit, syncUnitFormFromSelectedUnit]); + + useEffect(() => { + if (!positions.length) { + setSelectedPositionId(null); + setPositionMembers([]); + setIsPositionEditing(false); + return; + } + + if (selectedPositionId && positions.some((item) => item.id === selectedPositionId)) { + return; + } + + setSelectedPositionId(positions[0].id); + }, [positions, selectedPositionId]); + + useEffect(() => { + if (!selectedPosition) { + setIsPositionEditing(false); + setIsInviteEmployeeOpen(false); + setPositionForm(emptyPositionForm); + setPositionMembers([]); + return; + } + + setIsPositionEditing(false); + syncPositionFormFromSelectedPosition(selectedPosition); + void loadPositionMembers(selectedPosition.id); + }, [loadPositionMembers, selectedPosition, syncPositionFormFromSelectedPosition]); + + const goToUnit = (nextUnitId: string, nextSection = activeSection) => { + navigate(`/dashboard/org-structure/units/${nextUnitId}/${nextSection}`); + }; + + const handleRefresh = async () => { + resetMessages(); + await loadData(); + }; + + const handleCreateUnit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!organization) { + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.post("/units", { + name: { am: unitNameAm.trim(), en: unitNameEn.trim() }, + key: unitFormKey.trim(), + organizationId: organization.id, + }); + + resetUnitForm(); + setActionSuccess("Unit created."); + setIsCreateUnitOpen(false); + await loadData(); + } catch (saveError) { + setActionError(saveError instanceof Error ? saveError.message : "Failed to create unit."); + } finally { + setSubmitting(false); + } + }; + + const handleUpdateUnit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!organization || !selectedUnit) { + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.put(`/units/${selectedUnit.id}`, { + name: { am: unitNameAm.trim(), en: unitNameEn.trim() }, + key: unitFormKey.trim(), + organizationId: organization.id, + }); + + setActionSuccess("Unit updated."); + setIsUnitEditing(false); + await loadData(); + } catch (saveError) { + setActionError(saveError instanceof Error ? saveError.message : "Failed to update unit."); + } finally { + setSubmitting(false); + } + }; + + const handleDeleteUnit = async () => { + if (!selectedUnit || !window.confirm(`Delete unit '${getLocaleLabel(selectedUnit.name, selectedUnit.key)}'?`)) { + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.delete(`/units/${selectedUnit.id}`); + setActionSuccess("Unit deleted."); + setSelectedPositionId(null); + navigate("/dashboard/org-structure"); + await loadData(); + } catch (deleteError) { + setActionError(deleteError instanceof Error ? deleteError.message : "Failed to delete unit."); + } finally { + setSubmitting(false); + } + }; + + const handleSavePosition = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!organization || !selectedUnit) { + setActionError("Select a unit before managing positions."); + return; + } + + setSubmitting(true); + resetMessages(); + + try { + const payload = { + id: positionForm.id, + name: { am: positionForm.nameAm.trim(), en: positionForm.nameEn.trim() }, + key: positionForm.key.trim(), + unitId: selectedUnit.id, + organizationId: organization.id, + parentPositionId: positionForm.parentPositionId || null, + rank: 0, + }; + + if (isPositionEditing && positionForm.id) { + const existing = positions.find((item) => item.id === positionForm.id); + await api.put(`/positions/${positionForm.id}`, payload); + + if ((existing?.parentPositionId ?? "") !== (positionForm.parentPositionId || "")) { + await api.patch("/positions/change-parent", { + positionId: positionForm.id, + newParentId: positionForm.parentPositionId || null, + }); + } + + setActionSuccess("Position updated."); + setIsPositionEditing(false); + } else { + await api.post("/positions", payload); + setActionSuccess("Position created."); + syncPositionFormForCreate(createPositionMode); + setIsCreatePositionOpen(false); + } + + await loadData(); + } catch (saveError) { + setActionError( + saveError instanceof Error ? saveError.message : "Failed to save position.", + ); + } finally { + setSubmitting(false); + } + }; + + const handleDeletePosition = async () => { + if (!selectedPosition) { + return; + } + + if (!window.confirm(`Delete position '${getLocaleLabel(selectedPosition.name, selectedPosition.key)}'?`)) { + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.delete(`/positions/${selectedPosition.id}`); + setActionSuccess("Position deleted."); + await loadData(); + } catch (deleteError) { + setActionError( + deleteError instanceof Error ? deleteError.message : "Failed to delete position.", + ); + } finally { + setSubmitting(false); + } + }; + + const handleInviteEmployee = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!selectedPosition) { + setActionError("Select a position before inviting an employee."); + return; + } + + setSubmitting(true); + resetMessages(); + + try { + await api.post("/employee-positions/invite", { + positionId: selectedPosition.id, + username: inviteForm.username.trim(), + phoneNumber: inviteForm.phoneNumber.trim(), + email: inviteForm.email.trim(), + name: { am: inviteForm.nameAm.trim(), en: inviteForm.nameEn.trim() }, + }); + + setInviteForm(emptyInviteForm); + setActionSuccess("Employee invited and assigned to the selected position."); + setIsInviteEmployeeOpen(false); + await Promise.all([loadData(), loadPositionMembers(selectedPosition.id)]); + } catch (inviteError) { + setActionError( + inviteError instanceof Error ? inviteError.message : "Failed to invite employee.", + ); + } finally { + setSubmitting(false); + } + }; + + const positionMemberNames = positionMembers.map((item) => ({ + id: item.id, + name: getLocaleLabel(item.name ?? item.user?.name, item.user?.email ?? item.id), + email: item.user?.email, + })); + + return ( +
    +
    +
    +
    +
    + +
    +
    +

    + Org structure +

    +

    Organization management

    +

    + Manage units, department positions, and employee onboarding for EDR Freight from a + single workspace. +

    +
    +
    + + +
    +
    + + {actionSuccess ? ( +
    + {actionSuccess} +
    + ) : null} + + {actionError ? ( +
    + {actionError} +
    + ) : null} + + {loading ? ( +
    + Loading organization structure... +
    + ) : null} + + {!loading && error ? ( +
    + {error} +
    + ) : null} + + {!loading && !error ? ( +
    + + +
    + {selectedUnit ? ( + <> +
    +
    +
    +

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

    +

    {selectedUnit.key}

    +
    +
    + + +
    +
    + + {isUnitEditing ? ( +
    + + setUnitNameEn(event.target.value)} + required + /> + + + setUnitNameAm(event.target.value)} + required + /> + + +
    + setUnitFormKey(event.target.value)} + required + /> + +
    +
    +
    + + +
    +
    + ) : ( +
    + + + +
    + )} +
    + +
    +
    +
    + + +
    +
    + + {activeSection === "positions" ? ( +
    +
    +
    + + +
    + + {tree.length ? ( +
      + {tree.map((node) => ( + { + resetMessages(); + setSelectedPositionId(position.id); + }} + /> + ))} +
    + ) : ( +
    + No positions yet. Create the first root position for this unit. +
    + )} +
    + +
    +
    +
    +
    +
    Position details
    +
    + {selectedPosition + ? "View a selected position and edit it only when needed." + : "Select a position from the tree to inspect its details."} +
    +
    + {selectedPosition ? ( +
    + + +
    + ) : null} +
    + + {selectedPosition ? ( + isPositionEditing ? ( +
    + + + setPositionForm((current) => ({ ...current, nameEn: event.target.value })) + } + placeholder="Dispatch" + required + /> + + + + setPositionForm((current) => ({ ...current, nameAm: event.target.value })) + } + placeholder="ዲስፓች" + required + /> + + +
    + + setPositionForm((current) => ({ ...current, key: event.target.value })) + } + placeholder="dispatch" + required + /> + +
    +
    + + + +
    + + +
    +
    + ) : ( +
    + + + + +
    + ) + ) : ( +
    + Select a position from the tree to view its details. +
    + )} +
    +
    +
    + ) : ( +
    +
    +
    +
    +
    + +

    + Invite employee +

    +
    + +
    + {selectedPosition ? ( +
    +

    + New users are created during onboarding and assigned directly to the selected position. +

    + +
    + ) : ( +

    + Select a position from the tree first, then onboard employees into it. +

    + )} +
    + +
    +

    + Organization employees +

    + {orgEmployees.length ? ( +
      + {orgEmployees.map((employee) => ( +
    • +
      + {getLocaleLabel(employee.name ?? employee.user?.name, employee.id)} +
      +
      + {employee.user?.email ?? employee.user?.username ?? "No login details"} +
      + {employee.employeePositions?.length ? ( +
      + {employee.employeePositions + .map((item) => getLocaleLabel(item.position?.name, item.id)) + .join(", ")} +
      + ) : null} +
    • + ))} +
    + ) : ( +
    + No employees have been onboarded yet. +
    + )} +
    +
    + + +
    + )} +
    + + ) : ( +
    +
    + +
    +

    + Choose a unit to manage +

    +

    + Use the unit list on the left to manage positions and employees strictly inside one selected unit. +

    +
    + )} +
    +
    + ) : null} + + { + setIsCreateUnitOpen(open); + if (!open) { + resetUnitForm(); + } + }} + > + + + Create unit + + Add a new unit under the EDR Freight organization. + + +
    + + setUnitNameEn(event.target.value)} + placeholder="Operations" + required + /> + + + setUnitNameAm(event.target.value)} + placeholder="ኦፕሬሽንስ" + required + /> + + +
    + setUnitFormKey(event.target.value)} + placeholder="operations" + required + /> + +
    +
    +
    + + +
    +
    +
    +
    + + { + setIsCreatePositionOpen(open); + if (!open) { + syncPositionFormForCreate(createPositionMode); + } + }} + > + + + + {createPositionMode === "create-child" ? "Create child position" : "Create root position"} + + + {createPositionMode === "create-child" + ? "Add a child position under the currently selected position." + : "Add a top-level position for the selected unit."} + + +
    + + + setPositionForm((current) => ({ ...current, nameEn: event.target.value })) + } + placeholder="Dispatch" + required + /> + + + + setPositionForm((current) => ({ ...current, nameAm: event.target.value })) + } + placeholder="ዲስፓች" + required + /> + + +
    + + setPositionForm((current) => ({ ...current, key: event.target.value })) + } + placeholder="dispatch" + required + /> + +
    +
    + + + +
    + + +
    +
    +
    +
    + + { + setIsInviteEmployeeOpen(open); + if (!open) { + setInviteForm(emptyInviteForm); + } + }} + > + + + Invite employee + + Create a new employee account and assign it directly to the selected position. + + +
    + +
    + {selectedPosition ? getLocaleLabel(selectedPosition.name, selectedPosition.key) : "No position selected"} +
    +
    + + + setInviteForm((current) => ({ ...current, nameEn: event.target.value })) + } + required + /> + + + + setInviteForm((current) => ({ ...current, nameAm: event.target.value })) + } + required + /> + + + + setInviteForm((current) => ({ ...current, email: event.target.value })) + } + required + /> + + + + setInviteForm((current) => ({ ...current, username: event.target.value })) + } + required + /> + + + + setInviteForm((current) => ({ ...current, phoneNumber: event.target.value })) + } + required + /> + +
    + + +
    +
    +
    +
    +
    + ); +}; + +export default OrgStructurePage; diff --git a/orgstructure.md b/orgstructure.md new file mode 100644 index 000000000..745ec064a --- /dev/null +++ b/orgstructure.md @@ -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` diff --git a/packages/ui-common/src/components/Layout/DashboardLayout.tsx b/packages/ui-common/src/components/Layout/DashboardLayout.tsx index f86ddd933..bbe9e6bcc 100644 --- a/packages/ui-common/src/components/Layout/DashboardLayout.tsx +++ b/packages/ui-common/src/components/Layout/DashboardLayout.tsx @@ -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" ? ( @@ -132,8 +132,8 @@ const DashboardLayout = ({ headerExtra={themeToggleButton} />
    -
    -
    {title}
    +
    +
    {title}
    {isUserMenuOpen ? (
    -
    -

    +

    +

    {userName}

    {userEmail ? ( -

    +

    {userEmail}

    ) : 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" > Profile diff --git a/packages/ui-common/src/components/Layout/Sidebar.tsx b/packages/ui-common/src/components/Layout/Sidebar.tsx index f8dd1f4d1..fbd0ab7bc 100644 --- a/packages/ui-common/src/components/Layout/Sidebar.tsx +++ b/packages/ui-common/src/components/Layout/Sidebar.tsx @@ -83,34 +83,38 @@ 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 (