import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router'; import { Button, Card, Col, Container, Form, Modal, Row, Spinner } from 'react-bootstrap'; import { TbArrowsMove, TbChevronDown, TbChevronRight, TbPencil, TbPlus, TbTrash } from 'react-icons/tb'; import { signedGet, signedPost, isSuccess } from '@/api/client'; import { dropdownMap } from '@/api/dropdowns'; import { getSession } from '@/auth/storage'; import PageBreadcrumb from '@/components/PageBreadcrumb'; import { downloadCsv } from '@/helpers/csv'; function ownerLabel(node) { if (node?.is_vacant) return 'Vacant'; const info = node?.owner_info || {}; const name = info.name || [info.firstname, info.lastname].filter(Boolean).join(' ') || '-'; return `${name} (${info.employee_code || '-'})`; } function isInnermostLevel(node) { const type = String(node?.type || '').trim().toLowerCase(); return type === 'territory' || type === 'pool territory' || type === 'tm'; } function nodeCanExpand(node) { return !isInnermostLevel(node); } function collectExpanded(nodes, children, rootId) { const next = {}; Object.entries(children || {}).forEach(([id, kids]) => { if (!kids?.length) return; if (isInnermostLevel(nodes[id])) return; next[id] = true; }); if (rootId) next[rootId] = true; return next; } function ingestFullTree(items, me) { const nodes = {}; const grouped = {}; const all = [...(items || [])]; if (me?.id && !all.some((item) => item.id === me.id)) all.push(me); all.forEach((item) => { if (!item?.id) return; nodes[item.id] = { ...item }; const parent = item.parent_id || 'root'; if (!grouped[parent]) grouped[parent] = []; grouped[parent].push(item.id); }); Object.keys(nodes).forEach((id) => { const kids = grouped[id] || []; nodes[id].has_children = kids.length > 0; }); const rootId = me?.id || ''; const children = { ...grouped }; if (rootId && children[rootId] === undefined) children[rootId] = []; return { nodes, children, rootId }; } const emptyChild = { id: '', parent_id: '', previous_owner: '', code: '', owner: '', employee_code: '', email: '', firstname: '', lastname: '', roles: '', channels: '', }; function hierarchyItems(data) { if (Array.isArray(data?.items)) return data.items; if (Array.isArray(data?.hierarchys)) return data.hierarchys; if (Array.isArray(data)) return data; return []; } const CHILDREN_PARAMS = { children: 1 }; function createExpandStore() { let state = {}; const listeners = new Map(); const notify = (id) => { listeners.get(id)?.forEach((fn) => fn(Boolean(state[id]))); }; return { isOpen: (id) => Boolean(state[id]), collapse(id) { if (!state[id]) return; state = { ...state }; delete state[id]; notify(id); }, expand(id) { if (state[id]) return; state = { ...state, [id]: true }; notify(id); }, replace(next) { const prev = state; state = { ...(next || {}) }; const ids = new Set([...Object.keys(prev), ...Object.keys(state)]); ids.forEach((id) => notify(id)); }, subscribe(id, fn) { if (!listeners.has(id)) listeners.set(id, new Set()); listeners.get(id).add(fn); return () => { const set = listeners.get(id); if (!set) return; set.delete(fn); if (!set.size) listeners.delete(id); }; }, }; } function useIsExpanded(store, id) { const [open, setOpen] = useState(() => store.isOpen(id)); useEffect(() => store.subscribe(id, setOpen), [store, id]); return open; } const HierarchyContext = createContext(null); const NodeList = memo(function NodeList({ parentId }) { const { childrenById, nodesById } = useContext(HierarchyContext); const childIds = childrenById[parentId]; if (!childIds?.length) return null; return ( ); }); const NodeRow = memo(function NodeRow({ node }) { const { loadingIds, expandStore, actions } = useContext(HierarchyContext); const isExpanded = useIsExpanded(expandStore, node.id); const keepKids = useRef(isExpanded); if (isExpanded) keepKids.current = true; const isLoadingNode = Boolean(loadingIds[node.id]); const canExpand = nodeCanExpand(node); return ( <>
{canExpand ? ( ) : null}
{node.name} ({node.type}) — {ownerLabel(node)}
{keepKids.current ? ( ) : null} ); }); export default function HierarchyTree() { const [nodesById, setNodesById] = useState({}); const [childrenById, setChildrenById] = useState({}); const [loadingIds, setLoadingIds] = useState({}); const [rootId, setRootId] = useState(''); const [positions, setPositions] = useState({}); const [users, setUsers] = useState({}); const [roles, setRoles] = useState({}); const [channels, setChannels] = useState({}); const [loading, setLoading] = useState(true); const [loadingAll, setLoadingAll] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [message, setMessage] = useState(''); const [childOpen, setChildOpen] = useState(false); const [moveOpen, setMoveOpen] = useState(false); const [childTitle, setChildTitle] = useState('Add child'); const [childForm, setChildForm] = useState(emptyChild); const [lockCode, setLockCode] = useState(false); const [moveForm, setMoveForm] = useState({ id: '', parent_id: '', previous_owner: '', code: '' }); const lookupsLoaded = useRef(false); const lookupsInflight = useRef(null); const expandStore = useMemo(() => createExpandStore(), []); const childrenByIdRef = useRef(childrenById); childrenByIdRef.current = childrenById; const isCrestroad = () => getSession()?.domain === 'crestroad'; const mergeChildren = useCallback((parentId, items, extraNodes = []) => { setNodesById((prev) => { const next = { ...prev }; extraNodes.forEach((node) => { if (node?.id) next[node.id] = { ...next[node.id], ...node }; }); (items || []).forEach((item) => { if (item?.id) next[item.id] = { ...next[item.id], ...item }; }); if (parentId && next[parentId]) { next[parentId] = { ...next[parentId], has_children: (items || []).length > 0 }; } return next; }); if (parentId) { setChildrenById((prev) => ({ ...prev, [parentId]: (items || []).map((item) => item.id).filter(Boolean), })); } }, []); const fetchChildren = useCallback( async (parentId, { silent = false } = {}) => { if (!parentId || isCrestroad()) return []; setLoadingIds((prev) => ({ ...prev, [parentId]: true })); try { const response = await signedGet( 'ums', 'hierarchy/get_all', { ...CHILDREN_PARAMS, parent_id: parentId, }, { timeout: 120000 }, ); if (response?.statusCode === 401 || response?.statusCode === 403) { if (!silent) setError('Unauthorised'); return []; } if (!isSuccess(response)) { if (!silent) setError(response?.message || 'Unable to load hierarchy'); return []; } const items = hierarchyItems(response.data); mergeChildren(parentId, items); return items; } catch (err) { if (!silent) setError(err.message || 'Unable to load hierarchy'); return []; } finally { setLoadingIds((prev) => { const next = { ...prev }; delete next[parentId]; return next; }); } }, [mergeChildren], ); const loadTree = useCallback(async () => { setLoading(true); setError(''); try { const crestroad = isCrestroad(); const listRes = crestroad ? await signedGet('catalog', 'activity/hierarchy_list') : await signedGet('ums', 'hierarchy/get_all', CHILDREN_PARAMS, { timeout: 120000 }); if (listRes?.statusCode === 401 || listRes?.statusCode === 403) { setError('Unauthorised'); return; } if (!isSuccess(listRes)) { setError(listRes?.message || 'Unable to load hierarchy'); return; } const me = listRes.data?.me; const items = hierarchyItems(listRes.data); if (crestroad) { const ingested = ingestFullTree(items, me); setNodesById(ingested.nodes); setChildrenById(ingested.children); setRootId(ingested.rootId); expandStore.replace(ingested.rootId ? { [ingested.rootId]: true } : {}); return; } if (!me?.id) { setNodesById({}); setChildrenById({}); setRootId(''); expandStore.replace({}); return; } setRootId(me.id); expandStore.replace({ [me.id]: true }); mergeChildren(me.id, items, [{ ...me, has_children: items.length > 0 }]); } catch (err) { setError(err.message || 'Unable to load hierarchy'); } finally { setLoading(false); } }, [mergeChildren, expandStore]); useEffect(() => { loadTree(); }, [loadTree]); const loadLookups = useCallback(async () => { if (lookupsLoaded.current) return; if (lookupsInflight.current) return lookupsInflight.current; lookupsInflight.current = (async () => { try { const [positionMap, userMap, roleMap, channelMap] = await Promise.all([ dropdownMap('ums', 'hierarchy', (item) => `${item.name} (${item.code})`), dropdownMap('ums', 'users', (item) => `${item.firstname || ''} ${item.lastname || ''} (${item.employee_code || ''})`), dropdownMap('ums', 'roles', (item) => item.name), dropdownMap('ums', 'channel', (item) => item.name), ]); const positionRes = await signedGet('ums', 'hierarchy/dropdown'); const codeMap = {}; (isSuccess(positionRes) ? positionRes.data?.items || [] : []).forEach((item) => { codeMap[item.code] = `${item.name} (${item.code})`; }); setPositions(Object.keys(codeMap).length ? codeMap : positionMap); setUsers(userMap); setRoles(roleMap); setChannels(channelMap); lookupsLoaded.current = true; } finally { lookupsInflight.current = null; } })(); return lookupsInflight.current; }, []); const refetchParents = useCallback( async (parentIds) => { if (isCrestroad()) { await loadTree(); return; } const unique = [...new Set((parentIds || []).filter(Boolean))]; await Promise.all(unique.map((id) => fetchChildren(id, { silent: true }))); }, [fetchChildren, loadTree], ); const toggleExpand = useCallback( async (id) => { if (expandStore.isOpen(id)) { expandStore.collapse(id); return; } expandStore.expand(id); if (!isCrestroad() && childrenByIdRef.current[id] === undefined) { await fetchChildren(id); } }, [expandStore, fetchChildren], ); const openAdd = useCallback((node) => { setChildTitle(`Add New Child for ${node.name}`); setLockCode(false); setChildForm({ ...emptyChild, parent_id: node.id }); setChildOpen(true); loadLookups(); }, [loadLookups]); const openEdit = useCallback((node) => { setChildTitle(`Edit ${node.name}`); setLockCode(true); setChildForm({ ...emptyChild, id: node.id, parent_id: node.parent_id, previous_owner: node.owner || '', code: node.code || '', owner: node.is_vacant ? 'vacant' : node.owner || '', }); setChildOpen(true); loadLookups(); }, [loadLookups]); const openMove = useCallback((node) => { setMoveForm({ id: node.id, parent_id: node.parent_id, previous_owner: node.owner || '', code: '', }); setMoveOpen(true); loadLookups(); }, [loadLookups]); const onVacate = useCallback(async (node) => { if (!window.confirm(`Vacate "${node.name}"? This moves the position to vacant (not a hard delete).`)) return; setSaving(true); setError(''); try { const response = await signedPost('ums', 'hierarchy/update', { id: node.id, parent_id: 'vacant', parents: 'vacent', }); if (isSuccess(response)) { setMessage('Successfully Marked Deleted !!!'); await refetchParents([node.parent_id, rootId]); } else { setError(response?.message || 'Unable to vacate'); } } catch (err) { setError(err.message || 'Unable to vacate'); } finally { setSaving(false); } }, [refetchParents, rootId]); const saveChild = async (event) => { event.preventDefault(); setSaving(true); setError(''); try { const payload = { ...childForm, roles: childForm.roles ? [childForm.roles] : [], channels: childForm.channels ? [childForm.channels] : [], source: 'hierarchy', }; const path = childForm.id ? 'hierarchy/update' : 'hierarchy/create'; const response = await signedPost('ums', path, payload); if (!isSuccess(response)) { setError(response?.message || 'Save failed'); return; } setChildOpen(false); setMessage('Successfully Saved !!!'); if (childForm.parent_id) { expandStore.expand(childForm.parent_id); } await refetchParents([childForm.parent_id]); } catch (err) { setError(err.message || 'Save failed'); } finally { setSaving(false); } }; const saveMove = async (event) => { event.preventDefault(); setSaving(true); setError(''); try { const response = await signedPost('ums', 'hierarchy/moveposition', moveForm); if (!isSuccess(response)) { setError(response?.message || 'Move failed'); return; } setMoveOpen(false); setMessage('Successfully Saved !!!'); const target = Object.values(nodesById).find((node) => node.code === moveForm.code); await refetchParents([moveForm.parent_id, target?.id]); } catch (err) { setError(err.message || 'Move failed'); } finally { setSaving(false); } }; const onExport = async () => { setSaving(true); setError(''); try { const response = await signedGet('ums', 'hierarchy/get_all', { positions: true, export: true }); if (!isSuccess(response)) { setError(response?.message || 'Export failed'); return; } const headers = [ 'Hierarchy Id', 'Sub Department Name', 'Employee Code', 'Business Email', 'Display Name', 'Mobile 1 Phone', 'Position Code', 'Position Title', 'Designation', 'Zone', 'Region', 'Area', 'Territory', 'Manager 1 Employee Code', 'Manager 1 Name', "Manager's 1 Email", 'Manager 2 Employee Code', 'Manager 2 Name', "Manager's 2 Email", ]; const rows = (response.data?.hierarchys || []).map((user) => { const owner = user.owner_info || {}; const hasOwner = Boolean(owner.employee_code); const m1 = user.manager_level1 || {}; const m2 = user.manager_level2 || {}; return [ user.id ?? '-', hasOwner ? owner.domain || '' : '', hasOwner ? owner.employee_code || '' : '', hasOwner ? owner.email || '' : '', hasOwner ? `${owner.firstname || ''} ${owner.lastname || ''}`.trim() : '', hasOwner ? owner.phone || '-' : '', user.code ?? '-', user.name ?? '-', user.designation ?? '-', user.positions?.Zone ?? '-', user.positions?.Region ?? '-', user.positions?.Area ?? '-', user.positions?.Territory ?? '-', m1.employee_code || '', m1.name || '', m1.email || '', m2.employee_code || '', m2.name || '', m2.email || '', ]; }); downloadCsv([headers, ...rows], 'hierarchy.csv'); setMessage('Export downloaded'); } catch (err) { setError(err.message || 'Export failed'); } finally { setSaving(false); } }; const onLoadAll = async () => { setLoadingAll(true); setError(''); setMessage(''); try { if (isCrestroad()) { expandStore.replace(collectExpanded(nodesById, childrenById, rootId)); setMessage('Hierarchy loaded'); return; } const response = await signedGet('ums', 'hierarchy/get_all', {}, { timeout: 120000 }); if (response?.statusCode === 401 || response?.statusCode === 403) { setError('Unauthorised'); return; } if (!isSuccess(response)) { setError(response?.message || 'Unable to load hierarchy'); return; } const ingested = ingestFullTree(response.data?.items || [], response.data?.me); if (!ingested.rootId) { setError('Unable to load hierarchy'); return; } setNodesById(ingested.nodes); setChildrenById(ingested.children); setRootId(ingested.rootId); expandStore.replace(collectExpanded(ingested.nodes, ingested.children, ingested.rootId)); setMessage('Hierarchy loaded'); } catch (err) { setError(err.message || 'Unable to load hierarchy'); } finally { setLoadingAll(false); } }; const onRepopulate = async () => { if (!window.confirm('Repopulate typed position data for this domain?')) return; setSaving(true); setError(''); try { const domain = getSession()?.domain || ''; const response = await signedPost('ums', 'hierarchy/populatetypedata', { partition: `rapport-${domain}` }); if (isSuccess(response)) { setMessage('Successfully Populated !!!'); setNodesById({}); setChildrenById({}); expandStore.replace({}); await loadTree(); } else { setError(response?.message || 'Repopulate failed'); } } catch (err) { setError(err.message || 'Repopulate failed'); } finally { setSaving(false); } }; const actions = useMemo( () => ({ toggleExpand, openAdd, openMove, openEdit, onVacate }), [toggleExpand, openAdd, openMove, openEdit, onVacate], ); const treeValue = useMemo( () => ({ nodesById, childrenById, loadingIds, expandStore, actions }), [nodesById, childrenById, loadingIds, expandStore, actions], ); const rootNode = rootId ? nodesById[rootId] : null; return (
Import
{message ?
{message}
: null} {error ?
{error}
: null} {loading ? (
) : !rootNode ? (
No hierarchy nodes
) : ( )}
setChildOpen(false)}>
{childTitle} Position setChildForm((prev) => ({ ...prev, code: e.target.value }))} > {Object.entries(positions).map(([code, label]) => ( ))} User setChildForm((prev) => ({ ...prev, owner: e.target.value }))} > {Object.entries(users).map(([id, label]) => ( ))} {childForm.owner === 'addnew' ? ( Employee Code setChildForm((prev) => ({ ...prev, employee_code: e.target.value }))} /> Email setChildForm((prev) => ({ ...prev, email: e.target.value }))} /> Firstname setChildForm((prev) => ({ ...prev, firstname: e.target.value }))} /> Lastname setChildForm((prev) => ({ ...prev, lastname: e.target.value }))} /> Roles setChildForm((prev) => ({ ...prev, roles: e.target.value }))} > {Object.entries(roles).map(([id, label]) => ( ))} Channels setChildForm((prev) => ({ ...prev, channels: e.target.value }))} > {Object.entries(channels).map(([id, label]) => ( ))} ) : null}
setMoveOpen(false)}>
Move this position to another Position setMoveForm((prev) => ({ ...prev, code: e.target.value }))} > {Object.entries(positions).map(([code, label]) => ( ))}
); }