Rapport_React/src/views/organisation/HierarchyTree.jsx

825 lines
27 KiB
JavaScript

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 (
<ul className="list-unstyled ps-3 border-start ms-2 mt-1 mb-0 d-flex flex-column gap-1">
{childIds.map((id) => {
const node = nodesById[id];
if (!node) return null;
return (
<li key={id}>
<NodeRow node={node} />
</li>
);
})}
</ul>
);
});
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 (
<>
<div className="d-flex align-items-center justify-content-between gap-2 p-2 bg-light rounded">
<div className="d-flex align-items-center gap-2 min-w-0">
{canExpand ? (
<Button
size="sm"
variant="soft-secondary"
className="flex-shrink-0"
title={isExpanded ? 'Collapse' : 'Expand'}
onClick={() => actions.toggleExpand(node.id)}
>
{isLoadingNode ? (
<Spinner animation="border" size="sm" />
) : isExpanded ? (
<TbChevronDown className="fs-lg" />
) : (
<TbChevronRight className="fs-lg" />
)}
</Button>
) : null}
<div>
<strong>{node.name}</strong> ({node.type}) — {ownerLabel(node)}
</div>
</div>
<div className="d-flex gap-1">
<Button size="sm" variant="soft-secondary" title="Add child" onClick={() => actions.openAdd(node)}>
<TbPlus />
</Button>
<Button size="sm" variant="soft-info" title="Move" onClick={() => actions.openMove(node)}>
<TbArrowsMove />
</Button>
<Button size="sm" variant="soft-warning" title="Edit" onClick={() => actions.openEdit(node)}>
<TbPencil />
</Button>
<Button size="sm" variant="soft-danger" title="Vacate" onClick={() => actions.onVacate(node)}>
<TbTrash />
</Button>
</div>
</div>
{keepKids.current ? (
<div hidden={!isExpanded}>
<NodeList parentId={node.id} />
</div>
) : 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 (
<Container fluid>
<PageBreadcrumb title="Hierarchy Management" subtitle="Organisation" />
<div className="d-flex justify-content-end align-items-center mb-3 flex-wrap gap-2">
<div className="d-flex gap-2 flex-wrap">
<Button variant="dark" disabled={saving || loadingAll} onClick={onLoadAll}>
{loadingAll ? 'Loading...' : 'Load All'}
</Button>
<Button variant="dark" disabled={saving || loadingAll} onClick={onRepopulate}>
Repopulate Positions
</Button>
<Button variant="dark" disabled={saving || loadingAll} onClick={onExport}>
Export
</Button>
<Link to="/hierarchy/import" className="btn btn-dark">
Import
</Link>
</div>
</div>
{message ? <div className="alert alert-success">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<Card>
<Card.Body>
{loading ? (
<div className="text-center py-4">
<Spinner animation="border" />
</div>
) : !rootNode ? (
<div className="text-muted">No hierarchy nodes</div>
) : (
<HierarchyContext.Provider value={treeValue}>
<NodeRow node={rootNode} />
</HierarchyContext.Provider>
)}
</Card.Body>
</Card>
<Modal show={childOpen} onHide={() => setChildOpen(false)}>
<Form onSubmit={saveChild}>
<Modal.Header closeButton>
<Modal.Title>{childTitle}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group className="mb-2">
<Form.Label>Position</Form.Label>
<Form.Select
required
disabled={lockCode}
value={childForm.code}
onChange={(e) => setChildForm((prev) => ({ ...prev, code: e.target.value }))}
>
<option value=""> -- Select Type -- </option>
{Object.entries(positions).map(([code, label]) => (
<option key={code} value={code} disabled={lockCode && code !== childForm.code}>
{label}
</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className="mb-2">
<Form.Label>User</Form.Label>
<Form.Select
required
value={childForm.owner}
onChange={(e) => setChildForm((prev) => ({ ...prev, owner: e.target.value }))}
>
<option value=""> -- Select User -- </option>
<option value="vacant"> Vacant </option>
<option value="addnew"> Add New </option>
{Object.entries(users).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</Form.Select>
</Form.Group>
{childForm.owner === 'addnew' ? (
<Row>
<Col md={6} className="mb-2">
<Form.Label>Employee Code</Form.Label>
<Form.Control
required
value={childForm.employee_code}
onChange={(e) => setChildForm((prev) => ({ ...prev, employee_code: e.target.value }))}
/>
</Col>
<Col md={6} className="mb-2">
<Form.Label>Email</Form.Label>
<Form.Control
type="email"
required
value={childForm.email}
onChange={(e) => setChildForm((prev) => ({ ...prev, email: e.target.value }))}
/>
</Col>
<Col md={6} className="mb-2">
<Form.Label>Firstname</Form.Label>
<Form.Control
required
value={childForm.firstname}
onChange={(e) => setChildForm((prev) => ({ ...prev, firstname: e.target.value }))}
/>
</Col>
<Col md={6} className="mb-2">
<Form.Label>Lastname</Form.Label>
<Form.Control
required
value={childForm.lastname}
onChange={(e) => setChildForm((prev) => ({ ...prev, lastname: e.target.value }))}
/>
</Col>
<Col md={6} className="mb-2">
<Form.Label>Roles</Form.Label>
<Form.Select
required
value={childForm.roles}
onChange={(e) => setChildForm((prev) => ({ ...prev, roles: e.target.value }))}
>
<option value=""> -- Select Roles -- </option>
{Object.entries(roles).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</Form.Select>
</Col>
<Col md={6} className="mb-2">
<Form.Label>Channels</Form.Label>
<Form.Select
value={childForm.channels}
onChange={(e) => setChildForm((prev) => ({ ...prev, channels: e.target.value }))}
>
<option value=""> -- Select Channels -- </option>
{Object.entries(channels).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</Form.Select>
</Col>
</Row>
) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setChildOpen(false)}>
Close
</Button>
<Button type="submit" variant="info" disabled={saving}>
{saving ? 'Saving...' : 'Save changes'}
</Button>
</Modal.Footer>
</Form>
</Modal>
<Modal show={moveOpen} onHide={() => setMoveOpen(false)}>
<Form onSubmit={saveMove}>
<Modal.Header closeButton>
<Modal.Title>Move this position to another</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Label>Position</Form.Label>
<Form.Select
required
value={moveForm.code}
onChange={(e) => setMoveForm((prev) => ({ ...prev, code: e.target.value }))}
>
<option value=""> -- Select Type -- </option>
{Object.entries(positions).map(([code, label]) => (
<option key={code} value={code}>
{label}
</option>
))}
</Form.Select>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setMoveOpen(false)}>
Close
</Button>
<Button type="submit" variant="info" disabled={saving}>
{saving ? 'Saving...' : 'Save changes'}
</Button>
</Modal.Footer>
</Form>
</Modal>
</Container>
);
}