825 lines
27 KiB
JavaScript
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>
|
|
);
|
|
}
|