From d8d17af837ab0884472a5f25a6dfdfaee8c2ba6d Mon Sep 17 00:00:00 2001 From: yaschalew Date: Sat, 11 Jul 2026 10:25:34 +0300 Subject: [PATCH 01/46] fix issue --- .../src/pages/dashboard/user-management/UserManagementPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx index 4c7b8104e..317d50842 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx @@ -639,7 +639,7 @@ const UserManagementPage = () => { // Org/unit admins are scoped to the orgs they administer via my-admin-organizations. const endpoint = isSuperAdmin ? "/organizations" - : "/organizations/my-admin-organizations"; + : "/organizations/with-admin-flag"; const response = await api.get>(endpoint); const nextOrganizations = getItems(response.data); setOrganizations(nextOrganizations); From 6695c5448ed0bd3b85af6d4f55b505c7ceaf10d0 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 11 Jul 2026 07:34:24 +0000 Subject: [PATCH 02/46] change --- packages/types/src/freight/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index c5a1a364a..9e72cb365 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -70,10 +70,15 @@ export enum CargoUnitOfMeasure { export enum BookingStatus { Draft = "DRAFT", Submitted = "SUBMITTED", + /** Backoffice re-priced an approved request — customer must reconfirm. */ + PriceChangedPendingConfirm = "PRICE_CHANGED_PENDING_CONFIRM", ChangesRequested = "CHANGES_REQUESTED", PendingApproval = "PENDING_APPROVAL", ApprovedPendingSignature = "APPROVED_PENDING_SIGNATURE", Approved = "APPROVED", + ReadyForAssignment = "READY_FOR_ASSIGNMENT", + WagonAssigned = "WAGON_ASSIGNED", + Invoiced = "INVOICED", ContractReady = "CONTRACT_READY", SignedCustomer = "SIGNED_CUSTOMER", FullyExecuted = "FULLY_EXECUTED", @@ -95,7 +100,18 @@ export enum BookingStatus { Cancelled = "CANCELLED", PendingConsolidation = "PENDING_CONSOLIDATION", Consolidated = "CONSOLIDATED", + // Post counter-sign document-clearance gate (GL workflow). + AwaitingDocuments = "AWAITING_DOCUMENTS", + DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW", + ClearanceReady = "CLEARANCE_READY", + /** Road (truck) drawdown: waits for truck dispatch after Marketing accepts. */ + RoadDispatchPending = "ROAD_DISPATCH_PENDING", TruckAssigned = "TRUCK_ASSIGNED", + OperationRequested = "OPERATION_REQUESTED", + /** Operations review gate: customer submitted a schedule day for capacity/doc/route review. */ + OperationRequestPending = "OPERATION_REQUEST_PENDING", + OperationChangesRequested = "OPERATION_CHANGES_REQUESTED", + OperationPricePendingConfirm = "OPERATION_PRICE_PENDING_CONFIRM", /** General contract: paid umbrella contract that is accepting drawdown orders. */ ContractActive = "CONTRACT_ACTIVE", /** General contract: closed because its quantity was exhausted (or period elapsed). */ From 917c5604ebece0c2b1bc53152302fb349e3408b3 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Sat, 11 Jul 2026 11:10:51 +0300 Subject: [PATCH 03/46] fix ui --- apps/edr-freight-web/backoffice/src/App.tsx | 16 +- .../pages/dashboard/demo/DemoUser1Page.tsx | 67 - .../pages/dashboard/demo/DemoUser2Page.tsx | 67 - .../org-structure/OrgStructurePage.tsx | 1604 ------------ .../user-management/DepartmentsPage.tsx | 12 - .../user-management/EmployeesPage.tsx | 321 --- .../user-management/PermissionsPage.tsx | 265 -- .../user-management/PositionTypesPage.tsx | 1309 ---------- .../dashboard/user-management/RolesPage.tsx | 293 --- .../UserManagementHostPage.tsx | 92 - .../user-management/UserManagementPage.tsx | 2311 ----------------- .../dashboard/user-management/UsersPage.tsx | 825 ------ .../dashboard/user-management/iamConfig.ts | 207 -- .../backoffice/src/user-management/route.tsx | 6 +- 14 files changed, 3 insertions(+), 7392 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/org-structure/OrgStructurePage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/DepartmentsPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 51ee212d7..2591d395c 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -63,11 +63,8 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; import InvoicesPage from "./pages/invoices/InvoicesPage"; -import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; -import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; -import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; @@ -78,11 +75,6 @@ import { isEthiopianGl, isSuperAdmin, } from "./lib/permissions"; -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"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; @@ -661,7 +653,7 @@ const App = () => { return ( } /> - } /> + {/* } /> */} } /> } /> @@ -671,7 +663,7 @@ const App = () => { return ( {UserManagementRoutes()} - } /> + {/* } /> */} } /> } /> } /> @@ -1342,10 +1334,6 @@ const App = () => { path="rule-engine/:resource" element={} /> - - } /> - } /> - } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx deleted file mode 100644 index e47cb95bd..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useState } from "react"; - -import { api } from "@/auth/http"; - -const DemoUser1Page = () => { - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - - const run = async () => { - setLoading(true); - setError(null); - - try { - const response = await api.get("/test_user1"); - if (cancelled) return; - setData(response.data); - } catch (e: any) { - if (cancelled) return; - const message = - e?.response?.data?.message || - e?.response?.data?.error || - e?.message || - "Request failed"; - setError(String(message)); - } finally { - if (!cancelled) setLoading(false); - } - }; - - void run(); - return () => { - cancelled = true; - }; - }, []); - - return ( -
-
-

User1 Demo

-

- Calls GET /api/test_user1 (requires{' '} - can:demo:user1). -

- -
- {loading ?

Loading...

: null} - {error ? ( -
- {error} -
- ) : null} - {!loading && !error ? ( -
-              {JSON.stringify(data, null, 2)}
-            
- ) : null} -
-
-
- ); -}; - -export default DemoUser1Page; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx deleted file mode 100644 index 5ef7ad172..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useState } from "react"; - -import { api } from "@/auth/http"; - -const DemoUser2Page = () => { - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - - const run = async () => { - setLoading(true); - setError(null); - - try { - const response = await api.get("/test_user2"); - if (cancelled) return; - setData(response.data); - } catch (e: any) { - if (cancelled) return; - const message = - e?.response?.data?.message || - e?.response?.data?.error || - e?.message || - "Request failed"; - setError(String(message)); - } finally { - if (!cancelled) setLoading(false); - } - }; - - void run(); - return () => { - cancelled = true; - }; - }, []); - - return ( -
-
-

User2 Demo

-

- Calls GET /api/test_user2 (requires{' '} - can:demo:user2). -

- -
- {loading ?

Loading...

: null} - {error ? ( -
- {error} -
- ) : null} - {!loading && !error ? ( -
-              {JSON.stringify(data, null, 2)}
-            
- ) : null} -
-
-
- ); -}; - -export default DemoUser2Page; 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 deleted file mode 100644 index fdfc522af..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/org-structure/OrgStructurePage.tsx +++ /dev/null @@ -1,1604 +0,0 @@ -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/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/DepartmentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/DepartmentsPage.tsx deleted file mode 100644 index 4d2e50920..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/DepartmentsPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import FeaturePlaceholder from "@/components/FeaturePlaceholder"; - -const DepartmentsPage = () => { - return ( - - ); -}; - -export default DepartmentsPage; 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 deleted file mode 100644 index eecca7a3e..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/EmployeesPage.tsx +++ /dev/null @@ -1,321 +0,0 @@ -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>( - `/backoffice/organizations/${organizationId}/employees`, - { - params: { - skip: 0, - take: 1000, - }, - }, - ), - ), - ); - - if (!isMounted) { - return; - } - - const uniqueEmployees = Array.from( - new Map( - responses - .flatMap((response) => getItems(response.data)) - .map((employee) => [employee.id, employee]), - ).values(), - ); - - setEmployees( - uniqueEmployees.sort((left, right) => - getEmployeeDisplayName(left).localeCompare(getEmployeeDisplayName(right)), - ), - ); - } catch (error) { - if (!isMounted) { - return; - } - - setErrorMessage( - isAxiosError(error) - ? error.response?.data?.message ?? "Unable to load employees." - : "Unable to load employees.", - ); - } finally { - if (isMounted) { - setLoading(false); - } - } - }; - - void loadEmployees(); - - return () => { - isMounted = false; - }; - }, [user]); - - return ( -
    -
    -
    -

    - User Management -

    -
    -
    -

    Employees

    -

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

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

    - Employee name -

    -

    - {getEmployeeDisplayName(selectedEmployee)} -

    -
    -
    -

    - Status -

    -

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

    -
    -
    -

    - Username -

    -

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

    -
    -
    -

    - Email -

    -

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

    -
    -
    -

    - Phone -

    -

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

    -
    -
    -

    - Employee ID -

    -

    {selectedEmployee.id}

    -
    -
    - -
    -
    -

    Assigned positions

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

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

    -

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

    -
    - ))} -
    - ) : ( -
    - No positions are assigned to this employee. -
    - )} -
    -
    - ) : null} -
    -
    -
    - ); -}; - -export default EmployeesPage; 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 deleted file mode 100644 index 10c5aba25..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PermissionsPage.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { useEffect, useState } from "react"; -import { isAxiosError } from "axios"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@edr/ui-common"; - -import { api } from "@/auth/http"; - -interface LocaleText { - en?: string; - am?: string; -} - -interface PermissionRecord { - id: string; - key: string; - name?: LocaleText; - applicationId?: string | null; -} - -interface ApplicationRecord { - id: string; - key: string; - name?: LocaleText; -} - -interface ListResponse { - count?: number; - items?: T[]; - data?: T[]; -} - -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; - -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 [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); - const [errorMessage, setErrorMessage] = useState(null); - - useEffect(() => { - let isMounted = true; - - const loadPageData = async () => { - setLoading(true); - setErrorMessage(null); - - try { - 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(permissionsResponse.data)); - const applicationItems = [...getItems(applicationsResponse.data)].sort((left, right) => - getLocaleLabel(left.name, left.key).localeCompare( - getLocaleLabel(right.name, right.key), - ), - ); - - setPermissions(items); - setApplications(applicationItems); - setCount(permissionsResponse.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 loadPageData(); - - return () => { - isMounted = false; - }; - }, []); - - 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); - 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. -

    -
    -
    - {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... -
    - ) : errorMessage ? ( -
    - {errorMessage} -
    - ) : filteredPermissions.length ? ( -
    - {filteredPermissions.map((permission) => ( -
    -
    -

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

    -

    - {permission.key} -

    -
    -
    - ))} -
    - ) : ( -
    - No permissions match the selected application. -
    - )} - - {hasMore ? ( -
    -

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

    - -
    - ) : null} -
    -
    - ); -}; - -export default PermissionsPage; 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 deleted file mode 100644 index 55a4df00e..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx +++ /dev/null @@ -1,1309 +0,0 @@ -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 { CopyPlus, Plus, RefreshCw } from "lucide-react"; - -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; - applicationId?: string | null; -} - -interface ListResponse { - count?: number; - items?: T[]; - data?: T[]; -} - -const PAGE_SIZE = 1000; - -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 emptyCreateForm = { - copyPermissionFromId: "", - key: "", - nameAm: "", - nameEn: "", -}; - -type PositionTypeEditFormState = { - key: string; - nameAm: string; - nameEn: string; -}; - -const emptyEditForm: PositionTypeEditFormState = { - key: "", - nameAm: "", - nameEn: "", -}; - -const toggleSelection = ( - currentIds: string[], - targetIds: string[], - checked: boolean, -) => { - if (checked) { - return [...new Set([...currentIds, ...targetIds])]; - } - - return currentIds.filter((id) => !targetIds.includes(id)); -}; - -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 [loadingPermissionsCatalog, setLoadingPermissionsCatalog] = - useState(false); - const [submitting, setSubmitting] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const [selectedPositionType, setSelectedPositionType] = - useState(null); - const [allPermissions, setAllPermissions] = useState([]); - const [permissionsLoading, setPermissionsLoading] = useState(false); - const [permissionsError, setPermissionsError] = useState(null); - const [permissionSearch, setPermissionSearch] = useState(""); - const [selectedPermissionIds, setSelectedPermissionIds] = useState( - [], - ); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [createForm, setCreateForm] = useState(emptyCreateForm); - const [createPermissionSearch, setCreatePermissionSearch] = useState(""); - const [createPermissionIds, setCreatePermissionIds] = useState([]); - const [createError, setCreateError] = useState(null); - const [editForm, setEditForm] = - useState(emptyEditForm); - - 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; - const availableCopySources = useMemo( - () => - positionTypes.filter( - (positionType) => positionType.id !== selectedPositionType?.id, - ), - [positionTypes, selectedPositionType?.id], - ); - const filteredPermissions = useMemo(() => { - const query = permissionSearch.trim().toLowerCase(); - - return allPermissions.filter((permission) => { - if (!query) { - return true; - } - - const label = getLocaleLabel( - permission.name, - permission.key, - ).toLowerCase(); - return ( - label.includes(query) || permission.key.toLowerCase().includes(query) - ); - }); - }, [allPermissions, permissionSearch]); - const filteredCreatePermissions = useMemo(() => { - const query = createPermissionSearch.trim().toLowerCase(); - - return allPermissions.filter((permission) => { - if (!query) { - return true; - } - - const label = getLocaleLabel( - permission.name, - permission.key, - ).toLowerCase(); - return ( - label.includes(query) || permission.key.toLowerCase().includes(query) - ); - }); - }, [allPermissions, createPermissionSearch]); - const allFilteredPermissionIds = filteredPermissions.map( - (permission) => permission.id, - ); - const allFilteredCreatePermissionIds = filteredCreatePermissions.map( - (permission) => permission.id, - ); - const areAllFilteredPermissionsSelected = - allFilteredPermissionIds.length > 0 && - allFilteredPermissionIds.every((id) => selectedPermissionIds.includes(id)); - const areAllFilteredCreatePermissionsSelected = - allFilteredCreatePermissionIds.length > 0 && - allFilteredCreatePermissionIds.every((id) => - createPermissionIds.includes(id), - ); - - const loadPositionTypes = async (unitId: string) => { - const response = await api.get>( - `/position-types/list-with-commons/${unitId}`, - { - params: { - skip: 0, - take: PAGE_SIZE, - orderBy: "createdAt:Desc", - }, - }, - ); - - return getItems(response.data); - }; - - const loadPermissionsForPositionType = async (positionTypeId: string) => { - const response = await api.get>( - `/position-type-permissions/given-first/${positionTypeId}`, - ); - - return getItems(response.data); - }; - - 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(() => { - let isMounted = true; - - const loadPermissionsCatalog = async () => { - setLoadingPermissionsCatalog(true); - - try { - const response = await api.get>( - "/permissions", - { - params: { - skip: 0, - take: 2000, - }, - }, - ); - - if (!isMounted) { - return; - } - - setAllPermissions(getItems(response.data)); - } catch { - if (!isMounted) { - return; - } - - setAllPermissions([]); - } finally { - if (isMounted) { - setLoadingPermissionsCatalog(false); - } - } - }; - - void loadPermissionsCatalog(); - - 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 loadItems = async () => { - setLoadingPositionTypes(true); - setErrorMessage(null); - - try { - const items = await loadPositionTypes(selectedUnitId); - - if (!isMounted) { - return; - } - - setPositionTypes(items); - } 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 loadItems(); - - return () => { - isMounted = false; - }; - }, [selectedUnitId]); - - useEffect(() => { - if (!selectedPositionType) { - setSelectedPermissionIds([]); - setEditForm(emptyEditForm); - setPermissionsError(null); - setPermissionsLoading(false); - setPermissionSearch(""); - return; - } - - setEditForm({ - key: selectedPositionType.key, - nameAm: selectedPositionType.name?.am ?? "", - nameEn: selectedPositionType.name?.en ?? "", - }); - - let isMounted = true; - - const loadPermissions = async () => { - setPermissionsLoading(true); - setPermissionsError(null); - - try { - const items = await loadPermissionsForPositionType( - selectedPositionType.id, - ); - - if (!isMounted) { - return; - } - - setSelectedPermissionIds(items.map((permission) => permission.id)); - } catch (error) { - if (!isMounted) { - return; - } - - 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]); - - const handleRefresh = async () => { - if (!selectedUnitId) { - return; - } - - setLoadingPositionTypes(true); - setErrorMessage(null); - - try { - const items = await loadPositionTypes(selectedUnitId); - setPositionTypes(items); - - if (selectedPositionType) { - const nextSelected = - items.find((item) => item.id === selectedPositionType.id) ?? - selectedPositionType; - setSelectedPositionType(nextSelected); - } - } catch (error) { - setErrorMessage( - isAxiosError(error) - ? (error.response?.data?.message ?? - "Unable to refresh position types.") - : "Unable to refresh position types.", - ); - } finally { - setLoadingPositionTypes(false); - } - }; - - const openCreateDialog = () => { - setCreateForm(emptyCreateForm); - setCreatePermissionIds([]); - setCreatePermissionSearch(""); - setCreateError(null); - setIsCreateOpen(true); - }; - - const handleSelectCopySource = async (positionTypeId: string) => { - setCreateForm((current) => ({ - ...current, - copyPermissionFromId: positionTypeId, - })); - - if (!positionTypeId) { - setCreatePermissionIds([]); - return; - } - - try { - const copiedPermissions = - await loadPermissionsForPositionType(positionTypeId); - setCreatePermissionIds( - copiedPermissions.map((permission) => permission.id), - ); - } catch (error) { - setCreateError( - isAxiosError(error) - ? (error.response?.data?.message ?? "Unable to copy permissions.") - : "Unable to copy permissions.", - ); - } - }; - - const handleCreatePositionType = async ( - event: React.FormEvent, - ) => { - event.preventDefault(); - - if (!selectedUnitId) { - setCreateError("Select a unit before creating a position type."); - return; - } - - setSubmitting(true); - setCreateError(null); - - try { - const response = await api.post("/position-types", { - key: createForm.key.trim(), - name: { - am: createForm.nameAm.trim(), - en: createForm.nameEn.trim(), - }, - unitId: selectedUnitId, - }); - - const createdPositionType = response.data; - - await api.post("/position-type-permissions/assign-seconds-for-first", { - firstId: createdPositionType.id, - secondIds: createPermissionIds, - }); - - setIsCreateOpen(false); - setCreateForm(emptyCreateForm); - setCreatePermissionIds([]); - await handleRefresh(); - } catch (error) { - setCreateError( - isAxiosError(error) - ? (error.response?.data?.message ?? "Unable to create position type.") - : "Unable to create position type.", - ); - } finally { - setSubmitting(false); - } - }; - - const handleSavePositionTypeChanges = async () => { - if (!selectedPositionType) { - return; - } - - setSubmitting(true); - setPermissionsError(null); - - try { - if (!selectedPositionType.isSystem) { - await api.put(`/position-types/${selectedPositionType.id}`, { - key: editForm.key.trim(), - name: { - am: editForm.nameAm.trim(), - en: editForm.nameEn.trim(), - }, - unitId: selectedPositionType.unitId, - }); - } - - await api.post("/position-type-permissions/assign-seconds-for-first", { - firstId: selectedPositionType.id, - secondIds: selectedPermissionIds, - }); - - const [refreshedPermissions, refreshedPositionTypes] = await Promise.all([ - loadPermissionsForPositionType(selectedPositionType.id), - selectedUnitId - ? loadPositionTypes(selectedUnitId) - : Promise.resolve(positionTypes), - ]); - - setSelectedPermissionIds( - refreshedPermissions.map((permission) => permission.id), - ); - setPositionTypes(refreshedPositionTypes); - - const refreshedSelected = refreshedPositionTypes.find( - (item) => item.id === selectedPositionType.id, - ); - if (refreshedSelected) { - setSelectedPositionType(refreshedSelected); - } - } catch (error) { - setPermissionsError( - isAxiosError(error) - ? (error.response?.data?.message ?? "Unable to update position type.") - : "Unable to update position type.", - ); - } finally { - setSubmitting(false); - } - }; - - return ( -
    -
    -
    -

    - User Management -

    -
    -
    -

    - Position Type -

    -

    - Browse position types for a selected organization unit, add new - ones, and manage their permissions. -

    -
    -
    -
    - {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 and update 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 ?? "-"} -

    -
    -
    - -
    -
    - - - - {selectedPositionType.isSystem ? ( -

    - System position types keep their name and key, but you can - still manage permissions here. -

    - ) : null} -
    - -
    -

    - Permissions -

    -
    - {selectedPermissionIds.length} permissions selected -
    -
    - - {permissionsLoading ? ( -
    - Loading position type permissions... -
    - ) : permissionsError ? ( -
    - {permissionsError} -
    - ) : ( -
    - - setPermissionSearch(event.target.value) - } - placeholder="Search permissions by name or key" - /> - - - - {loadingPermissionsCatalog ? ( -
    - Loading permissions catalog... -
    - ) : filteredPermissions.length ? ( -
    - {filteredPermissions.map((permission) => ( - - ))} -
    - ) : ( -
    - No permissions match the current search. -
    - )} - -
    - - -
    -
    - )} -
    -
    - ) : null} -
    -
    - - - - - Create position type - - Add a new position type for the selected unit and optionally copy - permissions from an existing one. - - - -
    void handleCreatePositionType(event)} - > -
    - - - - - - - -
    - -
    -
    -

    - Permissions -

    -
    - {createPermissionIds.length} selected -
    -
    - - - setCreatePermissionSearch(event.target.value) - } - placeholder="Search permissions by name or key" - /> - - - - {loadingPermissionsCatalog ? ( -
    - Loading permissions catalog... -
    - ) : filteredCreatePermissions.length ? ( -
    - {filteredCreatePermissions.map((permission) => ( - - ))} -
    - ) : ( -
    - No permissions match the current search. -
    - )} -
    - - {createForm.copyPermissionFromId ? ( -
    -
    - - The new position type will inherit permissions from the - selected source. -
    -
    - ) : null} - - {createError ? ( -
    - {createError} -
    - ) : null} - -
    - - -
    -
    -
    -
    -
    - ); -}; - -export default PositionTypesPage; 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 deleted file mode 100644 index 862df0119..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { useEffect, useState } from "react"; -import { isAxiosError } from "axios"; -import { Shield } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@edr/ui-common"; - -import { api } from "@/auth/http"; - -interface LocaleText { - en?: string; - am?: string; -} - -interface RoleRecord { - id: string; - key: string; - name?: LocaleText; -} - -interface PermissionRecord { - id: string; - key: string; - name?: LocaleText; -} - -interface ListResponse { - items?: T[]; - data?: T[]; -} - -const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => - value?.en ?? value?.am ?? fallback; - -const getItems = (payload: ListResponse | T[] | undefined | null) => { - if (!payload) { - return [] as T[]; - } - - if (Array.isArray(payload)) { - return payload; - } - - return payload.items ?? payload.data ?? []; -}; - -const RolesPage = () => { - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(true); - const [errorMessage, setErrorMessage] = useState(null); - const [selectedRole, setSelectedRole] = useState(null); - const [rolePermissions, setRolePermissions] = useState([]); - const [rolePermissionsLoading, setRolePermissionsLoading] = useState(false); - const [rolePermissionsError, setRolePermissionsError] = useState(null); - - useEffect(() => { - let isMounted = true; - - const loadRoles = async () => { - setLoading(true); - setErrorMessage(null); - - try { - const response = await api.get>("/roles"); - - if (!isMounted) { - return; - } - - setRoles( - getItems(response.data).sort((left, right) => - getLocaleLabel(left.name, left.key).localeCompare( - getLocaleLabel(right.name, right.key), - ), - ), - ); - } catch (error) { - if (!isMounted) { - return; - } - - setErrorMessage( - isAxiosError(error) - ? error.response?.data?.message ?? "Unable to load roles." - : "Unable to load roles.", - ); - } finally { - if (isMounted) { - setLoading(false); - } - } - }; - - void loadRoles(); - - return () => { - isMounted = false; - }; - }, []); - - useEffect(() => { - if (!selectedRole) { - setRolePermissions([]); - setRolePermissionsError(null); - setRolePermissionsLoading(false); - return; - } - - let isMounted = true; - - const loadRolePermissions = async () => { - setRolePermissionsLoading(true); - setRolePermissionsError(null); - - try { - const response = await api.get>( - `/role-permissions/given-first/${selectedRole.id}`, - ); - - if (!isMounted) { - return; - } - - setRolePermissions( - getItems(response.data).sort((left, right) => - getLocaleLabel(left.name, left.key).localeCompare( - getLocaleLabel(right.name, right.key), - ), - ), - ); - } catch (error) { - if (!isMounted) { - return; - } - - setRolePermissionsError( - isAxiosError(error) - ? error.response?.data?.message ?? "Unable to load role details." - : "Unable to load role details.", - ); - } finally { - if (isMounted) { - setRolePermissionsLoading(false); - } - } - }; - - void loadRolePermissions(); - - return () => { - isMounted = false; - }; - }, [selectedRole]); - - return ( -
    -
    -
    -

    - User Management -

    -
    -
    -

    Roles

    -

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

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

    - Role name -

    -

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

    -
    -
    -

    - Role key -

    -

    {selectedRole.key}

    -
    -
    - -
    -
    -

    Permissions

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

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

    -

    - {permission.key} -

    -
    - ))} -
    - ) : ( -
    - No permissions are assigned to this role. -
    - )} -
    -
    - ) : null} -
    -
    -
    - ); -}; - -export default RolesPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx deleted file mode 100644 index d473bdc90..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { - UserManagementApp, - type UserManagementRuntimeOptions, - type UserManagementSessionSeed, -} from '@tria-plc/iamui'; -import { iamConfig } from './iamConfig'; - -function readCookieValue(name: string): string | null { - const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1'); - const match = document.cookie.match( - new RegExp(`(?:^|; )${escaped}=([^;]*)`), - ); - - return match ? decodeURIComponent(match[1]) : null; -} - -function readInitialSession(): UserManagementSessionSeed | null { - const token = - localStorage.getItem('fhc-backoffice-auth-token') ?? - readCookieValue('auth-token'); - - if (!token) { - return null; - } - - const refreshToken = - localStorage.getItem('fhc-backoffice-auth-refresh-token') ?? - readCookieValue('refresh-token') ?? - undefined; - - return { - token, - refreshToken, - rememberMe: true, - }; -} - -export default function UserManagementHostPage() { - const mountRef = useRef(null); - const rootRef = useRef(null); - const unmountTimerRef = useRef(null); - - useEffect(() => { - const mountNode = mountRef.current; - - if (!mountNode) { - return; - } - - if (unmountTimerRef.current !== null) { - window.clearTimeout(unmountTimerRef.current); - unmountTimerRef.current = null; - } - - if (!rootRef.current) { - rootRef.current = createRoot(mountNode); - } - - const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, ''); - const runtime: UserManagementRuntimeOptions = { - basename: '/um', - apiBaseUrl, - apiUrl: `${apiBaseUrl}/api`, - recordApiUrl: `${apiBaseUrl}/api`, - chronicleUrl: `${apiBaseUrl}/api`, - auditApiUrl: `${apiBaseUrl}/api`, - }; - - rootRef.current.render( - , - ); - - return () => { - unmountTimerRef.current = window.setTimeout(() => { - rootRef.current?.unmount(); - rootRef.current = null; - unmountTimerRef.current = null; - }, 0); - }; - }, []); - - return
    ; -} diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx deleted file mode 100644 index 317d50842..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx +++ /dev/null @@ -1,2311 +0,0 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { isAxiosError } from "axios"; -import { - Badge, - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@edr/ui-common"; -import { - Building2, - ChevronRight, - FolderTree, - MoreHorizontal, - Network, - RefreshCw, - Search, - ShieldCheck, - UserCheck, - UserMinus, - Users, -} from "lucide-react"; - -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; - parentUnitId?: string | null; -} - -interface PositionRecord { - id: string; - key: string; - name: LocaleText; - parentPositionId?: string | null; - positionTypeId?: string | null; - rank?: number; -} - -interface PositionTypeRecord { - id: string; - key: string; - name: LocaleText; -} - -interface EmployeeUserRecord { - id: string; - name?: LocaleText; - email?: string; - phoneNumber?: 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 RoleRecord { - id: string; - key: string; - name: LocaleText; -} - -interface PositionTreeNode extends PositionRecord { - children: PositionTreeNode[]; -} - -interface OrganizationConfigurationRecord { - id: string; - organizationId: string; - maximumNumberOfUnits?: number | null; - canCreateBranchByItself?: boolean; - canStartReceivingRecord?: boolean; -} - -interface PositionFormState { - id?: string; - nameEn: string; - nameAm: string; - key: string; - parentPositionId: string; - positionTypeId: string; -} - -interface UnitFormState { - nameEn: string; - nameAm: string; - key: string; -} - -interface EmployeeInviteFormState { - nameEn: string; - nameAm: string; - email: string; - username: string; - phoneNumber: string; -} - -interface ListResponse { - count?: number; - items?: T[]; - data?: T[]; -} - -const RESERVED_ROLE_KEYS = new Set(["super_admin", "organization_admin", "unit_admin"]); - -const emptyInviteForm: EmployeeInviteFormState = { - nameEn: "", - nameAm: "", - email: "", - username: "", - phoneNumber: "", -}; - -const emptyUnitForm: UnitFormState = { - nameEn: "", - nameAm: "", - key: "", -}; - -const emptyPositionForm: PositionFormState = { - nameEn: "", - nameAm: "", - key: "", - parentPositionId: "", - positionTypeId: "", -}; - -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 mergeEmployeesByUser = (employees: EmployeeRecord[]) => { - const employeesByUserId = new Map(); - - for (const employee of employees) { - const userId = employee.user?.id; - - if (!userId) { - employeesByUserId.set(employee.id, employee); - continue; - } - - const existing = employeesByUserId.get(userId); - - if (!existing) { - employeesByUserId.set(userId, employee); - continue; - } - - const existingPositions = existing.employeePositions ?? []; - const nextPositions = employee.employeePositions ?? []; - const mergedPositions = Array.from( - new Map( - [...existingPositions, ...nextPositions].map((position) => [position.id, position]), - ).values(), - ); - - employeesByUserId.set(userId, { - ...existing, - ...employee, - id: existing.id, - name: existing.name ?? employee.name, - status: existing.status ?? employee.status, - user: existing.user ?? employee.user, - employeePositions: mergedPositions, - }); - } - - return [...employeesByUserId.values()]; -}; - -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 getErrorMessage = (error: unknown, fallback: string) => { - if (isAxiosError(error)) { - const message = error.response?.data?.message; - if (typeof message === "string") { - return message; - } - if (Array.isArray(message) && typeof message[0] === "string") { - return message[0]; - } - } - - return error instanceof Error ? error.message : fallback; -}; - -const Field = ({ label, children }: { label: string; children: ReactNode }) => ( - -); - -const ManagementDialog = ({ - open, - title, - description, - onOpenChange, - children, -}: { - open: boolean; - title: string; - description?: string; - onOpenChange: (open: boolean) => void; - children: ReactNode; -}) => ( - - - - {title} - {description ? {description} : null} - - {children} - - -); - -const TreeNode = ({ - node, - depth = 0, - selectedPositionId, - onSelect, - expandedDepartmentIds, - onToggleExpand, - onInviteUser, - onAssignUser, - onAddSubDepartment, - onEdit, - onDelete, - disabled, -}: { - node: PositionTreeNode; - depth?: number; - selectedPositionId: string | null; - onSelect: (node: PositionRecord) => void; - expandedDepartmentIds: Set; - onToggleExpand: (nodeId: string) => void; - onInviteUser: (node: PositionRecord) => void; - onAssignUser: (node: PositionRecord) => void; - onAddSubDepartment: (node: PositionRecord) => void; - onEdit: (node: PositionRecord) => void; - onDelete: (node: PositionRecord) => void; - disabled?: boolean; -}) => { - const hasChildren = node.children.length > 0; - const isSelected = selectedPositionId === node.id; - const isExpanded = expandedDepartmentIds.has(node.id); - - return ( -
  • -
    - {hasChildren ? ( - - ) : ( -
    - - {hasChildren && isExpanded ? ( -
      - {node.children.map((child) => ( - - ))} -
    - ) : null} -
  • - ); -}; - -const UserManagementPage = () => { - const { user } = useAuth(); - const [organizations, setOrganizations] = useState([]); - const [units, setUnits] = useState([]); - const [positions, setPositions] = useState([]); - const [positionTypes, setPositionTypes] = useState([]); - const [orgEmployees, setOrgEmployees] = useState([]); - const [positionMembers, setPositionMembers] = useState([]); - const [selectedOrgConfiguration, setSelectedOrgConfiguration] = - useState(null); - const [expandedDepartmentIds, setExpandedDepartmentIds] = useState>(new Set()); - const [orgAdminUserIds, setOrgAdminUserIds] = useState>(new Set()); - const [unitAdminUserIds, setUnitAdminUserIds] = useState>(new Set()); - const [selectedOrgId, setSelectedOrgId] = useState(null); - const [selectedUnitId, setSelectedUnitId] = useState(null); - const [selectedPositionId, setSelectedPositionId] = useState(null); - const [loading, setLoading] = useState(true); - const [membersLoading, setMembersLoading] = useState(false); - const [orgEmployeesLoading, setOrgEmployeesLoading] = useState(false); - const [positionTypesLoading, setPositionTypesLoading] = useState(false); - const [actionError, setActionError] = useState(null); - const [actionSuccess, setActionSuccess] = useState(null); - const [loadError, setLoadError] = useState(null); - const [positionTypesError, setPositionTypesError] = useState(null); - const [submitting, setSubmitting] = useState(false); - const [unitSearch, setUnitSearch] = useState(""); - const [positionSearch, setPositionSearch] = useState(""); - const [employeeSearch, setEmployeeSearch] = useState(""); - const [assignSearch, setAssignSearch] = useState(""); - const [unitForm, setUnitForm] = useState(emptyUnitForm); - const [positionForm, setPositionForm] = useState(emptyPositionForm); - const [inviteForm, setInviteForm] = useState(emptyInviteForm); - const [roleIds, setRoleIds] = useState([]); - const [availableRoles, setAvailableRoles] = useState([]); - const [rolesLoading, setRolesLoading] = useState(false); - const [isCreateUnitOpen, setIsCreateUnitOpen] = useState(false); - const [isEditUnitOpen, setIsEditUnitOpen] = useState(false); - const [isCreatePositionOpen, setIsCreatePositionOpen] = useState(false); - const [isEditPositionOpen, setIsEditPositionOpen] = useState(false); - const [isInviteEmployeeOpen, setIsInviteEmployeeOpen] = useState(false); - const [isAssignEmployeeOpen, setIsAssignEmployeeOpen] = useState(false); - const [isManageRolesOpen, setIsManageRolesOpen] = useState(false); - const [selectedRoleUser, setSelectedRoleUser] = useState(null); - const [createPositionMode, setCreatePositionMode] = useState<"create-root" | "create-child">( - "create-root", - ); - - const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin")); - const allowedOrgIds = useMemo( - () => new Set((user?.employee ?? []).map((employee) => employee.organizationId).filter(Boolean)), - [user?.employee], - ); - - // Both paths are already scoped server-side: super admins get every org from - // /organizations, org/unit admins get only theirs from my-admin-organizations. - // So no client-side re-filtering (allowedOrgIds, from employee.organizationId, - // wouldn't include orgs administered without an employee record there). - const visibleOrganizations = organizations; - void allowedOrgIds; - - const selectedOrganization = useMemo( - () => visibleOrganizations.find((item) => item.id === selectedOrgId) ?? null, - [selectedOrgId, visibleOrganizations], - ); - const selectedUnit = useMemo( - () => units.find((item) => item.id === selectedUnitId) ?? null, - [selectedUnitId, units], - ); - const selectedPosition = useMemo( - () => positions.find((item) => item.id === selectedPositionId) ?? null, - [positions, selectedPositionId], - ); - const positionTree = useMemo(() => buildTree(positions), [positions]); - const parentOptions = useMemo( - () => positions.filter((item) => item.id !== selectedPositionId), - [positions, selectedPositionId], - ); - const unitsAllowedLabel = - selectedOrgConfiguration?.maximumNumberOfUnits == null - ? "∞" - : `${selectedOrgConfiguration.maximumNumberOfUnits}`; - - 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 filteredPositionTree = useMemo(() => { - const query = positionSearch.trim().toLowerCase(); - if (!query) { - return positionTree; - } - - const filterNode = (node: PositionTreeNode): PositionTreeNode | null => { - const label = `${getLocaleLabel(node.name, node.key)} ${node.key}`.toLowerCase(); - const matchingChildren = node.children - .map((child) => filterNode(child)) - .filter(Boolean) as PositionTreeNode[]; - - if (label.includes(query) || matchingChildren.length) { - return { ...node, children: matchingChildren }; - } - - return null; - }; - - return positionTree.map((node) => filterNode(node)).filter(Boolean) as PositionTreeNode[]; - }, [positionSearch, positionTree]); - - const filteredPositionMembers = useMemo(() => { - const query = employeeSearch.trim().toLowerCase(); - if (!query) { - return positionMembers; - } - - return positionMembers.filter((employee) => { - const name = getLocaleLabel(employee.name ?? employee.user?.name, employee.user?.email ?? employee.id).toLowerCase(); - const email = employee.user?.email?.toLowerCase() ?? ""; - const username = employee.user?.username?.toLowerCase() ?? ""; - return name.includes(query) || email.includes(query) || username.includes(query); - }); - }, [employeeSearch, positionMembers]); - - const assignableEmployees = useMemo(() => { - const query = assignSearch.trim().toLowerCase(); - return orgEmployees.filter((employee) => { - const label = getLocaleLabel(employee.name ?? employee.user?.name, employee.user?.email ?? employee.id).toLowerCase(); - const email = employee.user?.email?.toLowerCase() ?? ""; - if (!query) { - return true; - } - return label.includes(query) || email.includes(query); - }); - }, [assignSearch, orgEmployees]); - - const resetMessages = () => { - setActionError(null); - setActionSuccess(null); - }; - - const loadOrganizations = useCallback(async () => { - setLoading(true); - setLoadError(null); - - try { - // Super admins may list every organization (needs can:find_all:organization). - // Org/unit admins are scoped to the orgs they administer via my-admin-organizations. - const endpoint = isSuperAdmin - ? "/organizations" - : "/organizations/with-admin-flag"; - const response = await api.get>(endpoint); - const nextOrganizations = getItems(response.data); - setOrganizations(nextOrganizations); - } catch (error) { - setLoadError(getErrorMessage(error, "Failed to load organizations.")); - } finally { - setLoading(false); - } - }, [isSuperAdmin]); - - const loadOrgEmployees = useCallback(async (organizationId: string) => { - setOrgEmployeesLoading(true); - - try { - const response = await api.get>( - `/backoffice/organizations/${organizationId}/employees`, - ); - setOrgEmployees(mergeEmployeesByUser(getItems(response.data))); - } catch { - setOrgEmployees([]); - } finally { - setOrgEmployeesLoading(false); - } - }, []); - - const loadOrgAdmins = useCallback(async (organizationId: string) => { - try { - const response = await api.get>( - `/organizations/org-admins/${organizationId}`, - ); - setOrgAdminUserIds( - new Set(getItems(response.data).map((employee) => employee.user?.id).filter(Boolean) as string[]), - ); - } catch { - setOrgAdminUserIds(new Set()); - } - }, []); - - const loadUnitAdmins = useCallback(async (unitId: string | null) => { - if (!unitId) { - setUnitAdminUserIds(new Set()); - return; - } - - try { - const response = await api.get>(`/units/unit-admins/${unitId}`); - setUnitAdminUserIds( - new Set(getItems(response.data).map((employee) => employee.user?.id).filter(Boolean) as string[]), - ); - } catch { - setUnitAdminUserIds(new Set()); - } - }, []); - - const loadUnits = useCallback(async (organizationId: string) => { - try { - const response = await api.get>(`/units/list/${organizationId}`); - setUnits(getItems(response.data)); - } catch (error) { - setLoadError(getErrorMessage(error, "Failed to load units.")); - setUnits([]); - } - }, []); - - const loadOrganizationConfiguration = useCallback(async (organizationId: string) => { - try { - const response = await api.get>( - `/organization-configurations/list/${organizationId}`, - ); - setSelectedOrgConfiguration(getItems(response.data)[0] ?? null); - } catch { - setSelectedOrgConfiguration(null); - } - }, []); - - const loadPositions = useCallback(async (unitId: string) => { - try { - const response = await api.get>(`/positions/list/${unitId}`); - setPositions(getItems(response.data)); - } catch (error) { - setLoadError(getErrorMessage(error, "Failed to load positions.")); - setPositions([]); - } - }, []); - - const loadPositionTypes = useCallback(async (unitId: string) => { - setPositionTypesLoading(true); - setPositionTypesError(null); - - try { - const response = await api.get>( - `/position-types/list-with-commons/${unitId}?take=1000&skip=0&orderBy=createdAt:Desc`, - ); - setPositionTypes(getItems(response.data)); - } catch (error) { - const message = getErrorMessage(error, "Failed to load position types."); - setPositionTypes([]); - setPositionTypesError(message); - throw error; - } finally { - setPositionTypesLoading(false); - } - }, []); - - 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); - } - }, []); - - const refreshOrganizations = useCallback(async () => { - await loadOrganizations(); - }, [loadOrganizations]); - - const refreshSelectedOrg = useCallback(async () => { - if (!selectedOrgId) { - setUnits([]); - setPositions([]); - setPositionMembers([]); - setOrgEmployees([]); - setSelectedOrgConfiguration(null); - return; - } - - await Promise.all([ - loadUnits(selectedOrgId), - loadOrgEmployees(selectedOrgId), - loadOrgAdmins(selectedOrgId), - loadOrganizationConfiguration(selectedOrgId), - ]); - }, [loadOrgAdmins, loadOrgEmployees, loadOrganizationConfiguration, loadUnits, selectedOrgId]); - - const refreshSelectedUnit = useCallback(async () => { - if (!selectedUnitId) { - setPositions([]); - setPositionTypes([]); - setPositionTypesError(null); - setPositionMembers([]); - setUnitAdminUserIds(new Set()); - return; - } - - await Promise.all([ - loadPositions(selectedUnitId), - loadPositionTypes(selectedUnitId), - loadUnitAdmins(selectedUnitId), - ]); - }, [loadPositionTypes, loadPositions, loadUnitAdmins, selectedUnitId]); - - useEffect(() => { - void loadOrganizations(); - }, [loadOrganizations]); - - useEffect(() => { - if (!visibleOrganizations.length) { - setSelectedOrgId(null); - setSelectedOrgConfiguration(null); - setUnits([]); - setPositions([]); - setPositionMembers([]); - setOrgEmployees([]); - return; - } - - if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) { - return; - } - - setSelectedOrgId(visibleOrganizations[0]?.id ?? null); - setSelectedOrgConfiguration(null); - setUnits([]); - setPositions([]); - setPositionMembers([]); - setOrgEmployees([]); - }, [selectedOrgId, visibleOrganizations]); - - useEffect(() => { - if (!selectedOrgId) { - return; - } - - void refreshSelectedOrg(); - }, [refreshSelectedOrg, selectedOrgId]); - - useEffect(() => { - if (!selectedOrgId) { - setUnits([]); - setPositions([]); - setPositionTypes([]); - setPositionTypesError(null); - setSelectedUnitId(null); - setSelectedPositionId(null); - setExpandedDepartmentIds(new Set()); - setPositionMembers([]); - setOrgEmployees([]); - setOrgAdminUserIds(new Set()); - setSelectedOrgConfiguration(null); - return; - } - }, [selectedOrgId]); - - useEffect(() => { - if (!selectedOrgId || !selectedUnitId) { - return; - } - - if (units.some((unit) => unit.id === selectedUnitId)) { - return; - } - - setSelectedUnitId(units[0]?.id ?? null); - }, [selectedOrgId, selectedUnitId, units]); - - useEffect(() => { - if (!selectedOrgId || !units.length) { - return; - } - - if (selectedUnitId && units.some((unit) => unit.id === selectedUnitId)) { - return; - } - - setSelectedUnitId(units[0]?.id ?? null); - }, [selectedOrgId, selectedUnitId, units]); - - useEffect(() => { - if (!selectedUnitId) { - setPositions([]); - setPositionTypes([]); - setPositionTypesError(null); - setSelectedPositionId(null); - setExpandedDepartmentIds(new Set()); - setPositionMembers([]); - setUnitAdminUserIds(new Set()); - return; - } - }, [selectedUnitId]); - - useEffect(() => { - if (!selectedUnitId) { - return; - } - - void refreshSelectedUnit(); - }, [refreshSelectedUnit, selectedUnitId]); - - useEffect(() => { - if (!selectedUnitId || !selectedPositionId) { - return; - } - - if (!positions.some((position) => position.id === selectedPositionId)) { - setSelectedPositionId(null); - } - }, [positions, selectedPositionId, selectedUnitId]); - - useEffect(() => { - if (!selectedPositionId) { - setPositionMembers([]); - return; - } - }, [selectedPositionId]); - - useEffect(() => { - void loadPositionMembers(selectedPositionId); - }, [loadPositionMembers, selectedPositionId]); - - const handleRefresh = async () => { - resetMessages(); - await Promise.all([ - refreshOrganizations(), - selectedOrgId ? refreshSelectedOrg() : Promise.resolve(), - selectedUnitId ? refreshSelectedUnit() : Promise.resolve(), - loadPositionMembers(selectedPositionId), - ]); - }; - - const handleSelectOrganization = async (organizationId: string) => { - setSelectedOrgId(organizationId); - setSelectedUnitId(null); - setSelectedPositionId(null); - setExpandedDepartmentIds(new Set()); - setUnits([]); - setPositions([]); - setPositionMembers([]); - setOrgEmployees([]); - setOrgAdminUserIds(new Set()); - setUnitAdminUserIds(new Set()); - setSelectedOrgConfiguration(null); - resetMessages(); - - try { - await Promise.all([ - loadUnits(organizationId), - loadOrgEmployees(organizationId), - loadOrgAdmins(organizationId), - loadOrganizationConfiguration(organizationId), - ]); - } catch { - // Individual loaders already handle their own error state. - } - }; - - const handleSelectUnit = async (unitId: string) => { - setSelectedUnitId(unitId); - setSelectedPositionId(null); - setExpandedDepartmentIds(new Set()); - setPositions([]); - setPositionTypes([]); - setPositionTypesError(null); - setPositionMembers([]); - setUnitAdminUserIds(new Set()); - resetMessages(); - }; - - const handleToggleDepartment = (departmentId: string) => { - setExpandedDepartmentIds((current) => { - const next = new Set(current); - if (next.has(departmentId)) { - next.delete(departmentId); - } else { - next.add(departmentId); - } - return next; - }); - }; - - const openCreateSubDepartmentDialog = async (parent: PositionRecord) => { - if (!selectedUnit) { - setActionError("Select a unit before creating a department."); - return; - } - - resetMessages(); - - try { - await loadPositionTypes(selectedUnit.id); - setSelectedPositionId(parent.id); - setExpandedDepartmentIds((current) => new Set(current).add(parent.id)); - setCreatePositionMode("create-child"); - setPositionForm({ - ...emptyPositionForm, - parentPositionId: parent.id, - }); - setIsCreatePositionOpen(true); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to load position types.")); - } - }; - - const openInviteForDepartment = (position: PositionRecord) => { - setSelectedPositionId(position.id); - resetMessages(); - setIsInviteEmployeeOpen(true); - }; - - const openAssignForDepartment = (position: PositionRecord) => { - setSelectedPositionId(position.id); - resetMessages(); - setIsAssignEmployeeOpen(true); - }; - - const openCreateUnitDialog = () => { - setUnitForm(emptyUnitForm); - resetMessages(); - setIsCreateUnitOpen(true); - }; - - const openEditUnitDialog = (unit: UnitRecord) => { - setUnitForm({ - nameEn: unit.name.en ?? "", - nameAm: unit.name.am ?? "", - key: unit.key, - }); - resetMessages(); - setIsEditUnitOpen(true); - }; - - const openCreatePositionDialog = async ( - mode: "create-root" | "create-child", - unitId = selectedUnit?.id, - ) => { - if (!unitId) { - setActionError("Select a unit before creating a department."); - return; - } - - resetMessages(); - - try { - await loadPositionTypes(unitId); - setCreatePositionMode(mode); - setPositionForm({ - ...emptyPositionForm, - parentPositionId: mode === "create-child" && selectedPosition ? selectedPosition.id : "", - }); - setIsCreatePositionOpen(true); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to load position types.")); - } - }; - - const openEditPositionDialog = async (position: PositionRecord) => { - if (!selectedUnit) { - setActionError("Select a unit before editing a department."); - return; - } - - resetMessages(); - - try { - await loadPositionTypes(selectedUnit.id); - setPositionForm({ - id: position.id, - nameEn: position.name.en ?? "", - nameAm: position.name.am ?? "", - key: position.key, - parentPositionId: position.parentPositionId ?? "", - positionTypeId: position.positionTypeId ?? "", - }); - setIsEditPositionOpen(true); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to load position types.")); - } - }; - - const handleCreateUnit = async (event: React.FormEvent) => { - event.preventDefault(); - if (!selectedOrganization) { - return; - } - - setSubmitting(true); - resetMessages(); - - try { - await api.post("/units", { - name: { am: unitForm.nameAm.trim(), en: unitForm.nameEn.trim() }, - key: unitForm.key.trim(), - organizationId: selectedOrganization.id, - }); - setActionSuccess("Unit created."); - setIsCreateUnitOpen(false); - await refreshSelectedOrg(); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to create unit.")); - } finally { - setSubmitting(false); - } - }; - - const handleUpdateUnit = async (event: React.FormEvent) => { - event.preventDefault(); - if (!selectedOrganization || !selectedUnit) { - return; - } - - setSubmitting(true); - resetMessages(); - - try { - await api.put(`/units/${selectedUnit.id}`, { - name: { am: unitForm.nameAm.trim(), en: unitForm.nameEn.trim() }, - key: unitForm.key.trim(), - organizationId: selectedOrganization.id, - }); - setActionSuccess("Unit updated."); - setIsEditUnitOpen(false); - await refreshSelectedOrg(); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to update unit.")); - } finally { - setSubmitting(false); - } - }; - - const handleDeleteUnit = async (unit: UnitRecord) => { - if (!unit) { - return; - } - - if (!window.confirm(`Delete unit '${getLocaleLabel(unit.name, unit.key)}'?`)) { - return; - } - - setSubmitting(true); - resetMessages(); - - try { - await api.delete(`/units/${unit.id}`); - setActionSuccess("Unit deleted."); - await refreshSelectedOrg(); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to delete unit.")); - } finally { - setSubmitting(false); - } - }; - - const handleSavePosition = async (event: React.FormEvent, isEditing: boolean) => { - event.preventDefault(); - if (!selectedOrganization || !selectedUnit) { - setActionError("Select a unit before managing positions."); - return; - } - - if (!positionForm.positionTypeId) { - setActionError("Select a position type before saving the department."); - 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: selectedOrganization.id, - parentPositionId: positionForm.parentPositionId || null, - positionTypeId: positionForm.positionTypeId, - rank: 0, - }; - - if (isEditing && 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."); - setIsEditPositionOpen(false); - } else { - await api.post("/positions", payload); - setActionSuccess("Position created."); - setIsCreatePositionOpen(false); - } - - await refreshSelectedUnit(); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to save position.")); - } finally { - setSubmitting(false); - } - }; - - const handleDeletePosition = async (position: PositionRecord) => { - if (!position) { - return; - } - - if (!window.confirm(`Delete position '${getLocaleLabel(position.name, position.key)}'?`)) { - return; - } - - setSubmitting(true); - resetMessages(); - - try { - await api.delete(`/positions/${position.id}`); - setActionSuccess("Position deleted."); - await refreshSelectedUnit(); - } catch (error) { - setActionError(getErrorMessage(error, "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([loadOrgEmployees(selectedOrgId!), loadPositionMembers(selectedPosition.id)]); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to invite employee.")); - } finally { - setSubmitting(false); - } - }; - - const handleAssignEmployee = async (employeeId: string) => { - if (!selectedPosition) { - return; - } - - setSubmitting(true); - resetMessages(); - - try { - await api.post("/employee-positions/assign", { - positionId: selectedPosition.id, - employeeId, - }); - setActionSuccess("Employee assigned to the selected position."); - setIsAssignEmployeeOpen(false); - setAssignSearch(""); - await Promise.all([loadOrgEmployees(selectedOrgId!), loadPositionMembers(selectedPosition.id)]); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to assign employee.")); - } finally { - setSubmitting(false); - } - }; - - const handleToggleUserActivation = async (employee: EmployeeRecord) => { - if (!employee.user?.id) { - return; - } - - const isInactive = employee.status?.toLowerCase() === "inactive"; - setSubmitting(true); - resetMessages(); - - try { - await api.patch( - `/users/${isInactive ? "activate-user" : "deactivate-user"}/${employee.user.id}`, - ); - setActionSuccess(isInactive ? "User activated." : "User deactivated."); - await Promise.all([loadOrgEmployees(selectedOrgId!), loadPositionMembers(selectedPositionId)]); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to update user status.")); - } finally { - setSubmitting(false); - } - }; - - const handleAdminRoleMutation = async ( - endpoint: string, - payload: { organizationId?: string; unitId?: string; userId: string }, - successMessage: string, - ) => { - setSubmitting(true); - resetMessages(); - - try { - await api.post(endpoint, payload); - setActionSuccess(successMessage); - await Promise.all([ - selectedOrgId ? loadOrgAdmins(selectedOrgId) : Promise.resolve(), - loadUnitAdmins(selectedUnitId), - ]); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to update admin role.")); - } finally { - setSubmitting(false); - } - }; - - const openManageRolesDialog = async (employee: EmployeeRecord) => { - if (!selectedOrgId || !employee.user?.id) { - return; - } - - setRolesLoading(true); - resetMessages(); - setSelectedRoleUser(employee); - setIsManageRolesOpen(true); - - try { - const [rolesResponse, assignedResponse] = await Promise.all([ - api.get>("/roles"), - api.get(`/backoffice/organizations/${selectedOrgId}/employee-users/${employee.user.id}/roles`), - ]); - const roles = getItems(rolesResponse.data).filter((role) => !RESERVED_ROLE_KEYS.has(role.key)); - setAvailableRoles(roles); - setRoleIds(getItems(assignedResponse.data).map((role) => role.id)); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to load user roles.")); - setAvailableRoles([]); - setRoleIds([]); - } finally { - setRolesLoading(false); - } - }; - - const handleSaveRoles = async () => { - if (!selectedOrgId || !selectedRoleUser?.user?.id) { - return; - } - - setSubmitting(true); - resetMessages(); - - try { - await api.put( - `/backoffice/organizations/${selectedOrgId}/employee-users/${selectedRoleUser.user.id}/roles`, - { roleIds }, - ); - setActionSuccess("User roles updated."); - setIsManageRolesOpen(false); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to update user roles.")); - } finally { - setSubmitting(false); - } - }; - - return ( -
    -
    -
    -
    -
    - -
    -
    -

    - User management -

    -

    Organization workspace

    -

    - Manage organizations, branches, departments, team members, and admin access from a - single SmartOffice-style workspace. -

    -
    -
    - - -
    -
    - - {actionSuccess ? ( -
    - {actionSuccess} -
    - ) : null} - - {actionError ? ( -
    - {actionError} -
    - ) : null} - - {loadError ? ( -
    - {loadError} -
    - ) : null} - - {loading ? ( -
    - Loading user management workspace... -
    - ) : ( -
    - - - - - - -
    -
    -
    -

    Team members

    -

    - {selectedPosition - ? `${getLocaleLabel(selectedPosition.name, selectedPosition.key)} in ${getLocaleLabel(selectedUnit?.name, selectedUnit?.key ?? "")}` - : "Select a department"} -

    -
    -
    - -
    - - setEmployeeSearch(event.target.value)} - placeholder="Search team members" - /> -
    - - {membersLoading ? ( -
    - Loading team members... -
    - ) : filteredPositionMembers.length ? ( -
    - {filteredPositionMembers.map((employee) => { - const userId = employee.user?.id; - const isOrgAdmin = Boolean(userId && orgAdminUserIds.has(userId)); - const isUnitAdmin = Boolean(userId && unitAdminUserIds.has(userId)); - const displayName = getLocaleLabel( - employee.name ?? employee.user?.name, - employee.user?.email ?? employee.id, - ); - const assignedPositions = employee.employeePositions - ?.map((position) => getLocaleLabel(position.position?.name, position.position?.id ?? "")) - .filter(Boolean) - .join(", "); - - return ( -
    -
    -
    -
    -

    {displayName}

    - {isOrgAdmin ? ( - Org admin - ) : null} - {isUnitAdmin ? ( - Unit admin - ) : null} - {employee.status ? ( - {employee.status} - ) : null} -
    -
    - {employee.user?.email || employee.user?.username || "No contact info"} -
    - {assignedPositions ? ( -
    - Current positions: {assignedPositions} -
    - ) : null} -
    - -
    - - - - - - - -
    -
    -
    - ); - })} -
    - ) : ( -
    - No team members found for this department. -
    - )} -
    -
    - )} - - -
    - - setUnitForm((current) => ({ ...current, nameEn: event.target.value }))} - /> - - - setUnitForm((current) => ({ ...current, nameAm: event.target.value }))} - /> - - -
    - setUnitForm((current) => ({ ...current, key: event.target.value }))} - /> - -
    -
    -
    - - -
    -
    -
    - - -
    - - setUnitForm((current) => ({ ...current, nameEn: event.target.value }))} - /> - - - setUnitForm((current) => ({ ...current, nameAm: event.target.value }))} - /> - - - setUnitForm((current) => ({ ...current, key: event.target.value }))} - /> - -
    - - -
    -
    -
    - - -
    void handleSavePosition(event, false)}> - - - setPositionForm((current) => ({ ...current, nameEn: event.target.value })) - } - /> - - - - setPositionForm((current) => ({ ...current, nameAm: event.target.value })) - } - /> - - -
    - setPositionForm((current) => ({ ...current, key: event.target.value }))} - /> - -
    -
    - - - {positionTypesError ? ( -

    {positionTypesError}

    - ) : null} -
    - - - -
    - - -
    -
    -
    - - -
    void handleSavePosition(event, true)}> - - - setPositionForm((current) => ({ ...current, nameEn: event.target.value })) - } - /> - - - - setPositionForm((current) => ({ ...current, nameAm: event.target.value })) - } - /> - - - setPositionForm((current) => ({ ...current, key: event.target.value }))} - /> - - - - {positionTypesError ? ( -

    {positionTypesError}

    - ) : null} -
    - - - -
    - - -
    -
    -
    - - -
    - - setInviteForm((current) => ({ ...current, nameEn: event.target.value }))} - /> - - - setInviteForm((current) => ({ ...current, nameAm: event.target.value }))} - /> - - - setInviteForm((current) => ({ ...current, email: event.target.value }))} - /> - - - setInviteForm((current) => ({ ...current, username: event.target.value }))} - /> - - - - setInviteForm((current) => ({ ...current, phoneNumber: event.target.value })) - } - /> - -
    - - -
    -
    -
    - - -
    -
    - - setAssignSearch(event.target.value)} - placeholder="Search organization employees" - /> -
    - -
    - {orgEmployeesLoading ? ( -
    - Loading organization employees... -
    - ) : assignableEmployees.length ? ( - assignableEmployees.map((employee) => { - const displayName = getLocaleLabel( - employee.name ?? employee.user?.name, - employee.user?.email ?? employee.id, - ); - const assignedPositions = employee.employeePositions - ?.map((position) => getLocaleLabel(position.position?.name, position.position?.id ?? "")) - .filter(Boolean) - .join(", "); - - return ( -
    -
    -
    {displayName}
    -
    - {employee.user?.email || employee.user?.phoneNumber || "No contact info"} -
    - {assignedPositions ? ( -
    - Existing positions: {assignedPositions} -
    - ) : null} -
    - -
    - ); - }) - ) : ( -
    - No employees found for this organization. -
    - )} -
    -
    -
    - - -
    - {rolesLoading ? ( -
    - Loading roles... -
    - ) : ( -
    - {availableRoles.map((role) => ( - - ))} - {!availableRoles.length ? ( -
    - No assignable roles available. -
    - ) : null} -
    - )} -
    - - -
    -
    -
    -
    - ); -}; - -export default UserManagementPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx deleted file mode 100644 index 70d58d313..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx +++ /dev/null @@ -1,825 +0,0 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { isAxiosError } from "axios"; -import { - Badge, - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@edr/ui-common"; -import { Network, RefreshCw, Search, UserCheck, UserMinus, Users } from "lucide-react"; - -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 EmployeeUserRecord { - id: string; - name?: LocaleText; - email?: string; - phoneNumber?: 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 RoleRecord { - id: string; - key: string; - name: LocaleText; -} - -interface UserFormState { - nameEn: string; - nameAm: string; - email: string; - username: string; - phoneNumber: string; - assignOrganizationAdmin: boolean; -} - -interface ListResponse { - items?: T[]; - data?: T[]; -} - -const RESERVED_ROLE_KEYS = new Set(["super_admin", "organization_admin", "unit_admin"]); - -const emptyUserForm: UserFormState = { - nameEn: "", - nameAm: "", - email: "", - username: "", - phoneNumber: "", - assignOrganizationAdmin: false, -}; - -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 mergeEmployeesByUser = (employees: EmployeeRecord[]) => { - const employeesByUserId = new Map(); - - for (const employee of employees) { - const userId = employee.user?.id; - - if (!userId) { - employeesByUserId.set(employee.id, employee); - continue; - } - - const existing = employeesByUserId.get(userId); - - if (!existing) { - employeesByUserId.set(userId, employee); - continue; - } - - const existingPositions = existing.employeePositions ?? []; - const nextPositions = employee.employeePositions ?? []; - const mergedPositions = Array.from( - new Map( - [...existingPositions, ...nextPositions].map((position) => [position.id, position]), - ).values(), - ); - - employeesByUserId.set(userId, { - ...existing, - ...employee, - id: existing.id, - name: existing.name ?? employee.name, - status: existing.status ?? employee.status, - user: existing.user ?? employee.user, - employeePositions: mergedPositions, - }); - } - - return [...employeesByUserId.values()]; -}; - -const getErrorMessage = (error: unknown, fallback: string) => { - if (isAxiosError(error)) { - const message = error.response?.data?.message; - if (typeof message === "string") { - return message; - } - if (Array.isArray(message) && typeof message[0] === "string") { - return message[0]; - } - } - - return error instanceof Error ? error.message : fallback; -}; - -const Field = ({ label, children }: { label: string; children: ReactNode }) => ( - -); - -const ManagementDialog = ({ - open, - title, - description, - onOpenChange, - children, -}: { - open: boolean; - title: string; - description?: string; - onOpenChange: (open: boolean) => void; - children: ReactNode; -}) => ( - - - - {title} - {description ? {description} : null} - - {children} - - -); - -const UsersPage = () => { - const { user } = useAuth(); - const [organizations, setOrganizations] = useState([]); - const [orgEmployees, setOrgEmployees] = useState([]); - const [selectedOrgId, setSelectedOrgId] = useState(null); - const [orgUserSearch, setOrgUserSearch] = useState(""); - const [createUserForm, setCreateUserForm] = useState(emptyUserForm); - const [availableRoles, setAvailableRoles] = useState([]); - const [roleIds, setRoleIds] = useState([]); - const [selectedRoleUser, setSelectedRoleUser] = useState(null); - const [loading, setLoading] = useState(true); - const [orgEmployeesLoading, setOrgEmployeesLoading] = useState(false); - const [rolesLoading, setRolesLoading] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [loadError, setLoadError] = useState(null); - const [actionError, setActionError] = useState(null); - const [actionSuccess, setActionSuccess] = useState(null); - const [isCreateUserOpen, setIsCreateUserOpen] = useState(false); - const [isManageRolesOpen, setIsManageRolesOpen] = useState(false); - - const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin")); - const allowedOrgIds = useMemo( - () => new Set((user?.employee ?? []).map((employee) => employee.organizationId).filter(Boolean)), - [user?.employee], - ); - - const visibleOrganizations = useMemo(() => { - if (isSuperAdmin) { - return organizations; - } - - return organizations.filter((organization) => allowedOrgIds.has(organization.id)); - }, [allowedOrgIds, isSuperAdmin, organizations]); - - const selectedOrganization = useMemo( - () => visibleOrganizations.find((item) => item.id === selectedOrgId) ?? null, - [selectedOrgId, visibleOrganizations], - ); - - const filteredOrgEmployees = useMemo(() => { - const query = orgUserSearch.trim().toLowerCase(); - - return orgEmployees.filter((employee) => { - const label = getLocaleLabel( - employee.name ?? employee.user?.name, - employee.user?.email ?? employee.id, - ).toLowerCase(); - const email = employee.user?.email?.toLowerCase() ?? ""; - const username = employee.user?.username?.toLowerCase() ?? ""; - - if (!query) { - return true; - } - - return label.includes(query) || email.includes(query) || username.includes(query); - }); - }, [orgEmployees, orgUserSearch]); - - const resetMessages = () => { - setActionError(null); - setActionSuccess(null); - }; - - const loadOrganizations = useCallback(async () => { - setLoading(true); - setLoadError(null); - - try { - const response = await api.get>("/organizations"); - setOrganizations(getItems(response.data)); - } catch (error) { - setLoadError(getErrorMessage(error, "Failed to load organizations.")); - } finally { - setLoading(false); - } - }, []); - - const loadOrgEmployees = useCallback(async (organizationId: string) => { - setOrgEmployeesLoading(true); - - try { - const response = await api.get>( - `/backoffice/organizations/${organizationId}/employees`, - ); - setOrgEmployees(mergeEmployeesByUser(getItems(response.data))); - } catch { - setOrgEmployees([]); - } finally { - setOrgEmployeesLoading(false); - } - }, []); - - useEffect(() => { - void loadOrganizations(); - }, [loadOrganizations]); - - useEffect(() => { - if (!visibleOrganizations.length) { - setSelectedOrgId(null); - setOrgEmployees([]); - return; - } - - if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) { - return; - } - - setSelectedOrgId(visibleOrganizations[0]?.id ?? null); - }, [selectedOrgId, visibleOrganizations]); - - useEffect(() => { - if (!selectedOrgId) { - setOrgEmployees([]); - return; - } - - void loadOrgEmployees(selectedOrgId); - }, [loadOrgEmployees, selectedOrgId]); - - const handleRefresh = async () => { - resetMessages(); - await Promise.all([ - loadOrganizations(), - selectedOrgId ? loadOrgEmployees(selectedOrgId) : Promise.resolve(), - ]); - }; - - const handleSelectOrganization = async (organizationId: string) => { - setSelectedOrgId(organizationId); - setOrgEmployees([]); - resetMessages(); - - try { - await loadOrgEmployees(organizationId); - } catch { - // Loader already handles fallback state. - } - }; - - const openCreateUserDialog = () => { - if (!selectedOrgId) { - setActionError("Select an organization before adding a user."); - return; - } - - setCreateUserForm(emptyUserForm); - resetMessages(); - setIsCreateUserOpen(true); - }; - - const openManageRolesDialog = async (employee: EmployeeRecord) => { - if (!selectedOrgId || !employee.user?.id) { - return; - } - - setRolesLoading(true); - resetMessages(); - setSelectedRoleUser(employee); - setIsManageRolesOpen(true); - - try { - const [rolesResponse, assignedResponse] = await Promise.all([ - api.get>("/roles"), - api.get(`/backoffice/organizations/${selectedOrgId}/employee-users/${employee.user.id}/roles`), - ]); - const roles = getItems(rolesResponse.data).filter((role) => !RESERVED_ROLE_KEYS.has(role.key)); - setAvailableRoles(roles); - setRoleIds(getItems(assignedResponse.data).map((role) => role.id)); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to load user roles.")); - setAvailableRoles([]); - setRoleIds([]); - } finally { - setRolesLoading(false); - } - }; - - const handleCreateUser = async (event: React.FormEvent) => { - event.preventDefault(); - - if (!selectedOrgId) { - setActionError("Select an organization before adding a user."); - return; - } - - setSubmitting(true); - resetMessages(); - - try { - const response = await api.post( - `/backoffice/organizations/${selectedOrgId}/users`, - { - username: createUserForm.username.trim(), - phoneNumber: createUserForm.phoneNumber.trim(), - email: createUserForm.email.trim(), - name: { - am: createUserForm.nameAm.trim(), - en: createUserForm.nameEn.trim(), - }, - assignOrganizationAdmin: createUserForm.assignOrganizationAdmin, - }, - ); - - const shouldAssignOrganizationAdmin = createUserForm.assignOrganizationAdmin; - setCreateUserForm(emptyUserForm); - setIsCreateUserOpen(false); - setActionSuccess( - shouldAssignOrganizationAdmin - ? "User created as organization admin. Default password: 12345678." - : "User created. Default password: 12345678.", - ); - await loadOrgEmployees(selectedOrgId); - - if (!shouldAssignOrganizationAdmin) { - await openManageRolesDialog(response.data); - } - } catch (error) { - setActionError(getErrorMessage(error, "Failed to create user.")); - } finally { - setSubmitting(false); - } - }; - - const handleSaveRoles = async () => { - if (!selectedOrgId || !selectedRoleUser?.user?.id) { - return; - } - - setSubmitting(true); - resetMessages(); - - try { - await api.put( - `/backoffice/organizations/${selectedOrgId}/employee-users/${selectedRoleUser.user.id}/roles`, - { roleIds }, - ); - setActionSuccess("User roles updated."); - setIsManageRolesOpen(false); - } catch (error) { - setActionError(getErrorMessage(error, "Failed to update user roles.")); - } finally { - setSubmitting(false); - } - }; - - const handleToggleUserActivation = async (employee: EmployeeRecord) => { - if (!employee.user?.id) { - return; - } - - const isInactive = employee.status?.toLowerCase() === "inactive"; - setSubmitting(true); - resetMessages(); - - try { - await api.patch(`/users/${isInactive ? "activate-user" : "deactivate-user"}/${employee.user.id}`); - setActionSuccess(isInactive ? "User activated." : "User deactivated."); - if (selectedOrgId) { - await loadOrgEmployees(selectedOrgId); - } - } catch (error) { - setActionError(getErrorMessage(error, "Failed to update user status.")); - } finally { - setSubmitting(false); - } - }; - - return ( -
    -
    -
    -
    -
    - -
    -
    -

    - User management -

    -

    Users

    -

    - Create organization users, activate or deactivate access, and assign organization-scoped roles. -

    -
    -
    - - -
    -
    - - {actionSuccess ? ( -
    - {actionSuccess} -
    - ) : null} - - {actionError ? ( -
    - {actionError} -
    - ) : null} - - {loadError ? ( -
    - {loadError} -
    - ) : null} - - {loading ? ( -
    - Loading users workspace... -
    - ) : ( -
    - - -
    -
    -
    -

    Users

    -

    - {selectedOrganization - ? `Manage users in ${getLocaleLabel(selectedOrganization.name, selectedOrganization.key)}` - : "Select an organization"} -

    -
    -
    - {orgEmployees.length} users -
    -
    - -
    - - setOrgUserSearch(event.target.value)} - placeholder="Search users" - /> -
    - - {orgEmployeesLoading ? ( -
    - Loading users... -
    - ) : filteredOrgEmployees.length ? ( -
    - {filteredOrgEmployees.map((employee) => { - const userId = employee.user?.id; - const displayName = getLocaleLabel( - employee.name ?? employee.user?.name, - employee.user?.email ?? employee.id, - ); - const assignedPositions = employee.employeePositions - ?.map((position) => getLocaleLabel(position.position?.name, position.position?.id ?? "")) - .filter(Boolean) - .join(", "); - - return ( -
    -
    -
    -
    -

    {displayName}

    - {employee.status ? ( - {employee.status} - ) : null} -
    -
    - {employee.user?.email || employee.user?.username || "No contact info"} -
    -
    - {assignedPositions ? `Current positions: ${assignedPositions}` : "No positions assigned."} -
    -
    - -
    - - - -
    -
    -
    - ); - })} -
    - ) : ( -
    - {selectedOrganization - ? "No users found for this organization." - : "Select an organization to load users."} -
    - )} -
    -
    - )} - - -
    - - setCreateUserForm((current) => ({ ...current, nameEn: event.target.value }))} - /> - - - setCreateUserForm((current) => ({ ...current, nameAm: event.target.value }))} - /> - - - setCreateUserForm((current) => ({ ...current, email: event.target.value }))} - /> - - - setCreateUserForm((current) => ({ ...current, username: event.target.value }))} - /> - - - setCreateUserForm((current) => ({ ...current, phoneNumber: event.target.value }))} - /> - - -
    - - -
    -
    -
    - - -
    - {rolesLoading ? ( -
    - Loading roles... -
    - ) : ( -
    - {availableRoles.map((role) => ( - - ))} - {!availableRoles.length ? ( -
    - No assignable roles available. -
    - ) : null} -
    - )} -
    - - -
    -
    -
    -
    - ); -}; - -export default UsersPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts deleted file mode 100644 index ec27b5c76..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { DesignConfig } from "@tria-plc/iamui"; - -import { - FREIGHT_BRAND, - FREIGHT_BRAND_LIGHT, - freightBrand, -} from "@/theme/freight-brand"; - -export const iamConfig: DesignConfig = { - brand: { - appName: "EDR Freight Backoffice", - logoUrl: "/assets/logo.svg", - }, - colors: { - primary: FREIGHT_BRAND, - primaryForeground: "#ffffff", - secondary: "#f4f7fb", - background: "#f7f9fb", - foreground: "#0f172a", - border: "#eef1f4", - muted: "#f1f5f9", - mutedForeground: "#64748b", - card: "#ffffff", - sidebar: "#ffffff", - danger: "#ef4444", - }, - typography: { - fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif", - headingFontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif", - baseFontSize: "15px", - fontWeight: "500", - }, - shape: { - radius: "1rem", - }, - shadows: { - card: "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)", - dropdown: "0 12px 30px rgba(15, 23, 42, 0.12)", - modal: "0 20px 45px rgba(15, 23, 42, 0.2)", - }, - components: { - buttonDefaultVariant: "filled", - inputDefaultSize: "sm", - inputRadius: "md", - modalRadius: "lg", - tableHighlightOnHover: true, - }, - layout: { - userManagementView: "classic", - showTopBar: true as any, - sidebarWidth: "280px", - sidebarCollapsedWidth: "80px", - headerHeight: "80px", - contentMaxWidth: "none", - sidebarBackground: "#ffffff", - sidebarColor: "#475569", - sidebarMutedColor: "#94a3b8", - sidebarActiveBackground: - "linear-gradient(135deg, rgba(45, 191, 149, 0.14) 0%, rgba(27, 158, 122, 0.06) 100%)", - sidebarActiveColor: FREIGHT_BRAND, - sidebarHoverBackground: "#f5f7fa", - sidebarBorder: "#eef1f4", - sidebarRail: `linear-gradient(180deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`, - sidebarBrandLabel: "EDR Freight", - sidebarBrandSublabel: "Backoffice Console", - menuBackground: "#ffffff", - menuActiveColor: FREIGHT_BRAND, - menuActiveBorderColor: FREIGHT_BRAND, - menuColor: "#64748b", - menuHoverColor: "#0f172a", - modalAccentColor: `linear-gradient(135deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`, - modalHeaderBackground: "#ffffff", - modalHeaderEditBackground: "#ffffff", - modalIconBackground: freightBrand.mutedBg, - modalIconColor: FREIGHT_BRAND, - modalTitleColor: "#0f172a", - modalFocusColor: FREIGHT_BRAND, - modalSurface: "#ffffff", - }, - appearance: { - colorScheme: "light", - slots: { - root: { - styles: { - background: "#f7f9fb", - color: "#0f172a", - fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif", - }, - }, - shell: { - styles: { - background: "#f7f9fb", - }, - }, - content: { - styles: { - background: "#f7f9fb", - }, - }, - page: { - styles: { - background: "#ffffff", - border: "1px solid #eef1f4", - borderRadius: "24px", - boxShadow: - "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)", - }, - }, - card: { - styles: { - background: "#ffffff", - border: "1px solid #eef1f4", - borderRadius: "20px", - boxShadow: - "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)", - }, - }, - sidebar: { - styles: { - background: "#ffffff", - border: "1px solid #eef1f4", - borderRadius: "16px", - boxShadow: - "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)", - }, - }, - "sidebar-brand": { - styles: { - minHeight: "80px", - borderBottom: "1px solid #f1f5f9", - }, - }, - topbar: { - styles: { - background: "#ffffff", - border: "1px solid #eef1f4", - borderRadius: "16px", - boxShadow: - "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)", - }, - }, - "topbar-panel": { - styles: { - background: "#f7f9fb", - border: "1px solid #eef1f4", - borderRadius: "12px", - }, - }, - "topbar-user-summary": { - styles: { - borderRadius: "14px", - }, - }, - table: { - styles: { - background: "#ffffff", - border: "1px solid #eef1f4", - borderRadius: "20px", - overflow: "hidden", - }, - }, - "table-header": { - styles: { - background: "#f8fafc", - }, - }, - modal: { - styles: { - borderRadius: "24px", - overflow: "hidden", - }, - }, - "modal-header": { - styles: { - background: "#ffffff", - borderBottom: "1px solid #eef1f4", - }, - }, - }, - customCss: ` - [data-um-app="user-management"] { - --um-page-gap: 20px; - } - - [data-um-app="user-management"] h1, - [data-um-app="user-management"] h2, - [data-um-app="user-management"] h3, - [data-um-app="user-management"] h4, - [data-um-app="user-management"] h5, - [data-um-app="user-management"] h6 { - letter-spacing: -0.02em; - color: #0f172a; - } - - [data-um-app="user-management"] [data-um-slot="sidebar-item"][aria-current="page"] { - box-shadow: inset 3px 0 0 ${FREIGHT_BRAND}; - } - - [data-um-app="user-management"] button, - [data-um-app="user-management"] input, - [data-um-app="user-management"] select, - [data-um-app="user-management"] textarea { - font-family: 'Outfit', var(--font-sans), system-ui, sans-serif; - } - `, - }, -}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/route.tsx b/apps/edr-freight-web/backoffice/src/user-management/route.tsx index 910712d5d..76d3182ad 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/route.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/route.tsx @@ -6,20 +6,15 @@ import { isSuperAdmin } from "@/lib/permissions"; import { WithPermission } from "@/shared/hooks/useHas"; import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers"; import TemplatePage from "@/super-admin/components/templates/components/templates"; -import UserManagementPage from "@/pages/dashboard/user-management/UserManagementPage"; - import CreatePositionPage from "./pages/position-management/create"; import EditPositionPage from "./pages/position-management/edit"; import PositionManagementPage from "./pages/position-management"; import MigratedDataManagementPage from "./pages/position-management/MigratedDataManagementPage"; import UserPositionApprovalPage from "./pages/UserPositionApprovalPage"; import ViewMigratedDataPage from "./components/MigratedRecords/ViewMigratedDataPage"; -import ContentManagement from "./components/content/ContentManagement"; -import { BulkUserUpload } from "./bulkUpload/bulkUpload"; import AllRecordsPage from "./all-records/pages/AllRecordsPage"; import AllRecordDetailsPage from "./all-records/pages/AllRecordDetailsPage"; import Branding from "./web-Management/Branding/Branding"; -import { WebManagementHomePage } from "./web-Management/webManagement"; import { AppLayout } from "./Applayout"; import ActivityLogPage from "@/pages/ActivityLogPage"; import AdminRegistrationPage from "@/pages/Organizations/AdminRegistrationPage"; @@ -46,6 +41,7 @@ import ConfigurationPage from "@/pages/ConfigurationPage"; import { SidebarProvider } from "@/shared/common/ui/sidebar"; import { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext"; import { PermissionProvider } from "@/shared/context/PermissionContext"; +import UserManagementPage from "@/pages/UserManagementPage"; /** * Provider shell for the vendored IAM UI. Feeds its Auth + Permission contexts From 122c57debb0ec31627e7490257db4f7496015e53 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sat, 11 Jul 2026 11:12:16 +0300 Subject: [PATCH 04/46] Add booking lookup by phone number --- .../modules/bookings/bookings.controller.ts | 30 +++ .../src/modules/bookings/bookings.service.ts | 111 +++++++- .../src/app/booking/confirmation/page.tsx | 60 +++-- .../portal/src/app/booking/detail/page.tsx | 14 + .../portal/src/app/booking/lookup/page.tsx | 243 +++++++++++++++--- .../src/app/booking/passengers/page.tsx | 6 +- .../portal/src/app/booking/review/page.tsx | 41 ++- .../portal/src/lib/generate-voucher.ts | 13 +- 8 files changed, 430 insertions(+), 88 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 23a3b6c5a..7789fd56b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -441,6 +441,36 @@ export class BookingsController { return this.service.checkBookingUsage(id); } + @Get('by-phone') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'Find bookings by phone number (no auth required)', + description: `Returns all bookings where the contact phone matches the provided number. +Accepts Ethiopian local format (09XXXXXXXX) and international format (+251XXXXXXXXX). +Results are ordered most-recent first. Use the returned \`bookingRef\` to open booking detail.` + }) + @ApiQuery({ name: 'phone', required: true, description: 'Phone number in local (09…) or international (+251…) format' }) + @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) + @ApiResponse({ status: 200, description: 'Paginated list of bookings for this phone number' }) + @ApiResponse({ status: 400, description: 'Phone number missing or invalid' }) + findByPhone( + @Query('phone') phone?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + if (!phone?.trim()) throw new BadRequestException('Phone number is required'); + const digits = phone.replace(/[^\d]/g, ''); + if (digits.length < 7) throw new BadRequestException('Phone number is too short'); + return this.service.findByPhone(phone.trim(), { + status, + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 20, + }); + } + @Get(':bookingRef') @SetMetadata('isPublic', true) @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b80593cd5..d9c393ee7 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -39,6 +39,37 @@ function resolvePackageRoundTripTotal( return adultCount * adultFareMinor + paidChildren * adultFareMinor; } +/** + * Returns all plausible normalised variants of a raw phone string so that the + * DB query matches regardless of how the number was stored (local 09… vs international +251…). + * Returns an empty array when the input is clearly invalid (< 7 digits). + */ +function normalizePhoneVariants(raw: string): string[] { + // Strip whitespace, dashes, dots, parentheses — keep digits and a leading + + const stripped = raw.replace(/[^\d+]/g, ''); + const digits = stripped.replace(/^\+/, ''); + if (digits.length < 7) return []; + + const variants = new Set([stripped]); + + if (stripped.startsWith('+251') && digits.length === 12) { + // +251 9XXXXXXXX → 09XXXXXXXX + variants.add('0' + digits.slice(3)); + } else if (stripped.startsWith('251') && digits.length === 12) { + // 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX + variants.add('+' + stripped); + variants.add('0' + digits.slice(3)); + } else if (stripped.startsWith('0') && digits.length === 10) { + // 09XXXXXXXX → +251 9XXXXXXXX + variants.add('+251' + digits.slice(1)); + } else if (!stripped.startsWith('+') && digits.length >= 9) { + // bare international digits without + + variants.add('+' + digits); + } + + return [...variants]; +} + function calculateAge(dateOfBirth: Date): number { const today = new Date(); let age = today.getFullYear() - dateOfBirth.getFullYear(); @@ -145,6 +176,70 @@ export class BookingsService { }; } + async findByPhone(rawPhone: string, filters: BookingFilters = {}) { + const variants = normalizePhoneVariants(rawPhone); + if (variants.length === 0) return { items: [], meta: { page: 1, pageSize: 20, total: 0, totalPages: 0 } }; + + const { status, page = 1, pageSize = 20 } = filters; + const skip = (page - 1) * pageSize; + + const where: any = { + OR: [ + { contactPhone: { in: variants } }, + { passenger: { user: { phone: { in: variants } } } }, + ], + }; + if (status) where.status = status; + + const [items, total] = await Promise.all([ + this.prisma.booking.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } }, + seats: { select: { id: true } }, + priceTier: { select: { priceMinor: true } }, + }, + }), + this.prisma.booking.count({ where }), + ]); + + return { + items: items.map(booking => ({ + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + adultCount: booking.adultCount, + childCount: booking.childCount, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + createdAt: booking.createdAt, + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + arrivalAt: booking.schedule.arrivalAt, + }, + payment: booking.paymentIntent ?? undefined, + seatCount: booking.seats.length, + })), + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } + async findByDeviceId(deviceId: string, filters: BookingFilters = {}) { const { search, status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; @@ -1516,7 +1611,12 @@ export class BookingsService { seat: null, })), payment: (pkgBooking as any).paymentIntent - ? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status } + ? { + method: (pkgBooking as any).paymentIntent.method, + status: (pkgBooking as any).paymentIntent.status, + amountMinor: (pkgBooking as any).paymentIntent.amountMinor, + currency: (pkgBooking as any).paymentIntent.currency, + } : undefined, tickets: [], }; @@ -1556,7 +1656,14 @@ export class BookingsService { seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null, }, })), - payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined, + payment: (booking as any).paymentIntent + ? { + method: (booking as any).paymentIntent.method, + status: (booking as any).paymentIntent.status, + amountMinor: (booking as any).paymentIntent.amountMinor, + currency: (booking as any).paymentIntent.currency, + } + : undefined, // One ticket per passenger — matched on the frontend by passengerName, not array // position, since tickets are grouped/created independently of the passengers array. tickets: (booking as any).tickets?.map((t: any) => ({ diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 8ac4bf046..48e29968f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -7,7 +7,7 @@ import { useBookingStore } from '@/lib/booking-store'; import { usePaymentStore } from '@/lib/payment-store'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState } from 'react'; import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react'; import { format } from 'date-fns'; import { isChild, isFirstChild } from '@/utils/fare-utils'; @@ -19,6 +19,14 @@ type BookingWithTicket = { totalMinor?: number; createdAt?: string; paymentMethod?: string; + // The actual settled amount/currency for this booking's payment — authoritative over any + // client-side session state, since it reflects what was really charged server-side. + payment?: { + method?: string; + status?: string; + amountMinor?: number; + currency?: string; + }; // One ticket per passenger — match by passengerName, not array position (see // bookings.service.ts's getByRef). tickets?: Array<{ @@ -37,7 +45,6 @@ export default function ConfirmationPage() { const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const [copied, setCopied] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); - const confirmAttempted = useRef(false); // Warms the code-split voucher module ahead of the click so the handler's own // `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered @@ -66,22 +73,11 @@ export default function ConfirmationPage() { // Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge — // a gateway redirect back here does not mean payment succeeded (see payment return pages). + // Ticket generation itself is never triggered from this page — the payment webhook + // generates it server-side (for every payment method, wallet included); this page only + // ever fetches and displays whatever the booking query above already returns. const isConfirmed = _booking?.status === 'CONFIRMED'; - useEffect(() => { - if (bookingId && !confirmAttempted.current) { - confirmAttempted.current = true; - - // Only generate ticket if booking is already CONFIRMED (e.g. wallet payment) - // For other payment methods, ticket is generated by the payment webhook after payment completes - apiClient.get(`/bookings/${bookingId}`).then((data: any) => { - if (data?.status === 'CONFIRMED') { - apiClient.post(`/tickets/generate/${bookingId}`).catch(() => {}); - } - }).catch(() => {}); - } - }, [bookingId]); - const copyPNR = () => { if (pnr) { navigator.clipboard.writeText(pnr); @@ -105,15 +101,17 @@ export default function ConfirmationPage() { const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher'); const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; - // Prefer the amount/currency actually confirmed for the selected payment option; - // only fall back to the ETB booking fare when no payment step ran (e.g. $0 total). - const voucherCurrency = 'ETB'; + // The server-confirmed settled amount/currency (what was actually charged) is + // authoritative — prefer it over the ETB booking fare once it's available. + const settledAmountMinor = _booking?.payment?.amountMinor; + const settledCurrency = _booking?.payment?.currency; + const voucherCurrency = settledCurrency || 'ETB'; const createdAt = _booking?.createdAt || new Date().toISOString(); const status = _booking?.status || 'CONFIRMED'; - // Compute per-passenger fares using the same logic as the review/payment pages. - // reviewedPassengerFares is the authoritative source; rebuild from package context - // as a fallback so free children always show ETB 0.00 on their voucher. + // Compute per-passenger fares (in ETB) using the same logic as the review/payment + // pages. reviewedPassengerFares is the authoritative source; rebuild from package + // context as a fallback so free children always show 0 on their voucher. const { packageTierPriceMinor } = useBookingStore.getState(); const isPackageBooking = packageTierPriceMinor != null; const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; @@ -121,7 +119,7 @@ export default function ConfirmationPage() { const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0; const pkgChildFare = pkgAdultFare; - const getVoucherFare = (idx: number): number => { + const getEtbFare = (idx: number): number => { if (reviewedPassengerFares?.[idx] != null) return reviewedPassengerFares[idx].fareMinor; if (isPackageBooking) { const isPkgChild = idx >= adultCount; @@ -133,6 +131,17 @@ export default function ConfirmationPage() { return Math.round(totalFare / passengers.length); }; + // Real conversion happened (payment settled in something other than ETB) — scale each + // passenger's ETB fare proportionally into the settled currency, rather than showing + // ETB-denominated numbers next to a foreign currency label. + const etbFares = passengers.map((_, idx) => getEtbFare(idx)); + const etbTotal = etbFares.reduce((sum, f) => sum + f, 0); + const needsConversion = settledAmountMinor != null && settledCurrency && settledCurrency !== 'ETB' && etbTotal > 0; + const getVoucherFare = (idx: number): number => { + if (!needsConversion) return etbFares[idx]; + return Math.round(etbFares[idx] * (settledAmountMinor! / etbTotal)); + }; + const outbound = { trainNumber: activeSchedule?.trainNumber || 'N/A', trainName: 'EDR Express', @@ -390,6 +399,11 @@ export default function ConfirmationPage() {

    Total paid

    {(() => { + // The server-confirmed settled amount is authoritative — prefer it over + // any client-side session state, which can go stale (e.g. after a refresh). + if (_booking?.payment?.amountMinor != null) { + return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`; + } if (reviewedTotalMinor != null) return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`; if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`; if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`; diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 92df23f23..8af27b035 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -633,6 +633,20 @@ function BookingDetailContent() {

    + {isConfirmed && ( +
    + Total paid:{' '} + + {booking?.payment?.amountMinor != null + ? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}` + : `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`} + + {booking?.payment?.method && ( + via {booking.payment.method} + )} +
    + )} +
    {isConfirmed && ( <> diff --git a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx index e65882fd2..da369fb54 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx @@ -1,28 +1,99 @@ "use client"; -import { Search } from "lucide-react"; +import { Search, Phone, Ticket, ChevronRight, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { apiClient } from "@/lib/api-client"; +import { format } from "date-fns"; + +type SearchMode = "pnr" | "phone"; + +interface BookingListItem { + id: string; + bookingRef: string; + status: string; + totalMinor: number; + currency: string; + adultCount: number; + childCount: number; + bookingType: string; + createdAt: string; + schedule: { + originStation: { name: string; city?: string }; + destinationStation: { name: string; city?: string }; + departureAt: string; + }; + payment?: { method: string; status: string }; + seatCount: number; +} + +const STATUS_LABELS: Record = { + CONFIRMED: { label: "Confirmed", className: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" }, + PENDING_PAYMENT: { label: "Pending Payment", className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" }, + CANCELLED: { label: "Cancelled", className: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" }, + BOARDED: { label: "Boarded", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" }, + NO_SHOW: { label: "No Show", className: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" }, + REFUNDED: { label: "Refunded", className: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300" }, +}; export default function BookingLookupPage() { const router = useRouter(); + const [mode, setMode] = useState("pnr"); + + // PNR mode state const [bookingRef, setBookingRef] = useState(""); + + // Phone mode state + const [phone, setPhone] = useState(""); + const [phoneResults, setPhoneResults] = useState(null); + const [phoneLoading, setPhoneLoading] = useState(false); + const [error, setError] = useState(""); - const handleSubmit = (e: React.FormEvent) => { + // ── PNR submit ─────────────────────────────────────────────────────────── + const handlePnrSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = bookingRef.trim().toUpperCase(); - if (!trimmed) { - setError("Please enter a booking reference"); - return; - } + if (!trimmed) { setError("Please enter a booking reference"); return; } router.push(`/booking/detail?ref=${trimmed}`); }; + // ── Phone submit ───────────────────────────────────────────────────────── + const handlePhoneSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = phone.trim(); + if (!trimmed) { setError("Please enter your phone number"); return; } + const digits = trimmed.replace(/[^\d]/g, ""); + if (digits.length < 7) { setError("Please enter a valid phone number"); return; } + + setError(""); + setPhoneLoading(true); + setPhoneResults(null); + try { + const resp: any = await apiClient.get(`/bookings/by-phone?phone=${encodeURIComponent(trimmed)}`); + const items: BookingListItem[] = (resp as any)?.data?.items ?? (resp as any)?.items ?? []; + setPhoneResults(items); + if (items.length === 0) setError("No bookings found for this phone number"); + } catch { + setError("Could not look up bookings. Please check your number and try again."); + } finally { + setPhoneLoading(false); + } + }; + + const switchMode = (next: SearchMode) => { + setMode(next); + setError(""); + setPhoneResults(null); + setBookingRef(""); + setPhone(""); + }; + return (
    + {/* Header */}
    @@ -30,39 +101,145 @@ export default function BookingLookupPage() {

    Find Your Booking

    -

    - Enter your booking reference (PNR) to view details +

    + Search by booking reference or phone number

    -
    -
    - - { - setBookingRef(e.target.value.toUpperCase()); - setError(""); - }} - placeholder="Enter your PNR" - className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg font-mono" - /> - {error && ( -

    {error}

    - )} -
    - + {/* Mode tabs */} +
    - + +
    + + {/* ── PNR form ── */} + {mode === "pnr" && ( +
    +
    + + { setBookingRef(e.target.value.toUpperCase()); setError(""); }} + placeholder="e.g. ABCXYZ" + className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg font-mono uppercase tracking-widest" + /> + {error &&

    {error}

    } +
    + +
    + )} + + {/* ── Phone form ── */} + {mode === "phone" && ( + <> +
    +
    + + { setPhone(e.target.value); setError(""); setPhoneResults(null); }} + placeholder="Enter phone number" + className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg" + /> +

    + Enter the phone number you used when booking +

    + {error &&

    {error}

    } +
    + +
    + + {/* Results list */} + {phoneResults !== null && phoneResults.length > 0 && ( +
    +

    + {phoneResults.length} booking{phoneResults.length !== 1 ? "s" : ""} found — select one to view details: +

    + {phoneResults.map((b) => { + const statusInfo = STATUS_LABELS[b.status] ?? { label: b.status, className: "bg-gray-100 text-gray-700" }; + const amountEtb = (b.totalMinor / 100).toLocaleString("en-ET", { minimumFractionDigits: 2 }); + return ( + + ); + })} +
    + )} + + )}
    diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 64f19fdb4..4639bd196 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -465,8 +465,10 @@ function validatePhone(phone: string, nationality: string): string | null { if (!normalized) return 'Phone number is required'; const nat = getPhoneNat(nationality); if (nat === 'ETHIOPIAN') { - if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null; - return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)'; + // Only Ethio Telecom (0/+2519...) and Safaricom Ethiopia (0/+2517...) mobile ranges — + // other prefixes (e.g. landlines, unallocated blocks) are rejected. + if (/^(\+251[79]\d{8}|0[79]\d{8})$/.test(normalized)) return null; + return 'Enter a valid Ethio Telecom or Safaricom Ethiopia number (e.g., +251912345678 or 0712345678)'; } if (nat === 'DJIBOUTIAN') { if (/^\+253\d{8}$/.test(normalized)) return null; diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index ab93c1cf6..8b76721c2 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -313,19 +313,15 @@ export default function ReviewPage() { } // Build booking request for authenticated users - // Package bookings only: free children (first child per adult) don't go through - // seat selection and have no seatId, so they're excluded here — the backend derives - // them from adultCount/childCount instead. Regular bookings DO seat every passenger - // (including the free child, who still gets a real seatId and a $0 fare handled by - // the backend), so they must stay in the array or that passenger — and their - // ticket/seat/childCount — silently never gets created. - const bookingPassengers = passengers.filter((_p, i) => { - if (packageId) { - const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; - return !isFreePkgChild; - } - return true; - }); + // Free children (first child per adult) never go through seat selection and have no + // seatId — true for package bookings AND regular ones (see booking/seats/page.tsx's + // seatEligibility: "First adultCount children are free (no seat)... same rule applies" + // for regular bookings too). Submitting one anyway sends seatId: undefined, which the + // backend's `seat: { connect: { id } }` rejects — hence excluding them here for both + // cases. (Their absence from adultCount/childCount on the confirmed booking is a + // separate, backend-side gap — not something the frontend can paper over by sending + // an unseated passenger.) + const bookingPassengers = passengers.filter((p, i) => !(isChild(p) && isFirstChild(passengers, i))); bookingData = { passengerId: passengerId, @@ -375,19 +371,12 @@ export default function ReviewPage() { if (priceTierId) bookingData.priceTierId = priceTierId; } else { // For guests: send full passenger details array - // Package bookings only: free children (first child per adult) don't go through - // seat selection and have no seatId, so they're excluded here — the backend derives - // them from adultCount/childCount instead. Regular bookings DO seat every passenger - // (including the free child, who still gets a real seatId and a $0 fare handled by - // the backend), so they must stay in the array or that passenger — and their - // ticket/seat/childCount — silently never gets created. - const guestBookingPassengers = passengers.filter((_p, i) => { - if (packageId) { - const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; - return !isFreePkgChild; - } - return true; - }); + // Free children (first child per adult) never go through seat selection and have no + // seatId — true for package bookings AND regular ones (see booking/seats/page.tsx's + // seatEligibility comment). Submitting one anyway sends seatId: undefined, which the + // backend's `seat: { connect: { id } }` rejects — hence excluding them here for both + // cases. + const guestBookingPassengers = passengers.filter((p, i) => !(isChild(p) && isFirstChild(passengers, i))); bookingData = { scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index 7ab72e1a5..7fd0e71f2 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -426,9 +426,18 @@ interface VoucherData { // One ticket per passenger, matched below by passengerName — see bookings.service.ts's // getByRef(). Optional/absent falls back to a client-generated placeholder number. tickets?: Array<{ passengerName?: string; barcodePayload?: string }>; + // The actual settled amount/currency for this booking's payment — preferred over the + // ETB booking total once available, since it reflects what was really charged. + payment?: { amountMinor?: number; currency?: string }; } export const generateVoucherPDF = async (booking: VoucherData): Promise => { + const settledAmountMinor = booking.payment?.amountMinor; + const settledCurrency = booking.payment?.currency; + const useSettledAmount = settledAmountMinor != null && !!settledCurrency; + const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency; + const totalForSplit = useSettledAmount ? settledAmountMinor! : booking.totalMinor; + // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between // them — a setTimeout delay here would push later saves outside the click's synchronous // user-activation window and risk iOS Safari silently blocking them. The awaited work @@ -450,8 +459,8 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => status: booking.status, outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass }, isRoundTrip: false, - fareMinor: Math.round(booking.totalMinor / booking.passengers.length), - currency: booking.currency, + fareMinor: Math.round(totalForSplit / booking.passengers.length), + currency: voucherCurrency, createdAt: booking.createdAt, }); } From eec3d1863a9f3052ca4f69bc9c09333fc6af5cbf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 11 Jul 2026 06:50:33 +0000 Subject: [PATCH 05/46] Cleaned warehouse related UI and made all 15 pages consisitent --- apps/edr-freight-api/src/app.module.ts | 2 + .../src/modules/ai/ai.controller.ts | 31 ++ .../src/modules/ai/ai.module.ts | 11 + .../modules/ai/dto/ai-booking-request.dto.ts | 15 + .../src/modules/ai/mock-ai.service.ts | 277 ++++++++++++++++++ .../ai/types/ai-booking-result.type.ts | 47 +++ apps/edr-freight-web/backoffice/src/App.tsx | 6 + .../src/pages/ai/AiBookingMockTestPage.tsx | 221 ++++++++++++++ .../src/pages/warehouses/ArrivalQueuePage.tsx | 27 +- .../ExportDjiboutiUnloadingQueuePage.tsx | 36 +-- .../warehouses/InterchangeDocumentsPage.tsx | 266 ++++++++--------- .../warehouses/WarehouseDashboardPage.tsx | 97 +++--- .../pages/warehouses/WarehouseRulesPage.tsx | 262 ++++++++--------- .../backoffice/src/services/ai.service.ts | 48 +++ 14 files changed, 985 insertions(+), 361 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/ai/ai.controller.ts create mode 100644 apps/edr-freight-api/src/modules/ai/ai.module.ts create mode 100644 apps/edr-freight-api/src/modules/ai/dto/ai-booking-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/ai/mock-ai.service.ts create mode 100644 apps/edr-freight-api/src/modules/ai/types/ai-booking-result.type.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/ai/AiBookingMockTestPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/ai.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index afb214137..224a75ada 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -93,6 +93,7 @@ import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; +import { AiModule } from "./modules/ai/ai.module"; import { LoggerMiddleware } from "./logger.middleware"; @Module({ @@ -188,6 +189,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ImportOperationsModule, VerifaydaModule, FleetHistoryModule, + AiModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/modules/ai/ai.controller.ts b/apps/edr-freight-api/src/modules/ai/ai.controller.ts new file mode 100644 index 000000000..eb87fe97d --- /dev/null +++ b/apps/edr-freight-api/src/modules/ai/ai.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Public } from '@edr/api-common'; + +import { AiBookingRequestDto } from './dto/ai-booking-request.dto'; +import { AiBookingResult } from './types/ai-booking-result.type'; +import { MockAiService } from './mock-ai.service'; + +// @Public() — TODO: swap for real guard when this leaves dev/testing. +// Safe while public: extracts + validates text only, never creates or +// dispatches anything. +@Public() +@ApiTags('AI Assistant (mock)') +@Controller('ai') +export class AiController { + constructor(private readonly mockAiService: MockAiService) {} + + @Post('booking/extract') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + 'Mock AI: extract structured booking fields from free-text request', + }) + @ApiOkResponse({ + description: + 'Extracted fields, validation result, and next-step recommendation', + }) + extractBooking(@Body() dto: AiBookingRequestDto): AiBookingResult { + return this.mockAiService.extractBooking(dto.text); + } +} diff --git a/apps/edr-freight-api/src/modules/ai/ai.module.ts b/apps/edr-freight-api/src/modules/ai/ai.module.ts new file mode 100644 index 000000000..e274d90b6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/ai/ai.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { AiController } from './ai.controller'; +import { MockAiService } from './mock-ai.service'; + +@Module({ + controllers: [AiController], + providers: [MockAiService], + exports: [MockAiService], +}) +export class AiModule {} diff --git a/apps/edr-freight-api/src/modules/ai/dto/ai-booking-request.dto.ts b/apps/edr-freight-api/src/modules/ai/dto/ai-booking-request.dto.ts new file mode 100644 index 000000000..7ec64e310 --- /dev/null +++ b/apps/edr-freight-api/src/modules/ai/dto/ai-booking-request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class AiBookingRequestDto { + @ApiProperty({ + description: 'Free-text customer booking request to extract fields from', + example: + 'Book 2x40ft containers from Djibouti to Indode. Cargo electronics. Customer ABC Logistics.', + minLength: 5, + }) + @IsString() + @IsNotEmpty({ message: 'text must not be empty' }) + @MinLength(5, { message: 'text must be at least 5 characters' }) + text!: string; +} diff --git a/apps/edr-freight-api/src/modules/ai/mock-ai.service.ts b/apps/edr-freight-api/src/modules/ai/mock-ai.service.ts new file mode 100644 index 000000000..7883bf9ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/ai/mock-ai.service.ts @@ -0,0 +1,277 @@ +import { Injectable } from '@nestjs/common'; + +import { + AiBookingResult, + AiContainerType, + AiDirection, + AiExtractedBooking, + AiRecommendation, + AiValidationResult, +} from './types/ai-booking-result.type'; + +/** + * Deterministic keyword/regex "AI" for the booking assistant workflow. + * No external AI calls — this class is the single seam to swap for a real + * provider later (OllamaAiService / ClaudeAiService / OpenAiService): keep + * the `extractBooking(text): AiBookingResult` contract and replace the body. + */ + +const KNOWN_LOCATIONS = [ + 'Djibouti', + 'Indode', + 'Modjo', + 'Adama', + 'Dire Dawa', + 'Addis Ababa', +] as const; + +const INLAND_LOCATIONS = new Set([ + 'Indode', + 'Modjo', + 'Adama', + 'Dire Dawa', + 'Addis Ababa', +]); + +// Longest names first so "Dire Dawa" wins before a shorter partial could. +const LOCATION_ALTERNATION = [...KNOWN_LOCATIONS] + .sort((a, b) => b.length - a.length) + .map((name) => name.replace(/\s+/g, '\\s+')) + .join('|'); + +// Checked in order; first hit wins, so specific cargo words beat the +// generic "refrigerated" fallback. +const CARGO_KEYWORDS: ReadonlyArray = [ + [/\belectronics\b/i, 'electronics'], + [/\bcoffee\b/i, 'coffee'], + [/\bwheat\b/i, 'wheat'], + [/\bfertilizers?\b/i, 'fertilizer'], + [/\bchemicals?\b/i, 'chemical'], + [/\bmachinery\b/i, 'machinery'], + [/\bmedicines?\b/i, 'medicine'], + [/\bsesame\b/i, 'sesame'], + [/\b(?:vehicles?|cars?)\b/i, 'vehicles'], + [/\brefrigerated\b/i, 'refrigerated cargo'], +]; + +const WORD_NUMBERS: Record = { + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + ten: 10, +}; + +// A capitalized-word run: "ABC Logistics", "Auto Import PLC", "Ethio Coffee +// Export". Stops at the first lowercase word ("wants", "needs", …). +const NAME_CAPTURE = String.raw`([A-Z][A-Za-z0-9&.'-]*(?:\s+[A-Z][A-Za-z0-9&.'-]*)*)`; + +// No `i` flag: the capture relies on case ([A-Z] word starts) to know where +// the company name ends ("Customer ABC Logistics wants…" → "ABC Logistics"). +const CUSTOMER_PATTERNS: ReadonlyArray = [ + new RegExp(String.raw`\b[Cc]ustomer(?:\s+is)?\s*:?\s+${NAME_CAPTURE}`), + new RegExp(String.raw`\b[Ff]or\s+${NAME_CAPTURE}`), +]; + +const RECOMMEND_CREATE: AiRecommendation = { + action: 'CREATE_DRAFT_BOOKING', + message: + 'Booking data looks complete. User can review and create a draft booking.', + confidence: 0.85, +}; + +const RECOMMEND_MISSING: AiRecommendation = { + action: 'REQUEST_MISSING_INFORMATION', + message: + 'Some required booking information is missing. Ask the customer for the missing fields before creating a draft booking.', + confidence: 0.45, +}; + +@Injectable() +export class MockAiService { + extractBooking(text: string): AiBookingResult { + const input = text.trim(); + + const { origin, destination } = this.extractRoute(input); + + const extracted: AiExtractedBooking = { + customerName: this.extractCustomerName(input), + origin, + destination, + cargoType: this.extractCargoType(input), + containerType: this.extractContainerType(input), + quantity: this.extractQuantity(input), + direction: this.resolveDirection(origin, destination), + weightKg: this.extractWeightKg(input), + pickupRequired: this.extractFlag(input, 'pickup'), + deliveryRequired: this.extractFlag(input, 'delivery'), + }; + + const validation = this.validate(extracted); + + return { + provider: 'mock', + extracted, + validation, + recommendation: validation.valid ? RECOMMEND_CREATE : RECOMMEND_MISSING, + }; + } + + private extractCustomerName(text: string): string | null { + for (const pattern of CUSTOMER_PATTERNS) { + const match = text.match(pattern); + if (match?.[1]) { + const name = match[1].replace(/[.,;:!?]+$/, '').trim(); + if (name) return name; + } + } + return null; + } + + private extractRoute(text: string): { + origin: string | null; + destination: string | null; + } { + const fromMatch = text.match( + new RegExp(String.raw`\bfrom\s+(${LOCATION_ALTERNATION})\b`, 'i'), + ); + const toMatch = text.match( + new RegExp(String.raw`\bto\s+(${LOCATION_ALTERNATION})\b`, 'i'), + ); + + let origin = fromMatch ? this.canonicalLocation(fromMatch[1]) : null; + let destination = toMatch ? this.canonicalLocation(toMatch[1]) : null; + + if (!origin || !destination) { + // Fall back to order of appearance ("Djibouti to Indode" without + // "from", or a bare location mention). + const mentions: string[] = []; + const all = text.matchAll( + new RegExp(String.raw`\b(${LOCATION_ALTERNATION})\b`, 'gi'), + ); + for (const m of all) { + const canonical = this.canonicalLocation(m[1]); + if (canonical && !mentions.includes(canonical)) mentions.push(canonical); + } + + if (!origin && !destination) { + origin = mentions[0] ?? null; + destination = mentions[1] ?? null; + } else if (!origin) { + origin = mentions.find((loc) => loc !== destination) ?? null; + } else { + destination = mentions.find((loc) => loc !== origin) ?? null; + } + } + + return { origin, destination }; + } + + private canonicalLocation(raw: string): string | null { + const normalized = raw.replace(/\s+/g, ' ').toLowerCase(); + return ( + KNOWN_LOCATIONS.find((loc) => loc.toLowerCase() === normalized) ?? null + ); + } + + private resolveDirection( + origin: string | null, + destination: string | null, + ): AiDirection | null { + if (!origin || !destination) return null; + if (origin === 'Djibouti' && INLAND_LOCATIONS.has(destination)) { + return 'IMPORT'; + } + if (INLAND_LOCATIONS.has(origin) && destination === 'Djibouti') { + return 'EXPORT'; + } + return null; + } + + private extractCargoType(text: string): string | null { + for (const [pattern, cargo] of CARGO_KEYWORDS) { + if (pattern.test(text)) return cargo; + } + return null; + } + + private extractContainerType(text: string): AiContainerType | null { + // Lookbehind instead of \b: "2x40ft" has no word boundary before "40", + // but "140ft" must not read as a 40ft container. + if (/(? { /> }> } /> + {/* Dev/testing page for the mock AI booking assistant. */} + } + /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/ai/AiBookingMockTestPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ai/AiBookingMockTestPage.tsx new file mode 100644 index 000000000..bd9f1d968 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/ai/AiBookingMockTestPage.tsx @@ -0,0 +1,221 @@ +import { useState } from "react"; +import { + Alert, + Badge, + Button, + Card, + Code, + Group, + Stack, + Table, + Text, + Textarea, + Title, +} from "@mantine/core"; +import axios from "axios"; + +import { + AiBookingExtractResult, + extractBookingFromText, +} from "@/services/ai.service"; + +const EXAMPLE_TEXT = + "Book 2x40ft containers from Djibouti to Indode. Cargo electronics. Customer ABC Logistics."; + +const formatValue = (value: string | number | boolean | null): string => { + if (value === null) return "—"; + if (typeof value === "boolean") return value ? "Yes" : "No"; + return String(value); +}; + +const EXTRACTED_FIELD_LABELS: Array<{ + key: keyof AiBookingExtractResult["extracted"]; + label: string; +}> = [ + { key: "customerName", label: "Customer Name" }, + { key: "origin", label: "Origin" }, + { key: "destination", label: "Destination" }, + { key: "cargoType", label: "Cargo Type" }, + { key: "containerType", label: "Container Type" }, + { key: "quantity", label: "Quantity" }, + { key: "direction", label: "Direction" }, + { key: "weightKg", label: "Weight (kg)" }, + { key: "pickupRequired", label: "Pickup Required" }, + { key: "deliveryRequired", label: "Delivery Required" }, +]; + +export default function AiBookingMockTestPage() { + const [text, setText] = useState(""); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [showRawJson, setShowRawJson] = useState(false); + + const handleTest = async () => { + setLoading(true); + setError(null); + setResult(null); + try { + setResult(await extractBookingFromText(text)); + } catch (err) { + const backendMessage = axios.isAxiosError(err) + ? (err.response?.data as { message?: string | string[] } | undefined) + ?.message + : null; + setError( + backendMessage + ? `Mock AI request failed: ${ + Array.isArray(backendMessage) + ? backendMessage.join(", ") + : backendMessage + }` + : "Mock AI request failed", + ); + } finally { + setLoading(false); + } + }; + + const handleCreateDraftBooking = () => { + window.alert("Draft booking creation will be connected in the next step."); + }; + + const canCreateDraft = Boolean(result?.validation.valid); + + return ( + + Mock AI Booking Assistant + + + + ",E.noCloneChecked=!!t.cloneNode(!0).lastChild.defaultValue,t.innerHTML="",E.option=!!t.lastChild})();var We={thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};We.tbody=We.tfoot=We.colgroup=We.caption=We.thead,We.th=We.td,E.option||(We.optgroup=We.option=[1,""]);function Ie(e,t){var i;return typeof e.getElementsByTagName<"u"?i=e.getElementsByTagName(t||"*"):typeof e.querySelectorAll<"u"?i=e.querySelectorAll(t||"*"):i=[],t===void 0||t&&ae(e,t)?u.merge([e],i):i}function Kt(e,t){for(var i=0,l=e.length;i-1){f&&f.push(h);continue}if(w=pt(h),x=Ie(I.appendChild(h),"script"),w&&Kt(x),i)for(R=0;h=x[R++];)br.test(h.type||"")&&i.push(h)}return I}var Dr=/^([^.]*)(?:\.(.+)|)/;function xt(){return!0}function gt(){return!1}function Qt(e,t,i,l,f,h){var x,b;if(typeof t=="object"){typeof i!="string"&&(l=l||i,i=void 0);for(b in t)Qt(e,b,i,l,t[b],h);return e}if(l==null&&f==null?(f=i,l=i=void 0):f==null&&(typeof i=="string"?(f=l,l=void 0):(f=l,l=i,i=void 0)),f===!1)f=gt;else if(!f)return e;return h===1&&(x=f,f=function(v){return u().off(v),x.apply(this,arguments)},f.guid=x.guid||(x.guid=u.guid++)),e.each(function(){u.event.add(this,t,f,l,i)})}u.event={global:{},add:function(e,t,i,l,f){var h,x,b,v,w,R,I,C,F,ne,de,oe=V.get(e);if(Be(e))for(i.handler&&(h=i,i=h.handler,f=h.selector),f&&u.find.matchesSelector(ut,f),i.guid||(i.guid=u.guid++),(v=oe.events)||(v=oe.events=Object.create(null)),(x=oe.handle)||(x=oe.handle=function(Se){return typeof u<"u"&&u.event.triggered!==Se.type?u.event.dispatch.apply(e,arguments):void 0}),t=(t||"").match(Ne)||[""],w=t.length;w--;)b=Dr.exec(t[w])||[],F=de=b[1],ne=(b[2]||"").split(".").sort(),F&&(I=u.event.special[F]||{},F=(f?I.delegateType:I.bindType)||F,I=u.event.special[F]||{},R=u.extend({type:F,origType:de,data:l,handler:i,guid:i.guid,selector:f,needsContext:f&&u.expr.match.needsContext.test(f),namespace:ne.join(".")},h),(C=v[F])||(C=v[F]=[],C.delegateCount=0,(!I.setup||I.setup.call(e,l,ne,x)===!1)&&e.addEventListener&&e.addEventListener(F,x)),I.add&&(I.add.call(e,R),R.handler.guid||(R.handler.guid=i.guid)),f?C.splice(C.delegateCount++,0,R):C.push(R),u.event.global[F]=!0)},remove:function(e,t,i,l,f){var h,x,b,v,w,R,I,C,F,ne,de,oe=V.hasData(e)&&V.get(e);if(!(!oe||!(v=oe.events))){for(t=(t||"").match(Ne)||[""],w=t.length;w--;){if(b=Dr.exec(t[w])||[],F=de=b[1],ne=(b[2]||"").split(".").sort(),!F){for(F in v)u.event.remove(e,F+t[w],i,l,!0);continue}for(I=u.event.special[F]||{},F=(l?I.delegateType:I.bindType)||F,C=v[F]||[],b=b[2]&&new RegExp("(^|\\.)"+ne.join("\\.(?:.*\\.|)")+"(\\.|$)"),x=h=C.length;h--;)R=C[h],(f||de===R.origType)&&(!i||i.guid===R.guid)&&(!b||b.test(R.namespace))&&(!l||l===R.selector||l==="**"&&R.selector)&&(C.splice(h,1),R.selector&&C.delegateCount--,I.remove&&I.remove.call(e,R));x&&!C.length&&((!I.teardown||I.teardown.call(e,ne,oe.handle)===!1)&&u.removeEvent(e,F,oe.handle),delete v[F])}u.isEmptyObject(v)&&V.remove(e,"handle events")}},dispatch:function(e){var t,i,l,f,h,x,b=new Array(arguments.length),v=u.event.fix(e),w=(V.get(this,"events")||Object.create(null))[v.type]||[],R=u.event.special[v.type]||{};for(b[0]=v,t=1;t=1)){for(;w!==this;w=w.parentNode||this)if(w.nodeType===1&&!(e.type==="click"&&w.disabled===!0)){for(h=[],x={},i=0;i-1:u.find(f,this,null,[w]).length),x[f]&&h.push(l);h.length&&b.push({elem:w,handlers:h})}}return w=this,v\s*$/g;function kr(e,t){return ae(e,"table")&&ae(t.nodeType!==11?t:t.firstChild,"tr")&&u(e).children("tbody")[0]||e}function nn(e){return e.type=(e.getAttribute("type")!==null)+"/"+e.type,e}function an(e){return(e.type||"").slice(0,5)==="true/"?e.type=e.type.slice(5):e.removeAttribute("type"),e}function wr(e,t){var i,l,f,h,x,b,v;if(t.nodeType===1){if(V.hasData(e)&&(h=V.get(e),v=h.events,v)){V.remove(t,"handle events");for(f in v)for(i=0,l=v[f].length;i1&&typeof F=="string"&&!E.checkClone&&tn.test(F))return e.each(function(de){var oe=e.eq(de);ne&&(t[0]=F.call(this,de,oe.html())),yt(oe,t,i,l)});if(I&&(f=jr(t,e[0].ownerDocument,!1,e,l),h=f.firstChild,f.childNodes.length===1&&(f=h),h||l)){for(x=u?.map(Ie(f,"script"),nn),b=x.length;R0&&Kt(x,!v&&Ie(e,"script")),b},cleanData:function(e){for(var t,i,l,f=u.event.special,h=0;(i=e[h])!==void 0;h++)if(Be(i)){if(t=i[V.expando]){if(t.events)for(l in t.events)f[l]?u.event.remove(i,l):u.removeEvent(i,l,t.handle);i[V.expando]=void 0}i[_e.expando]&&(i[_e.expando]=void 0)}}}),u.fn.extend({detach:function(e){return Er(this,e,!0)},remove:function(e){return Er(this,e)},text:function(e){return xe(this,function(t){return t===void 0?u.text(this):this.empty().each(function(){(this.nodeType===1||this.nodeType===11||this.nodeType===9)&&(this.textContent=t)})},null,e,arguments.length)},append:function(){return yt(this,arguments,function(e){if(this.nodeType===1||this.nodeType===11||this.nodeType===9){var t=kr(this,e);t.appendChild(e)}})},prepend:function(){return yt(this,arguments,function(e){if(this.nodeType===1||this.nodeType===11||this.nodeType===9){var t=kr(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return yt(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return yt(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;(e=this[t])!=null;t++)e.nodeType===1&&(u.cleanData(Ie(e,!1)),e.textContent="");return this},clone:function(e,t){return e=e??!1,t=t??e,this?.map(function(){return u.clone(this,e,t)})},html:function(e){return xe(this,function(t){var i=this[0]||{},l=0,f=this.length;if(t===void 0&&i.nodeType===1)return i.innerHTML;if(typeof t=="string"&&!en.test(t)&&!We[(vr.exec(t)||["",""])[1].toLowerCase()]){t=u.htmlPrefilter(t);try{for(;l=0&&(v+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-h-v-b-.5))||0),v+w}function Or(e,t,i){var l=Ht(e),f=!E.boxSizingReliable()||i,h=f&&u.css(e,"boxSizing",!1,l)==="border-box",x=h,b=St(e,t,l),v="offset"+t[0].toUpperCase()+t.slice(1);if(Gt.test(b)){if(!i)return b;b="auto"}return(!E.boxSizingReliable()&&h||!E.reliableTrDimensions()&&ae(e,"tr")||b==="auto"||!parseFloat(b)&&u.css(e,"display",!1,l)==="inline")&&e.getClientRects().length&&(h=u.css(e,"boxSizing",!1,l)==="border-box",x=v in e,x&&(b=e[v])),b=parseFloat(b)||0,b+Zt(e,t,i||(h?"border":"content"),x,l,b)+"px"}u.extend({cssHooks:{opacity:{get:function(e,t){if(t){var i=St(e,"opacity");return i===""?"1":i}}}},cssNumber:{animationIterationCount:!0,aspectRatio:!0,borderImageSlice:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,scale:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeMiterlimit:!0,strokeOpacity:!0},cssProps:{},style:function(e,t,i,l){if(!(!e||e.nodeType===3||e.nodeType===8||!e.style)){var f,h,x,b=je(t),v=Xt.test(t),w=e.style;if(v||(t=$t(b)),x=u.cssHooks[t]||u.cssHooks[b],i!==void 0){if(h=typeof i,h==="string"&&(f=Nt.exec(i))&&f[1]&&(i=gr(e,t,f),h="number"),i==null||i!==i)return;h==="number"&&!v&&(i+=f&&f[3]||(u.cssNumber[b]?"":"px")),!E.clearCloneStyle&&i===""&&t.indexOf("background")===0&&(w[t]="inherit"),(!x||!("set"in x)||(i=x.set(e,i,l))!==void 0)&&(v?w.setProperty(t,i):w[t]=i)}else return x&&"get"in x&&(f=x.get(e,!1,l))!==void 0?f:w[t]}},css:function(e,t,i,l){var f,h,x,b=je(t),v=Xt.test(t);return v||(t=$t(b)),x=u.cssHooks[t]||u.cssHooks[b],x&&"get"in x&&(f=x.get(e,!0,i)),f===void 0&&(f=St(e,t,l)),f==="normal"&&t in Mr&&(f=Mr[t]),i===""||i?(h=parseFloat(f),i===!0||isFinite(h)?h||0:f):f}}),u.each(["height","width"],function(e,t){u.cssHooks[t]={get:function(i,l,f){if(l)return un.test(u.css(i,"display"))&&(!i.getClientRects().length||!i.getBoundingClientRect().width)?Nr(i,dn,function(){return Or(i,t,f)}):Or(i,t,f)},set:function(i,l,f){var h,x=Ht(i),b=!E.scrollboxSize()&&x.position==="absolute",v=b||f,w=v&&u.css(i,"boxSizing",!1,x)==="border-box",R=f?Zt(i,t,f,w,x):0;return w&&b&&(R-=Math.ceil(i["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(x[t])-Zt(i,t,"border",!1,x)-.5)),R&&(h=Nt.exec(l))&&(h[3]||"px")!=="px"&&(i.style[t]=l,l=u.css(i,t)),Tr(i,l,R)}}}),u.cssHooks.marginLeft=Cr(E.reliableMarginLeft,function(e,t){if(t)return(parseFloat(St(e,"marginLeft"))||e.getBoundingClientRect().left-Nr(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),u.each({margin:"",padding:"",border:"Width"},function(e,t){u.cssHooks[e+t]={expand:function(i){for(var l=0,f={},h=typeof i=="string"?i.split(" "):[i];l<4;l++)f[e+Ze[l]+t]=h[l]||h[l-2]||h[0];return f}},e!=="margin"&&(u.cssHooks[e+t].set=Tr)}),u.fn.extend({css:function(e,t){return xe(this,function(i,l,f){var h,x,b={},v=0;if(Array.isArray(l)){for(h=Ht(i),x=l.length;v1)}});function Ae(e,t,i,l,f){return new Ae.prototype.init(e,t,i,l,f)}u.Tween=Ae,Ae.prototype={constructor:Ae,init:function(e,t,i,l,f,h){this.elem=e,this.prop=i,this.easing=f||u.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=l,this.unit=h||(u.cssNumber[i]?"":"px")},cur:function(){var e=Ae.propHooks[this.prop];return e&&e.get?e.get(this):Ae.propHooks._default.get(this)},run:function(e){var t,i=Ae.propHooks[this.prop];return this.options.duration?this.pos=t=u.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),i&&i.set?i.set(this):Ae.propHooks._default.set(this),this}},Ae.prototype.init.prototype=Ae.prototype,Ae.propHooks={_default:{get:function(e){var t;return e.elem.nodeType!==1||e.elem[e.prop]!=null&&e.elem.style[e.prop]==null?e.elem[e.prop]:(t=u.css(e.elem,e.prop,""),!t||t==="auto"?0:t)},set:function(e){u.fx.step[e.prop]?u.fx.step[e.prop](e):e.elem.nodeType===1&&(u.cssHooks[e.prop]||e.elem.style[$t(e.prop)]!=null)?u.style(e.elem,e.prop,e.now+e.unit):e.elem[e.prop]=e.now}}},Ae.propHooks.scrollTop=Ae.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},u.easing={linear:function(e){return e},swing:function(e){return .5-Math.cos(e*Math.PI)/2},_default:"swing"},u.fx=Ae.prototype.init,u.fx.step={};var vt,Yt,fn=/^(?:toggle|show|hide)$/,hn=/queueHooks$/;function er(){Yt&&(A.hidden===!1&&o.requestAnimationFrame?o.requestAnimationFrame(er):o.setTimeout(er,u.fx.interval),u.fx.tick())}function Lr(){return o.setTimeout(function(){vt=void 0}),vt=Date.now()}function Bt(e,t){var i,l=0,f={height:e};for(t=t?1:0;l<4;l+=2-t)i=Ze[l],f["margin"+i]=f["padding"+i]=e;return t&&(f.opacity=f.width=e),f}function Ir(e,t,i){for(var l,f=(qe.tweeners[t]||[]).concat(qe.tweeners["*"]),h=0,x=f.length;h1)},removeAttr:function(e){return this.each(function(){u.removeAttr(this,e)})}}),u.extend({attr:function(e,t,i){var l,f,h=e.nodeType;if(!(h===3||h===8||h===2)){if(typeof e.getAttribute>"u")return u.prop(e,t,i);if((h!==1||!u.isXMLDoc(e))&&(f=u.attrHooks[t.toLowerCase()]||(u.expr.match.bool.test(t)?Ar:void 0)),i!==void 0){if(i===null){u.removeAttr(e,t);return}return f&&"set"in f&&(l=f.set(e,i,t))!==void 0?l:(e.setAttribute(t,i+""),i)}return f&&"get"in f&&(l=f.get(e,t))!==null?l:(l=u.find.attr(e,t),l??void 0)}},attrHooks:{type:{set:function(e,t){if(!E.radioValue&&t==="radio"&&ae(e,"input")){var i=e.value;return e.setAttribute("type",t),i&&(e.value=i),t}}}},removeAttr:function(e,t){var i,l=0,f=t&&t.match(Ne);if(f&&e.nodeType===1)for(;i=f[l++];)e.removeAttribute(i)}}),Ar={set:function(e,t,i){return t===!1?u.removeAttr(e,i):e.setAttribute(i,i),i}},u.each(u.expr.match.bool.source.match(/\w+/g),function(e,t){var i=_t[t]||u.find.attr;_t[t]=function(l,f,h){var x,b,v=f.toLowerCase();return h||(b=_t[v],_t[v]=x,x=i(l,f,h)!=null?v:null,_t[v]=b),x}});var xn=/^(?:input|select|textarea|button)$/i,gn=/^(?:a|area)$/i;u.fn.extend({prop:function(e,t){return xe(this,u.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[u.propFix[e]||e]})}}),u.extend({prop:function(e,t,i){var l,f,h=e.nodeType;if(!(h===3||h===8||h===2))return(h!==1||!u.isXMLDoc(e))&&(t=u.propFix[t]||t,f=u.propHooks[t]),i!==void 0?f&&"set"in f&&(l=f.set(e,i,t))!==void 0?l:e[t]=i:f&&"get"in f&&(l=f.get(e,t))!==null?l:e[t]},propHooks:{tabIndex:{get:function(e){var t=u.find.attr(e,"tabindex");return t?parseInt(t,10):xn.test(e.nodeName)||gn.test(e.nodeName)&&e.href?0:-1}}},propFix:{for:"htmlFor",class:"className"}}),E.optSelected||(u.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),u.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){u.propFix[this.toLowerCase()]=this});function ct(e){var t=e.match(Ne)||[];return t.join(" ")}function dt(e){return e.getAttribute&&e.getAttribute("class")||""}function tr(e){return Array.isArray(e)?e:typeof e=="string"?e.match(Ne)||[]:[]}u.fn.extend({addClass:function(e){var t,i,l,f,h,x;return M(e)?this.each(function(b){u(this).addClass(e.call(this,b,dt(this)))}):(t=tr(e),t.length?this.each(function(){if(l=dt(this),i=this.nodeType===1&&" "+ct(l)+" ",i){for(h=0;h-1;)i=i.replace(" "+f+" "," ");x=ct(i),l!==x&&this.setAttribute("class",x)}}):this):this.attr("class","")},toggleClass:function(e,t){var i,l,f,h,x=typeof e,b=x==="string"||Array.isArray(e);return M(e)?this.each(function(v){u(this).toggleClass(e.call(this,v,dt(this),t),t)}):typeof t=="boolean"&&b?t?this.addClass(e):this.removeClass(e):(i=tr(e),this.each(function(){if(b)for(h=u(this),f=0;f-1)return!0;return!1}});var yn=/\r/g;u.fn.extend({val:function(e){var t,i,l,f=this[0];return arguments.length?(l=M(e),this.each(function(h){var x;this.nodeType===1&&(l?x=e.call(this,h,u(this).val()):x=e,x==null?x="":typeof x=="number"?x+="":Array.isArray(x)&&(x=u?.map(x,function(b){return b==null?"":b+""})),t=u.valHooks[this.type]||u.valHooks[this.nodeName.toLowerCase()],(!t||!("set"in t)||t.set(this,x,"value")===void 0)&&(this.value=x))})):f?(t=u.valHooks[f.type]||u.valHooks[f.nodeName.toLowerCase()],t&&"get"in t&&(i=t.get(f,"value"))!==void 0?i:(i=f.value,typeof i=="string"?i.replace(yn,""):i??"")):void 0}}),u.extend({valHooks:{option:{get:function(e){var t=u.find.attr(e,"value");return t??ct(u.text(e))}},select:{get:function(e){var t,i,l,f=e.options,h=e.selectedIndex,x=e.type==="select-one",b=x?null:[],v=x?h+1:f.length;for(h<0?l=v:l=x?h:0;l-1)&&(i=!0);return i||(e.selectedIndex=-1),h}}}}),u.each(["radio","checkbox"],function(){u.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=u.inArray(u(e).val(),t)>-1}},E.checkOn||(u.valHooks[this].get=function(e){return e.getAttribute("value")===null?"on":e.value})});var Rt=o.location,Pr={guid:Date.now()},rr=/\?/;u.parseXML=function(e){var t,i;if(!e||typeof e!="string")return null;try{t=new o.DOMParser().parseFromString(e,"text/xml")}catch{}return i=t&&t.getElementsByTagName("parsererror")[0],(!t||i)&&u.error("Invalid XML: "+(i?u?.map(i.childNodes,function(l){return l.textContent}).join(` -`):e)),t};var Fr=/^(?:focusinfocus|focusoutblur)$/,Wr=function(e){e.stopPropagation()};u.extend(u.event,{trigger:function(e,t,i,l){var f,h,x,b,v,w,R,I,C=[i||A],F=k.call(e,"type")?e.type:e,ne=k.call(e,"namespace")?e.namespace.split("."):[];if(h=I=x=i=i||A,!(i.nodeType===3||i.nodeType===8)&&!Fr.test(F+u.event.triggered)&&(F.indexOf(".")>-1&&(ne=F.split("."),F=ne.shift(),ne.sort()),v=F.indexOf(":")<0&&"on"+F,e=e[u.expando]?e:new u.Event(F,typeof e=="object"&&e),e.isTrigger=l?2:3,e.namespace=ne.join("."),e.rnamespace=e.namespace?new RegExp("(^|\\.)"+ne.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,e.result=void 0,e.target||(e.target=i),t=t==null?[e]:u.makeArray(t,[e]),R=u.event.special[F]||{},!(!l&&R.trigger&&R.trigger.apply(i,t)===!1))){if(!l&&!R.noBubble&&!P(i)){for(b=R.delegateType||F,Fr.test(b+F)||(h=h.parentNode);h;h=h.parentNode)C.push(h),x=h;x===(i.ownerDocument||A)&&C.push(x.defaultView||x.parentWindow||o)}for(f=0;(h=C[f++])&&!e.isPropagationStopped();)I=h,e.type=f>1?b:R.bindType||F,w=(V.get(h,"events")||Object.create(null))[e.type]&&V.get(h,"handle"),w&&w.apply(h,t),w=v&&h[v],w&&w.apply&&Be(h)&&(e.result=w.apply(h,t),e.result===!1&&e.preventDefault());return e.type=F,!l&&!e.isDefaultPrevented()&&(!R._default||R._default.apply(C.pop(),t)===!1)&&Be(i)&&v&&M(i[F])&&!P(i)&&(x=i[v],x&&(i[v]=null),u.event.triggered=F,e.isPropagationStopped()&&I.addEventListener(F,Wr),i[F](),e.isPropagationStopped()&&I.removeEventListener(F,Wr),u.event.triggered=void 0,x&&(i[v]=x)),e.result}},simulate:function(e,t,i){var l=u.extend(new u.Event,i,{type:e,isSimulated:!0});u.event.trigger(l,null,t)}}),u.fn.extend({trigger:function(e,t){return this.each(function(){u.event.trigger(e,t,this)})},triggerHandler:function(e,t){var i=this[0];if(i)return u.event.trigger(e,t,i,!0)}});var vn=/\[\]$/,Hr=/\r?\n/g,bn=/^(?:submit|button|image|reset|file)$/i,jn=/^(?:input|select|textarea|keygen)/i;function nr(e,t,i,l){var f;if(Array.isArray(t))u.each(t,function(h,x){i||vn.test(e)?l(e,x):nr(e+"["+(typeof x=="object"&&x!=null?h:"")+"]",x,i,l)});else if(!i&&se(t)==="object")for(f in t)nr(e+"["+f+"]",t[f],i,l);else l(e,t)}u.param=function(e,t){var i,l=[],f=function(h,x){var b=M(x)?x():x;l[l.length]=encodeURIComponent(h)+"="+encodeURIComponent(b??"")};if(e==null)return"";if(Array.isArray(e)||e.jquery&&!u.isPlainObject(e))u.each(e,function(){f(this.name,this.value)});else for(i in e)nr(i,e[i],t,f);return l.join("&")},u.fn.extend({serialize:function(){return u.param(this.serializeArray())},serializeArray:function(){return this?.map(function(){var e=u.prop(this,"elements");return e?u.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!u(this).is(":disabled")&&jn.test(this.nodeName)&&!bn.test(e)&&(this.checked||!Ct.test(e))})?.map(function(e,t){var i=u(this).val();return i==null?null:Array.isArray(i)?u?.map(i,function(l){return{name:t.name,value:l.replace(Hr,`\r -`)}}):{name:t.name,value:i.replace(Hr,`\r -`)}}).get()}});var Dn=/%20/g,kn=/#.*$/,wn=/([?&])_=[^&]*/,En=/^(.*?):[ \t]*([^\r\n]*)$/mg,Nn=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Cn=/^(?:GET|HEAD)$/,Sn=/^\/\//,Yr={},ar={},Br="*/".concat("*"),ir=A.createElement("a");ir.href=Rt.href;function qr(e){return function(t,i){typeof t!="string"&&(i=t,t="*");var l,f=0,h=t.toLowerCase().match(Ne)||[];if(M(i))for(;l=h[f++];)l[0]==="+"?(l=l.slice(1)||"*",(e[l]=e[l]||[]).unshift(i)):(e[l]=e[l]||[]).push(i)}}function Jr(e,t,i,l){var f={},h=e===ar;function x(b){var v;return f[b]=!0,u.each(e[b]||[],function(w,R){var I=R(t,i,l);if(typeof I=="string"&&!h&&!f[I])return t.dataTypes.unshift(I),x(I),!1;if(h)return!(v=I)}),v}return x(t.dataTypes[0])||!f["*"]&&x("*")}function sr(e,t){var i,l,f=u.ajaxSettings.flatOptions||{};for(i in t)t[i]!==void 0&&((f[i]?e:l||(l={}))[i]=t[i]);return l&&u.extend(!0,e,l),e}function _n(e,t,i){for(var l,f,h,x,b=e.contents,v=e.dataTypes;v[0]==="*";)v.shift(),l===void 0&&(l=e.mimeType||t.getResponseHeader("Content-Type"));if(l){for(f in b)if(b[f]&&b[f].test(l)){v.unshift(f);break}}if(v[0]in i)h=v[0];else{for(f in i){if(!v[0]||e.converters[f+" "+v[0]]){h=f;break}x||(x=f)}h=h||x}if(h)return h!==v[0]&&v.unshift(h),i[h]}function Rn(e,t,i,l){var f,h,x,b,v,w={},R=e.dataTypes.slice();if(R[1])for(x in e.converters)w[x.toLowerCase()]=e.converters[x];for(h=R.shift();h;)if(e.responseFields[h]&&(i[e.responseFields[h]]=t),!v&&l&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),v=h,h=R.shift(),h){if(h==="*")h=v;else if(v!=="*"&&v!==h){if(x=w[v+" "+h]||w["* "+h],!x){for(f in w)if(b=f.split(" "),b[1]===h&&(x=w[v+" "+b[0]]||w["* "+b[0]],x)){x===!0?x=w[f]:w[f]!==!0&&(h=b[0],R.unshift(b[1]));break}}if(x!==!0)if(x&&e.throws)t=x(t);else try{t=x(t)}catch(I){return{state:"parsererror",error:x?I:"No conversion from "+v+" to "+h}}}}return{state:"success",data:t}}u.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Rt.href,type:"GET",isLocal:Nn.test(Rt.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Br,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":u.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?sr(sr(e,u.ajaxSettings),t):sr(u.ajaxSettings,e)},ajaxPrefilter:qr(Yr),ajaxTransport:qr(ar),ajax:function(e,t){typeof e=="object"&&(t=e,e=void 0),t=t||{};var i,l,f,h,x,b,v,w,R,I,C=u.ajaxSetup({},t),F=C.context||C,ne=C.context&&(F.nodeType||F.jquery)?u(F):u.event,de=u.Deferred(),oe=u.Callbacks("once memory"),Se=C.statusCode||{},Ce={},Ke={},Qe="canceled",ce={readyState:0,getResponseHeader:function(he){var we;if(v){if(!h)for(h={};we=En.exec(f);)h[we[1].toLowerCase()+" "]=(h[we[1].toLowerCase()+" "]||[]).concat(we[2]);we=h[he.toLowerCase()+" "]}return we==null?null:we.join(", ")},getAllResponseHeaders:function(){return v?f:null},setRequestHeader:function(he,we){return v==null&&(he=Ke[he.toLowerCase()]=Ke[he.toLowerCase()]||he,Ce[he]=we),this},overrideMimeType:function(he){return v==null&&(C.mimeType=he),this},statusCode:function(he){var we;if(he)if(v)ce.always(he[ce.status]);else for(we in he)Se[we]=[Se[we],he[we]];return this},abort:function(he){var we=he||Qe;return i&&i.abort(we),ft(0,we),this}};if(de.promise(ce),C.url=((e||C.url||Rt.href)+"").replace(Sn,Rt.protocol+"//"),C.type=t.method||t.type||C.method||C.type,C.dataTypes=(C.dataType||"*").toLowerCase().match(Ne)||[""],C.crossDomain==null){b=A.createElement("a");try{b.href=C.url,b.href=b.href,C.crossDomain=ir.protocol+"//"+ir.host!=b.protocol+"//"+b.host}catch{C.crossDomain=!0}}if(C.data&&C.processData&&typeof C.data!="string"&&(C.data=u.param(C.data,C.traditional)),Jr(Yr,C,t,ce),v)return ce;w=u.event&&C.global,w&&u.active++===0&&u.event.trigger("ajaxStart"),C.type=C.type.toUpperCase(),C.hasContent=!Cn.test(C.type),l=C.url.replace(kn,""),C.hasContent?C.data&&C.processData&&(C.contentType||"").indexOf("application/x-www-form-urlencoded")===0&&(C.data=C.data.replace(Dn,"+")):(I=C.url.slice(l.length),C.data&&(C.processData||typeof C.data=="string")&&(l+=(rr.test(l)?"&":"?")+C.data,delete C.data),C.cache===!1&&(l=l.replace(wn,"$1"),I=(rr.test(l)?"&":"?")+"_="+Pr.guid+++I),C.url=l+I),C.ifModified&&(u.lastModified[l]&&ce.setRequestHeader("If-Modified-Since",u.lastModified[l]),u.etag[l]&&ce.setRequestHeader("If-None-Match",u.etag[l])),(C.data&&C.hasContent&&C.contentType!==!1||t.contentType)&&ce.setRequestHeader("Content-Type",C.contentType),ce.setRequestHeader("Accept",C.dataTypes[0]&&C.accepts[C.dataTypes[0]]?C.accepts[C.dataTypes[0]]+(C.dataTypes[0]!=="*"?", "+Br+"; q=0.01":""):C.accepts["*"]);for(R in C.headers)ce.setRequestHeader(R,C.headers[R]);if(C.beforeSend&&(C.beforeSend.call(F,ce,C)===!1||v))return ce.abort();if(Qe="abort",oe.add(C.complete),ce.done(C.success),ce.fail(C.error),i=Jr(ar,C,t,ce),!i)ft(-1,"No Transport");else{if(ce.readyState=1,w&&ne.trigger("ajaxSend",[ce,C]),v)return ce;C.async&&C.timeout>0&&(x=o.setTimeout(function(){ce.abort("timeout")},C.timeout));try{v=!1,i.send(Ce,ft)}catch(he){if(v)throw he;ft(-1,he)}}function ft(he,we,Tt,lr){var Ge,Ot,Xe,it,st,He=we;v||(v=!0,x&&o.clearTimeout(x),i=void 0,f=lr||"",ce.readyState=he>0?4:0,Ge=he>=200&&he<300||he===304,Tt&&(it=_n(C,ce,Tt)),!Ge&&u.inArray("script",C.dataTypes)>-1&&u.inArray("json",C.dataTypes)<0&&(C.converters["text script"]=function(){}),it=Rn(C,it,ce,Ge),Ge?(C.ifModified&&(st=ce.getResponseHeader("Last-Modified"),st&&(u.lastModified[l]=st),st=ce.getResponseHeader("etag"),st&&(u.etag[l]=st)),he===204||C.type==="HEAD"?He="nocontent":he===304?He="notmodified":(He=it.state,Ot=it.data,Xe=it.error,Ge=!Xe)):(Xe=He,(he||!He)&&(He="error",he<0&&(he=0))),ce.status=he,ce.statusText=(we||He)+"",Ge?de.resolveWith(F,[Ot,He,ce]):de.rejectWith(F,[ce,He,Xe]),ce.statusCode(Se),Se=void 0,w&&ne.trigger(Ge?"ajaxSuccess":"ajaxError",[ce,C,Ge?Ot:Xe]),oe.fireWith(F,[ce,He]),w&&(ne.trigger("ajaxComplete",[ce,C]),--u.active||u.event.trigger("ajaxStop")))}return ce},getJSON:function(e,t,i){return u.get(e,t,i,"json")},getScript:function(e,t){return u.get(e,void 0,t,"script")}}),u.each(["get","post"],function(e,t){u[t]=function(i,l,f,h){return M(l)&&(h=h||f,f=l,l=void 0),u.ajax(u.extend({url:i,type:t,dataType:h,data:l,success:f},u.isPlainObject(i)&&i))}}),u.ajaxPrefilter(function(e){var t;for(t in e.headers)t.toLowerCase()==="content-type"&&(e.contentType=e.headers[t]||"")}),u._evalUrl=function(e,t,i){return u.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,converters:{"text script":function(){}},dataFilter:function(l){u.globalEval(l,t,i)}})},u.fn.extend({wrapAll:function(e){var t;return this[0]&&(M(e)&&(e=e.call(this[0])),t=u(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t?.map(function(){for(var i=this;i.firstElementChild;)i=i.firstElementChild;return i}).append(this)),this},wrapInner:function(e){return M(e)?this.each(function(t){u(this).wrapInner(e.call(this,t))}):this.each(function(){var t=u(this),i=t.contents();i.length?i.wrapAll(e):t.append(e)})},wrap:function(e){var t=M(e);return this.each(function(i){u(this).wrapAll(t?e.call(this,i):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){u(this).replaceWith(this.childNodes)}),this}}),u.expr.pseudos.hidden=function(e){return!u.expr.pseudos.visible(e)},u.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},u.ajaxSettings.xhr=function(){try{return new o.XMLHttpRequest}catch{}};var Mn={0:200,1223:204},Mt=u.ajaxSettings.xhr();E.cors=!!Mt&&"withCredentials"in Mt,E.ajax=Mt=!!Mt,u.ajaxTransport(function(e){var t,i;if(E.cors||Mt&&!e.crossDomain)return{send:function(l,f){var h,x=e.xhr();if(x.open(e.type,e.url,e.async,e.username,e.password),e.xhrFields)for(h in e.xhrFields)x[h]=e.xhrFields[h];e.mimeType&&x.overrideMimeType&&x.overrideMimeType(e.mimeType),!e.crossDomain&&!l["X-Requested-With"]&&(l["X-Requested-With"]="XMLHttpRequest");for(h in l)x.setRequestHeader(h,l[h]);t=function(b){return function(){t&&(t=i=x.onload=x.onerror=x.onabort=x.ontimeout=x.onreadystatechange=null,b==="abort"?x.abort():b==="error"?typeof x.status!="number"?f(0,"error"):f(x.status,x.statusText):f(Mn[x.status]||x.status,x.statusText,(x.responseType||"text")!=="text"||typeof x.responseText!="string"?{binary:x.response}:{text:x.responseText},x.getAllResponseHeaders()))}},x.onload=t(),i=x.onerror=x.ontimeout=t("error"),x.onabort!==void 0?x.onabort=i:x.onreadystatechange=function(){x.readyState===4&&o.setTimeout(function(){t&&i()})},t=t("abort");try{x.send(e.hasContent&&e.data||null)}catch(b){if(t)throw b}},abort:function(){t&&t()}}}),u.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),u.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return u.globalEval(e),e}}}),u.ajaxPrefilter("script",function(e){e.cache===void 0&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),u.ajaxTransport("script",function(e){if(e.crossDomain||e.scriptAttrs){var t,i;return{send:function(l,f){t=u(" - - - -
    - - diff --git a/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md b/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md deleted file mode 100644 index dfa4751f4..000000000 --- a/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md +++ /dev/null @@ -1,3802 +0,0 @@ -# Changelog -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), -and is generated by [Changie](https://github.com/miniscruff/changie). - -## 7.9.3 - 2026-05-19 - -### Security -- Fixed media plugin `data-mce-object` injection leading to stored XSS. #TINY-14357 -- Fixed stored XSS vulnerability through `mce:protected` comments. #TINY-14353 -- Fixed stored XSS vulnerability through `data-mce-` prefixed `src`, `href`, `style` attributes. #TINY-14333 - -## 7.9.2 - 2026-02-11 - -### Deprecated -- The default value of `allow_html_in_comments` will change from `true` to `false` in TinyMCE 8.x. #TINY-11900 - -### Security -- Updated dependencies and parsing logic for enhanced content sanitization. HTML-like content in comments and certain legacy patterns are now sanitized more strictly when `xss_sanitization` is enabled (default). The Introduced `allow_html_in_comments` option provides control over comment node sanitization behavior. - #TINY-11900 -- Introduced `allow_html_in_comments` option (boolean, default: `true`) to control handling of HTML-like syntax in comment nodes. This option will default to `false` in TinyMCE 8.x. #TINY-11900 - -## 7.9.1 - 2025-05-29 - -### Improved -- Update `Notices` file and minified notices. #TINY-12091 - -## 7.9.0 - 2025-05-15 - -### Added -- Added new `disc` style option for unordered lists. #TINY-12015 - -### Improved -- The resize cursor now points in the correct direction for each resize mode. Patch contributed by daniloff200. ##GH-10189 -- If `style_formats` is empty, the button is now disabled. #TINY-12005 -- Inline dialog dropdowns reposition when the dialog is dragged or the window is scrolled. #TINY-11368 -- Bullet list icons were have been updated to better represent the default styles. #TINY-12014 - -### Changed -- The ContextFormSizeInput lock button is now centered instead of aligned to the end. #TINY-11916 -- Changed the default value of `advlist_bullet_styles` option to `default,disc,circle,square`. #TINY-12083 - -### Fixed -- Autolink no longer overrides already existing links when autolinking. #TINY-11836 -- Removed the deprecated CSS media selector `-ms-high-contrast`. #TINY-11876 -- The `mceInsertContent` command no longer deletes the parent block element when an anchor is selected. #TINY-11953 -- Table resizers are now visible when inline editor has a z-index property. #TINY-11981 -- Tabbing inside a `figcaption` element no longer displays two text insertion carets. #TINY-11997 -- Pressing Enter before a floating image no longer duplicates the image. #TINY-11676 -- Editor did not scroll into viewport on receiving focus on Chrome and Safari. #TINY-12017 -- Select UI elements was not properly styled on Chrome version 136. #TINY-12131 - -## 7.8.0 - 2025-04-09 - -### Added -- New subtoolbar support for context toolbars. #TINY-11748 -- New `extended_mathml_attributes` and `extended_mathml_elements` options. #TINY-11756 -- New `onboarding` option. #TINY-11931 - -### Improved -- Focus outline was misaligned with comment card border on saving an edit. #TINY-11329 -- The `editor.selection.scrollIntoView()` method now pads the target scroll area with a small margin, ensuring content doesn't sit at the very edge of the viewport. #TINY-11786 - -### Changed -- Changed promotional text and link. #TINY-11905 - -### Fixed -- Setting editor height to a `pt` or `em` value was ignoring min/max height settings. #TINY-11108 - -## 7.7.2 - 2025-03-19 - -### Fixed -- Error was thrown when pressing tab in the last cell of a non-editable table. #TINY-11797 -- Error was thrown when trying to use the context form API after a component was detached. #TINY-11781 -- Deleting an empty block within an
  • element would move cursor to the end of the
  • . #TINY-11763 -- Deleting an empty block that was between two lists would throw an Error when all three elements were nested inside a list. #TINY-11763 - -## 7.7.1 - 2025-03-05 - -### Fixed -- Skin UI content CSS was truncated when bundling, causing CSS styles to be missing. #TINY-11875 -- Context forms used to disappear if their input was disabled in the `onSetup` API. #TINY-11890 - -## 7.7.0 - 2025-02-20 - -### Added -- `link_attributes_postprocess` option that allows overriding attributes of a link that would be inserted through the link dialog. #TINY-11707 - -### Improved -- Improved visual indication of keyboard focus in annotations that contain an image. #TINY-11596 -- The type now defaults to `info` when `editor.notificationManager.open()` is used without a specified type or with an invalid one. #TINY-11661 - -### Changed -- Updated the `link` plugin behavior to move the cursor outside of the link when inserted or edited via the UI. Patch contributed by Philipp91. #GH-9998 - -### Fixed -- Keyboard navigation for size inputs in context forms. #TINY-11394 -- Keyboard navigation for context form sliders. #TINY-11482 -- The `insertContent` API was not replacing selected non-editable elements correctly. #TINY-11714 -- Context toolbar inputs had incorrect margins. #TINY-11624 -- Iframe aria text no longer suggests opening the help dialog when the help plugin is not enabled. #TINY-11672 -- Preview dialog no longer opens anchor links in a new tab. #TINY-11740 -- The `float` property was not properly removed on the image when converting a image into a captioned image. #TINY-11670 -- Expanding selection to word didn't work inside inline editing host elements. #TINY-11304 -- The `semantics` element in MathML was not properly retained when `annotation` elements were allowed. #TINY-11755 -- It was possible to tab to a toolbar group that had all children disabled. #TINY-11665 -- Keyboard navigation would get stuck on the 'more' toolbar button. #TINY-11762 -- Toolbar groups had both a `title` attribute and a custom tooltip, causing overlapping tooltips #TINY-11768 -- Toolbar text field did not render focus correctly. #TINY-11658 - -## 7.6.1 - 2025-01-22 - -### Fixed -- Text input was prevented in form elements in the contents of the editor. #TINY-11446 -- Opening a notification when the toolbar is positioned at the bottom of the editor threw an error. #TINY-11498 -- Table resize bars were not properly aligned for inline editors inside scrollable containers. #TINY-11215 - -## 7.6.0 - 2024-12-11 - -### Added -- It is now possible to create labeled groups in context toolbars. #TINY-11095 -- New `contextsliderform` and `contextsizeinput` context form types. #TINY-11342 -- New `back` function in `ContextFormApi` to go back to the previous toolbar. #TINY-11344 -- New `QuickbarInsertImage` command that is executed by the `quickimage` button. #TINY-11399 -- New `onSetup` function to the context form API. #TINY-11494 -- New `placeholder` to the context form input field API. #TINY-11459 -- New `disabled` option to restore the previous `readonly` mode behavior, allowing the editor to be displayed in a disabled state. #TINY-11488 - -### Improved -- Base64 data was not properly decoded due to unhandled URL-encoded characters. #TINY-9548 -- The `latin` list style type is now recognized as an alias for the `alpha` list style type. #TINY-11515 - -### Fixed -- Image selection was removed when calling `editor.nodeChanged()` while having focus inside the editor UI. #TINY-11437 -- Tooltip would not show for group toolbar button. #TINY-11391 -- Changing the table row type when a `contenteditable=false` cell was selected would not work as expected. #TINY-11383 -- The `samp` format was being applied as a `block` level format, instead of an `inline` format. #TINY-11390 -- Removed title attribute from dialog tree elements as they already have a tooltip. #TINY-11470 -- Fixed CSS bundling for skin UI content CSS. #TINY-11558 -- Fixed incorrect resource keys for CSS bundling JS files. #TINY-11558 - -## 7.5.0 - 2024-11-06 - -### Added -- Added support for using raw CSS in the list of possible colours, using the `color_map_raw` property. #GH-9788 - -### Improved -- Improved color picker aria support. #TINY-11291 - -### Fixed -- Autocompleter would not activate after applying an inline format like font size in some cases. #TINY-11273 -- The `toolbar-sticky-offset` would still be applied after entering fullscreen mode. #TINY-11137 -- Text and background color toolbar buttons would not be fully greyed out in readonly mode. #TINY-11313 -- Closing a nested modal dialog would lose focus from the editor. #TINY-11153 -- Inability to type '{' character on German keyboard layouts. #TINY-11395 - -## 7.4.1 - 2024-10-10 - -### Fixed -- Invalid HTML elements within SVG elements were not removed. #TINY-11332 - -## 7.4.0 - 2024-10-09 - -### Added -- New `context` property for all ui components. This allows buttons and menu items to be enabled or disabled based on whether their context matches a given predicate; status updates are checked on `init`, `NodeChange`, and `SwitchMode` events. #TINY-11211 -- Tree component now allows the addition of a custom icon. #TINY-11131 -- Added focus function to view button api. #TINY-11122 -- New option `allow_mathml_annotation_encodings` to opt-in to keep math annotations with specific encodings. #TINY-11166 -- Added global `color-active` LESS variable for use in editor skins. #TINY-11266 - -### Improved -- In read-only mode the editor now allows normal cursor movement and block element selection, including video playback. #TINY-11264 -- Pasting a table now places the cursor after the table instead of into the last cell. #TINY-11082 -- Dialog list dropdown menus now close when the browser window resizes. #TINY-11123 - -### Fixed -- Mouse hover on partially visible dialog collection elements no longer scrolls. #TINY-9915 -- Caret would unexpectedly shift to the non-editable table row above when pressing Enter. #TINY-11077 -- Deleting a selection in a list element would sometimes prevent the `input` event from being dispatched. #TINY-11100 -- Placing the cursor after a table with a br after it would misplace added newlines before the table instead of after. #TINY-11110 -- Sidebar could not be toggled until the skin was loaded. #TINY-11155 -- The image dialog lost focus after closing an image upload error alert. #TINY-11159 -- Copying tables to the clipboard did not correctly separate cells and rows for the "text/plain" MIME type. #TINY-10847 -- The editor resize handle was incorrectly rendered when all components were removed from the status bar. #TINY-11257 - -## 7.3.0 - 2024-08-07 - -### Added -- Colorpicker number input fields now show an error tooltip and error icon when invalid text has been entered. #TINY-10799 -- New `format-code` icon. #TINY-11018 - -### Improved -- When a full document was loaded as editor content the head elements were added to the body. #TINY-11053 - -### Fixed -- Unnecessary nbsp entities were inserted when typing at the edges of inline elements. #TINY-10854 -- Fixed JavaScript error when inserting a table using the context menu by adjusting the event order in `renderInsertTableMenuItem`. #TINY-6887 -- Notifications didn't position and resize properly when resizing the editor or toggling views. #TINY-10894 -- The pattern commands would execute even if the command was not enabled. #TINY-10994 -- Split button popups were incorrectly positioned when switching to fullscreen mode if the editor was inside a scrollable container. #TINY-10973 -- Sequential html comments would in some cases generate unwanted elements. #TINY-10955 -- The listbox component had a fixed width and was not a responsive ui element. #TINY-10884 -- Prevent default mousedown on toolbar buttons was causing misplaced focus bugs. #TINY-10638 -- Attempting to use focus commands on an editor where the cursor had last been in certain contentEditable="true" elements would fail. #TINY-11085 -- Colorpicker's hex-based input field showed the wrong validation error message. #TINY-11115 - -## 7.2.1 - 2024-07-03 - -### Fixed -- Text content could move unexpectedly when deleting a paragraph. #TINY-10590 -- Cursor would shift to the start of the editor body when focus was shifted to a noneditable cell of a table. #TINY-10127 -- Long translations of the bottom help text would cause minor graphical issues. #TINY-10961 -- Open Link button was disabled when selection partially covered a link or when multiple links were selected. #TINY-11009 - -## 7.2.0 - 2024-06-19 - -### Added -- Added `options.debug` API that logs the initial raw editor options to console. #TINY-10605 -- Added `referrerpolicy` as a valid attribute for an iframe element. #TINY-10374 -- New `onInit` and `stretched` properties to the `HtmlPanel` dialog component. #TINY-10900 -- Added support for querying the state of the `mceTogglePlainTextPaste` command. #TINY-10938 -- Added `for` option to dialog label components to improve accessibility. The value must be another component on the same dialog. #TINY-10971 - -### Improved -- Dialog slider components now emit an onChange event when using arrow keys. #TINY-10428 -- Accessibility for element path buttons, added tooltip to describe the button and removed incorrect `aria-level` attribute. #TINY-10891 -- Improve merging of inserted inline elements by removing nodes with redundant inheritable styles. #TINY-10869 -- Improved Find & Replace dialog accessibility by changing placeholders to labels. #TINY-10871 - -### Changed -- Replaced tiny branding logo with `Build with TinyMCE` text and logo. #TINY-11001 - -### Fixed -- Deleting in a `div` with preceeding `br` elements would sometimes throw errors. #TINY-10840 -- `autoresize_bottom_margin` was not reliably applied in some situations. #TINY-10793 -- Fixed cases where adding a newline around a br, table or img would not move the cursor to a new line. #TINY-10384 -- Focusing on `contenteditable="true"` element when using `editable_root: false` and inline mode causing selection to be shifted. #TINY-10820 -- Corrected the `role` attribute on listbox dialog components to `combobox` when there are no nested menu items. #TINY-10807 -- HTML entities that were double decoded in `noscript` elements caused an XSS vulnerability. #TINY-11019 -- It was possible to inject XSS HTML that was not matching the regexp when using the `noneditable_regexp` option. #TINY-11022 - -## 7.1.2 - 2024-06-05 - -### Fixed -- CSS color values set to `transparent` were incorrectly converted to '#000000`. #TINY-10916 - -## 7.1.1 - 2024-05-22 - -### Fixed -- Insert/Edit image dialog lost focus after the image upload completed. #TINY-10885 -- Deleting into a list from a paragraph that has an `img` tag could cause extra inline styles to be added. #TINY-10892 -- Resolved an issue where emojis configured with the `emojiimages` database were not loading correctly due to a broken CDN. #TINY-10878 -- Iframes in dialogs were not rendering rounded borders correctly. #TINY-10901 -- Autocompleter possible values are no longer capped at a length of 10. #TINY-10942 - -## 7.1.0 - 2024-05-08 - -### Added -- Parser support for math elements. #TINY-10809 -- New `math-equation` icon. #TINY-10804 - -### Improved -- Included `itemprop`, `itemscope` and `itemtype` as valid HTML5 attributes in the core schema. #TINY-9932 -- Notification accessibility improvements: added tooltips, keyboard navigation and shortcut to focus on notifications. #TINY-6925 -- Removed `aria-pressed` from the `More` button in sliding toolbar mode and replaced it with `aria-expanded`. #TINY-10795 -- The editor UI now renders correctly in Windows High Contrast Mode. #TINY-10781 - -### Fixed -- Backspacing in certain html setups resulted in data moving around unexpectedly. #TINY-10590 -- Dialog title markup changed to use an `h1` element instead of `div`. #TINY-10800 -- Dialog title was not announced in macOS VoiceOver, dialogs now use `aria-label` instead of `aria-labelledby` on macOS. #TINY-10808 -- Theme loader did not respect the suffix when it was loading skin CSS files. #TINY-10602 -- Custom block elements with colon characters would throw errors. #TINY-10813 -- Tab navigation in views didn't work. #TINY-10780 -- Video and audio elements could not be played on Safari. #TINY-10774 -- `ToggleToolbarDrawer` command did not toggle the toolbar in `sliding` mode when `{skipFocus: true}` parameter was passed. #TINY-10726 -- The buttons in the custom view header were clipped on when overflowing. #TINY-10741 -- In the custom view, the scrollbar of the container was not visible if its height was greater than the editor. #TINY-10741 -- Fixed accessibility issue by removing duplicate `role="menu"` attribute from color swatches. #TINY-10806 -- Fullscreen mode now prevents focus from leaving the editor. #TINY-10597 -- Open link context menu action did not work with selection surrounding a link. #TINY-10391 -- Styles were not retained when toggling a list on and off. #TINY-10837 -- Caret and placeholder text were invisible in Windows High Contrast Mode. #TINY-9811 -- Firefox did not announce the iframe title when `iframe_aria_text` was set. #TINY-10718 -- Notification width was not constrained to the width of the editor. #TINY-10886 -- Open link context menu action was not enabled for links on images. #TINY-10391 - -## 7.0.1 - 2024-04-10 - -### Fixed -- Toggle list behavior generated wrong html when the `forced_root_block` option was set to `div`. #TINY-10488 -- Tapping inside a composed text on Firefox Android would not close the autocompleter. #TINY-10715 -- An inline editor toolbar now behaves correctly in horizontally scrolled containers. #TINY-10684 -- Tooltips unintended shrinking and incorrectly positioned when shown in horizontally scrollable container. #TINY-10797 -- The status bar was invisible when the editor's height is short. #TINY-10705 - -## 7.0.0 - 2024-03-20 - -### Added -- New `license_key` option that must be set to `gpl` or a valid license key. #TINY-10681 -- New custom tooltip functionality, tooltip will be shown when hovering with a mouse or with keyboard focus. #TINY-9275 -- New `sandbox_iframes_exclusions` option that holds a list of URL host names to be excluded from iframe sandboxing when `sandbox_iframes` is set to `true`. #TINY-10350 -- Added 'getAllEmojis' api function to the emoticons plugin. #TINY-10572 -- Element preset support for the `valid_children` option and Schema.addValidChildren API. #TINY-9979 -- A new `trigger` property for block text pattern configurations, allowing pattern activation with either Space or Enter keys. #TINY-10324 -- onFocus callback for CustomEditor dialog component. #TINY-10596 -- icons for the import from Word, export to Word and export to PDF premium plugins. #TINY-10612 -- `data` is now a valid element in the Schema. #TINY-10611 -- More advanced schema config for custom elements. #TINY-9980 -- Custom tooltip for autocompleter, now visible on both mouse hover and keyboard focus, except single column cases. #TINY-9638 - -### Improved -- Included keyboard shortcut in custom tooltip for `ToolbarButton` and `ToolbarToggleButton`. #TINY-10487 -- Improved showing which element has focus for keyboard navigation. #TINY-9176 -- Custom tooltips will now show for items in `collection` which is rendered inside a dialog, on mouse hover and keyboard focus. #TINY-9637 -- Autocompleter will now work with IMEs. #TINY-10637 -- Make table ghost element better reflect height changes when resizing. #TINY-10658 - -### Changed -- TinyMCE is now licensed GPL Version 2 or later. #TINY-10578 -- `convert_unsafe_embeds` editor option is now defaulted to `true`. #TINY-10351 -- `sandbox_iframes` editor option is now defaulted to `true`. #TINY-10350 -- The DOMUtils.isEmpty API function has been modified to consider nodes containing only comments as empty. #TINY-10459 -- The `highlight_on_focus` option now defaults to true, adding a focus outline to every editor. #TINY-10574 -- Delay before the tooltip to show up, from 800ms to 300ms. #TINY-10475 -- Now `tox-view__pane` has `position: relative` instead of `static`. #TINY-10561 -- Update outbound link for statusbar Tiny logo #TINY-10494 -- Remove the height field from the `table` plugin cell dialog. The `table` plugin row dialog now controls the row height by setting the height on the `tr` element, not the `td` elements. #TINY-10617 -- Change table height resizing handling to remove heights from `td`/`th` elements and only apply to `tr` elements. #TINY-10589 -- Removed incorrect `aria-placeholder` attribute from editor body when `placeholder` option is set. #TINY-10452 -- The `tooltip` property for dialog's footer `togglebutton` is now optional. #TINY-10672 -- Changed the `media_url_resolver` option to use promises. #TINY-9154 -- `Styles` bespoke toolbar button fallback changed to `Formats` if `Paragraph` is not configured in `style_formats` option. #TINY-10603 -- Updated deprecation/removed console message. #TINY-10694 - -### Removed -- Deprecated `force_hex_color` option, with the default now being all colors are forced to hex format as lower case. #TINY-10436 -- Deprecated `remove_trailing_brs` option from DomParser. #TINY-10454 -- `title` attribute on buttons with visible label. #TINY-10453 -- `InsertOrderedList` and `InsertUnorderedList` commands from core, these now only exist in the `lists` plugin. #TINY-10644 -- `closeButton` from the notification API, close buttons in notifications are now required. #TINY-10646 -- The autocompleter `ch` configuration property has been removed. Use the `trigger` property instead. #TINY-8929 -- Deprecated `template` plugin. #TINY-10654 - -### Fixed -- When deleting the last row in a table, the cursor would jump to the first cell (top left), instead of moving to the next adjacent cell in some cases. #TINY-6309 -- Heading formatting would be partially applied to the content within the `summary` element when the caret was positioned between words. #TINY-10312 -- Moving focus to the outside of the editor after having clicked a menu would not fire a `blur` event as expected. #TINY-10310 -- Autocomplete would sometimes cause corrupt data when starting during text composition. #TINY-10317 -- Inline mode with persisted toolbar would show regardless of the skin being loaded, causing css issues. #TINY-10482 -- Table classes couldn't be removed via setting an empty value in `table_class_list`. Also fixed being forced to pick the first class option. #TINY-6653 -- Directly right clicking on a ol's li in FireFox didn't enable the button `List Properties...` in the context menu. #TINY-10490 -- The `link_default_target` option wasn't considered when inserting a link via `quicklink` toolbar. #TINY-10439 -- When inline editor toolbar wrapped to multiple lines the top wasn't always calculated correctly. #TINY-10580 -- Removed manually dispatching dragend event on drop in Firefox. #TINY-10389 -- Slovenian help dialog content had a dot in the wrong place. #TINY-10601 -- Pressing Backspace at the start of an empty `summary` element within a `details` element nested in a list item no longer removes the `summary` element. #TINY-10303 -- The toolbar width was miscalculated for the inline editor positioned inside a scrollable container. #TINY-10581 -- Fixed incorrect object processor for `event_root` option. #TINY-10433 -- Adding newline after using `selection.setContent` to insert a block element would throw an unhandled exception. #TINY-10560 -- Floating toolbar buttons in inline editor incorrectly wrapped into multiple rows on window resizing or zooming. #TINY-10570 -- When setting table border width and `table_style_by_css` is true, only the border attribute is set to 0 and border-width styling is no longer used. #TINY-10308 -- Clicking to the left or right of a non-editable div in Firefox would show two cursors. #TINY-10314 - -## 6.8.3 - 2024-02-08 - -### Changed -- Update outbound TinyMCE website links. #TINY-10491 - -### Fixed -- The floating toolbar would not be fully visible when the editor was placed inside a scrollable container. #TINY-10335 -- ShadowDOM skin was not loaded properly when used with js bundling feature. #TINY-10451 - -## 6.8.2 - 2023-12-11 - -### Fixed -- Bespoke select toolbar buttons including `fontfamily`, `fontsize`, `blocks`, and `styles` incorrectly used plural words in their accessible names. #TINY-10426 -- The `align` bespoke select toolbar button had an accessible name that was misleading and grammatically incorrect in certain cases. #TINY-10435 -- Accessible names of bespoke select toolbar buttons including `align`, `fontfamily`, `fontsize`, `blocks`, and `styles` were incorrectly translated. #TINY-10426 #TINY-10435 -- Clicking inside table cells with heavily nested content could cause the browser to hang. #TINY-10380 -- Toggling a list that contains an LI element having another list as its first child would remove the remaining content within that LI element. #TINY-10414 - -## 6.8.1 - 2023-11-29 - -### Improved -- Colorpicker now includes the Brightness/Saturation selector and hue slider in the keyboard navigable items. #TINY-9287 - -### Fixed -- Translation syntax for announcement text in the table grid was incorrectly formatted. #TINY-10141 -- The functions `schema.isWrapper` and `schema.isInline` did not exclude node names that started with `#` which should not be considered as elements. #TINY-10385 - -## 6.8.0 - 2023-11-22 - -### Added -- CSS files are now also generated as separate JS files to improve bundling of all resources. #TINY-10352 -- Added new `StylesheetLoader.loadRawCss` API that can be used to load CSS into a style element. #TINY-10352 -- Added new `StylesheetLoader.unloadRawCss` API that can be used to unload CSS that was loaded into a style element. #TINY-10352 -- Added `force_hex_color` editor option. Option `'always'` converts all RGB & RGBA colours to hex, `'rgb_only'` will only convert RGB and *not* RGBA colours to hex, `'off'` won't convert any colours to hex. #TINY-9819 -- Added `default_font_stack` editor option that makes it possible to define what is considered a system font stack. #TINY-10290 -- New `sandbox_iframes` option that controls whether iframe elements will be added a `sandbox=""` attribute to mitigate malicious intent. #TINY-10348 -- New `convert_unsafe_embeds` option that controls whether `` and `` elements will be converted to more restrictive alternatives, namely `` for image MIME types, `