Workflow Changes
This commit is contained in:
parent
41dd41b304
commit
c032b0d84f
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUCKET="rapport-admin"
|
||||
DISTRIBUTION_ID="E1S7BD7JARZ1BJ"
|
||||
export AWS_PROFILE="stratawiztech"
|
||||
export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-ap-south-1}"
|
||||
REGION="$AWS_DEFAULT_REGION"
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
echo "AWS CLI is required. Install it and configure credentials, then run this script again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building (VITE_API_MODE=production)..."
|
||||
VITE_API_MODE=production npm run build
|
||||
|
||||
if [[ ! -f dist/index.html ]]; then
|
||||
echo "Build did not produce dist/index.html."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Uploading hashed assets to s3://${BUCKET}/assets ..."
|
||||
aws s3 sync dist/assets "s3://${BUCKET}/assets" \
|
||||
--region "$REGION" \
|
||||
--delete \
|
||||
--cache-control "public, max-age=31536000, immutable"
|
||||
|
||||
echo "Uploading other static files..."
|
||||
aws s3 sync dist "s3://${BUCKET}" \
|
||||
--region "$REGION" \
|
||||
--delete \
|
||||
--exclude "index.html" \
|
||||
--exclude "assets/*" \
|
||||
--cache-control "public, max-age=3600"
|
||||
|
||||
echo "Uploading index.html..."
|
||||
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
|
||||
--region "$REGION" \
|
||||
--cache-control "no-cache, no-store, must-revalidate" \
|
||||
--content-type "text/html; charset=utf-8"
|
||||
|
||||
echo "Invalidating CloudFront ${DISTRIBUTION_ID}..."
|
||||
INVALIDATION_ID="$(aws cloudfront create-invalidation \
|
||||
--distribution-id "$DISTRIBUTION_ID" \
|
||||
--paths "/*" \
|
||||
--query 'Invalidation.Id' \
|
||||
--output text)"
|
||||
|
||||
echo "Deployed to s3://${BUCKET}. CloudFront invalidation: ${INVALIDATION_ID}"
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"@hookform/resolvers": "^5.1.1",
|
||||
"@rc-component/tour": "^2.1.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@xyflow/react": "^12.11.6",
|
||||
"apexcharts": "^4.7.0",
|
||||
"apextree": "^1.3.0",
|
||||
"axios": "^1.11.0",
|
||||
|
|
@ -2297,6 +2298,55 @@
|
|||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz",
|
||||
"integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -2370,7 +2420,7 @@
|
|||
"version": "19.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
|
||||
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
|
|
@ -2695,6 +2745,48 @@
|
|||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.6.tgz",
|
||||
"integrity": "sha512-9XsEJNHjatKYndszKTF/bsU7FOP9dJ6V/EQwzy3oMdtqgBuUq7BjKSwkEo+C7s4qHstHQfwwoHA3E8QfpPxZZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.82",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=17",
|
||||
"@types/react-dom": ">=17",
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.82",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.82.tgz",
|
||||
"integrity": "sha512-4DKnL3CGtCGLRSmgDqaajRVgeksMXq/Yw4wPfdMfm7JvdIiWHGzVYOfFUztiATwdBXZqUU6HhehwUdbV9G23PQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@yr/monotone-cubic-spline": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
|
||||
|
|
@ -3195,6 +3287,12 @@
|
|||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
|
|
@ -3362,6 +3460,111 @@
|
|||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/datatables.net": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-2.3.3.tgz",
|
||||
|
|
@ -7254,6 +7457,34 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"deploy": "bash deploy.sh",
|
||||
"format": "prettier --write './src/**/*.{ts,tsx,js,jsx}'"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"@hookform/resolvers": "^5.1.1",
|
||||
"@rc-component/tour": "^2.1.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@xyflow/react": "^12.11.6",
|
||||
"apexcharts": "^4.7.0",
|
||||
"apextree": "^1.3.0",
|
||||
"axios": "^1.11.0",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const LOCAL_SERVICES = {
|
|||
rcpa: 3022,
|
||||
events: 3023,
|
||||
contact: 3024,
|
||||
workflow: 3025,
|
||||
forum: 4002,
|
||||
vgreet: 4003,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -244,6 +244,8 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
|
|||
'formexports_get_all',
|
||||
'quiz_get_all',
|
||||
'roles_leaderboard_get_all',
|
||||
'workflow_definition_get_all',
|
||||
'workflow_instance_get_all',
|
||||
])
|
||||
) {
|
||||
const formChildren = [
|
||||
|
|
@ -257,6 +259,14 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
|
|||
child('leaderboards', 'Leaderboard', '/leaderboards', 'roles_leaderboard_get_all'),
|
||||
].filter((item) => !item.permission || permissions.includes(item.permission));
|
||||
|
||||
if (hasAny(permissions, ['workflow_definition_get_all', 'workflow_instance_get_all'])) {
|
||||
formChildren.push(
|
||||
...[
|
||||
child('workflows', 'Workflows', '/workflows', 'workflow_definition_get_all'),
|
||||
child('workflow-monitor', 'Workflow monitor', '/workflow/monitor', 'workflow_instance_get_all'),
|
||||
].filter((item) => !item.permission || permissions.includes(item.permission))
|
||||
);
|
||||
}
|
||||
if (domain === 'titans' && permissions.includes('all_data_access')) {
|
||||
formChildren.push(child('titans-hcp', 'Titans HCP Data Upload', '/titanshcpgatheringreports'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,9 +29,10 @@ const contactattributes = {
|
|||
{ key: 'status', title: 'Status', type: 'select' },
|
||||
],
|
||||
formFields: async ({ item, mode } = {}) => {
|
||||
const [listRes, users] = await Promise.all([
|
||||
const [listRes, users, workflowRes] = await Promise.all([
|
||||
signedGet('contact', 'contactattribute/get_all'),
|
||||
dropdownMap('ums', 'users', (user) => `${user.firstname || ''} ${user.lastname || ''} (${user.employee_code || ''})`),
|
||||
signedGet('workflow', 'workflow_definition/get_all'),
|
||||
]);
|
||||
const exclusionOptions = {};
|
||||
const tabOptions = {};
|
||||
|
|
@ -53,6 +54,13 @@ const contactattributes = {
|
|||
l4manager: 'L4 Manager',
|
||||
...users,
|
||||
};
|
||||
const workflowDefinitions = { '': 'None' };
|
||||
const workflowItems = isSuccess(workflowRes) ? workflowRes.data?.items || [] : [];
|
||||
workflowItems.forEach((definition) => {
|
||||
if (!definition?.id || !definition.published_version) return;
|
||||
if (definition.entity && definition.entity !== 'contact_approval' && definition.entity !== 'manual') return;
|
||||
workflowDefinitions[definition.id] = `${definition.name} (v${definition.published_version})`;
|
||||
});
|
||||
|
||||
return [
|
||||
{ key: 'attribute', title: 'Attribute', type: 'text', required: true },
|
||||
|
|
@ -98,6 +106,13 @@ const contactattributes = {
|
|||
{ key: 'inclusions', title: 'Inclusions', type: 'multiselect', values: exclusionOptions },
|
||||
{ key: 'status', title: 'Status', type: 'select', required: true, values: { active: 'Active', disabled: 'Disabled' } },
|
||||
{ key: 'workflow', title: 'Enable Workflow', type: 'yesno', required: true, default: 'no' },
|
||||
{
|
||||
key: 'workflow_definition_id',
|
||||
title: 'Workflow definition',
|
||||
type: 'select',
|
||||
values: workflowDefinitions,
|
||||
workflowOnly: true,
|
||||
},
|
||||
{
|
||||
key: 'send_approval_to_manager_if_approver_vacant',
|
||||
title: 'Send Approval Notification to Manager if Approver is Vacant',
|
||||
|
|
@ -142,6 +157,7 @@ const contactattributes = {
|
|||
tab: item.tab || '',
|
||||
tag_instructions: item.tag_instructions || '',
|
||||
workflow: item.workflow || 'no',
|
||||
workflow_definition_id: item.workflow_definition_id || '',
|
||||
send_approval_to_manager_if_approver_vacant: item.send_approval_to_manager_if_approver_vacant || 'no',
|
||||
workflow_type: item.workflow_type || 'single',
|
||||
workflow_approvers:
|
||||
|
|
@ -156,7 +172,7 @@ const contactattributes = {
|
|||
editable: item.editable || 'yes',
|
||||
options: Array.isArray(item.options) ? item.options : [],
|
||||
}),
|
||||
beforeSave: (data) => {
|
||||
beforeSave: (data, mode) => {
|
||||
const payload = { ...data };
|
||||
payload.exclusions = Array.isArray(payload.exclusions)
|
||||
? payload.exclusions.filter(Boolean).map(String).join(',')
|
||||
|
|
@ -181,10 +197,17 @@ const contactattributes = {
|
|||
payload.workflow_approvers = Array.isArray(payload.workflow_approvers)
|
||||
? payload.workflow_approvers.filter(Boolean)
|
||||
: [];
|
||||
payload.workflow_definition_id = payload.workflow_definition_id || '';
|
||||
if (mode === 'new' && payload.workflow === 'yes' && !payload.workflow_definition_id) {
|
||||
throw new Error('New contact attributes with workflow must use a published workflow definition');
|
||||
}
|
||||
if (payload.workflow !== 'yes') {
|
||||
payload.workflow_type = 'single';
|
||||
payload.workflow_approvers = [];
|
||||
payload.workflow_definition_id = '';
|
||||
payload.send_approval_to_manager_if_approver_vacant = 'no';
|
||||
} else if (payload.workflow_definition_id) {
|
||||
payload.workflow_approvers = [];
|
||||
} else if (!payload.workflow_approvers.length) {
|
||||
payload.workflow_approvers = ['l1manager'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,10 +55,12 @@ const formentitys = {
|
|||
{ key: 'status', title: 'Status', type: 'select' },
|
||||
],
|
||||
formFields: async () => {
|
||||
const [usersRes, roles, contactRes] = await Promise.all([
|
||||
const [usersRes, roles, contactRes, userAttrRes, workflowRes] = await Promise.all([
|
||||
signedGet('ums', 'users/dropdown'),
|
||||
dropdownMap('ums', 'roles', (item) => item.name),
|
||||
signedGet('contact', 'contactattribute/get_all'),
|
||||
signedGet('ums', 'userattribute/get_all'),
|
||||
signedGet('workflow', 'workflow_definition/get_all'),
|
||||
]);
|
||||
const approvers = {
|
||||
l1manager: 'L1 Manager',
|
||||
|
|
@ -79,6 +81,19 @@ const formentitys = {
|
|||
contactItems.forEach((attr) => {
|
||||
if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code;
|
||||
});
|
||||
const userAttributes = {};
|
||||
const userAttrItems = isSuccess(userAttrRes) ? userAttrRes.data?.items || [] : [];
|
||||
userAttrItems.forEach((attr) => {
|
||||
if (!attr?.code || attr.status === 'disabled') return;
|
||||
userAttributes[attr.code] = attr.attribute || attr.code;
|
||||
});
|
||||
const workflowDefinitions = { '': 'None' };
|
||||
const workflowItems = isSuccess(workflowRes) ? workflowRes.data?.items || [] : [];
|
||||
workflowItems.forEach((definition) => {
|
||||
if (!definition?.id || !definition.published_version) return;
|
||||
if (definition.entity && definition.entity !== 'form_submission' && definition.entity !== 'manual') return;
|
||||
workflowDefinitions[definition.id] = `${definition.name} (v${definition.published_version})`;
|
||||
});
|
||||
return [
|
||||
{ key: 'title', title: 'Title', type: 'text', required: true },
|
||||
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
|
||||
|
|
@ -99,6 +114,15 @@ const formentitys = {
|
|||
values: { l1manager: 'L1 Manager', l2manager: 'L2 Manager', l3manager: 'L3Manager', l4manager: 'L4 Manager' },
|
||||
},
|
||||
{ key: 'contact_attributes', title: 'Include Doctor Attributes in Export', type: 'multiselect', values: contactAttributes },
|
||||
{ key: 'show_user_attributes', title: 'Show User Attributes', type: 'yesno', required: true, default: 'no' },
|
||||
{
|
||||
key: 'user_attributes',
|
||||
title: 'Include User Attributes in Export',
|
||||
type: 'multiselect',
|
||||
values: userAttributes,
|
||||
default: [],
|
||||
showIf: { show_user_attributes: 'yes' },
|
||||
},
|
||||
{
|
||||
key: 'form_type',
|
||||
title: 'Enter From Type',
|
||||
|
|
@ -132,6 +156,13 @@ const formentitys = {
|
|||
{ key: 'roles_info', title: 'Roles Info', type: 'yesno', required: true, default: 'no' },
|
||||
{ key: 'allow_non_hierarchy_submission', title: 'Allow Non Hierarchy Submission', type: 'yesno', required: true },
|
||||
{ key: 'workflow', title: 'Enable Workflow', type: 'yesno', required: true },
|
||||
{
|
||||
key: 'workflow_definition_id',
|
||||
title: 'Workflow definition',
|
||||
type: 'select',
|
||||
values: workflowDefinitions,
|
||||
showIf: { workflow: 'yes' },
|
||||
},
|
||||
{
|
||||
key: 'workflow_type',
|
||||
title: 'Workflow type',
|
||||
|
|
@ -153,7 +184,7 @@ const formentitys = {
|
|||
{ key: 'auto_approve', title: 'Want To Auto Approve', type: 'yesno', required: true },
|
||||
{ key: 'auto_approve_condition', title: 'Auto Approve Condition If Any', type: 'textarea', fullwidth: true },
|
||||
{ key: 'export_fields', title: 'Export Fields', type: 'textarea', fullwidth: true },
|
||||
{ key: 'workflow_approvers', title: 'Metric', type: 'mapping', required: true, approvers, fullwidth: true },
|
||||
{ key: 'workflow_approvers', title: 'Metric', type: 'mapping', approvers, fullwidth: true },
|
||||
{ key: 'email_notification', title: 'Email Notification', type: 'yesno', required: true, default: 'no', fullwidth: true },
|
||||
{
|
||||
key: 'email_notification_rules',
|
||||
|
|
@ -200,9 +231,12 @@ const formentitys = {
|
|||
next.manager_data_child_type = asArray(next.manager_data_child_type);
|
||||
next.export_manager_levels = asArray(next.export_manager_levels);
|
||||
next.contact_attributes = asArray(next.contact_attributes);
|
||||
next.show_user_attributes = yesno(next.show_user_attributes, 'no');
|
||||
next.user_attributes = asArray(next.user_attributes);
|
||||
next.roles_email_notification = asArray(next.roles_email_notification);
|
||||
next.roles_push_notification = asArray(next.roles_push_notification);
|
||||
next.workflow_approvers = Array.isArray(next.workflow_approvers) && next.workflow_approvers.length ? next.workflow_approvers : ['l1manager'];
|
||||
next.workflow_definition_id = next.workflow_definition_id || '';
|
||||
return next;
|
||||
},
|
||||
beforeSave: async (data, mode) => {
|
||||
|
|
@ -211,12 +245,23 @@ const formentitys = {
|
|||
payload.export_manager_levels = asArray(payload.export_manager_levels);
|
||||
payload.manager_de_roles = asArray(payload.manager_de_roles);
|
||||
payload.contact_attributes = asArray(payload.contact_attributes);
|
||||
payload.show_user_attributes = payload.show_user_attributes === 'yes' ? 'yes' : 'no';
|
||||
payload.user_attributes = asArray(payload.user_attributes);
|
||||
payload.enrollment_form_attributes = asArray(payload.enrollment_form_attributes);
|
||||
payload.roles_email_notification = asArray(payload.roles_email_notification);
|
||||
payload.roles_push_notification = asArray(payload.roles_push_notification);
|
||||
payload.workflow_approvers = Array.isArray(payload.workflow_approvers)
|
||||
? payload.workflow_approvers.filter(Boolean)
|
||||
: [];
|
||||
payload.workflow_definition_id = payload.workflow_definition_id || '';
|
||||
if (mode === 'new' && payload.workflow === 'yes' && !payload.workflow_definition_id) {
|
||||
throw new Error('New forms with workflow must use a published workflow definition');
|
||||
}
|
||||
if (payload.workflow_definition_id) {
|
||||
payload.workflow_approvers = [];
|
||||
} else if (payload.workflow === 'yes' && !payload.workflow_approvers.length) {
|
||||
payload.workflow_approvers = ['l1manager'];
|
||||
}
|
||||
payload.email_notification = payload.email_notification === 'yes' ? 'yes' : 'no';
|
||||
payload.roles_info = payload.roles_info === 'yes' ? 'yes' : 'no';
|
||||
if (payload.email_notification === 'no') {
|
||||
|
|
|
|||
|
|
@ -48,16 +48,23 @@ const metricviews = {
|
|||
sort_order: item.sort_order ?? item.widget_sort_order ?? 0,
|
||||
})),
|
||||
formFields: async () => {
|
||||
const [metrics, roles, contactRes] = await Promise.all([
|
||||
const [metrics, roles, contactRes, userAttrRes] = await Promise.all([
|
||||
metricOptions(),
|
||||
dropdownMap('ums', 'roles', (item) => item.name),
|
||||
signedGet('contact', 'contactattribute/get_all'),
|
||||
signedGet('ums', 'userattribute/get_all'),
|
||||
]);
|
||||
const contactAttributes = {};
|
||||
const contactItems = isSuccess(contactRes) ? contactRes.data?.items || [] : [];
|
||||
contactItems.forEach((attr) => {
|
||||
if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code;
|
||||
});
|
||||
const userAttributes = {};
|
||||
const userAttrItems = isSuccess(userAttrRes) ? userAttrRes.data?.items || [] : [];
|
||||
userAttrItems.forEach((attr) => {
|
||||
if (!attr?.code || attr.status === 'disabled') return;
|
||||
userAttributes[attr.code] = attr.attribute || attr.code;
|
||||
});
|
||||
const versesMetrics = { ...metrics, fixed: 'Fixed Value' };
|
||||
return [
|
||||
{ key: 'name', title: 'Name', type: 'text', required: true },
|
||||
|
|
@ -117,11 +124,16 @@ const metricviews = {
|
|||
type: 'multiselect',
|
||||
values: { progress: 'Progress', percentile: 'Percentile', pie: 'Pie chart', bar: 'Bar chart', line: 'Line chart' },
|
||||
},
|
||||
{
|
||||
key: 'value_views',
|
||||
title: 'Value views',
|
||||
type: 'multiselect',
|
||||
values: { value: 'Value' },
|
||||
},
|
||||
{
|
||||
key: 'details_views',
|
||||
title: 'Details views',
|
||||
type: 'multiselect',
|
||||
required: true,
|
||||
values: { tabular: 'Tabular', contacts: 'Doctor View', incremental: 'Incremental View' },
|
||||
},
|
||||
{
|
||||
|
|
@ -132,6 +144,12 @@ const metricviews = {
|
|||
},
|
||||
{
|
||||
key: 'leaderboard_views',
|
||||
title: 'My Team views',
|
||||
type: 'multiselect',
|
||||
values: { leaderboard: 'My Team View' },
|
||||
},
|
||||
{
|
||||
key: 'scoreboard_views',
|
||||
title: 'Leaderboard views',
|
||||
type: 'multiselect',
|
||||
values: { leaderboard: 'Leaderboard View' },
|
||||
|
|
@ -145,6 +163,20 @@ const metricviews = {
|
|||
{ key: 'doctor_view_name', title: 'Enter Doctor View Name', type: 'text' },
|
||||
{ key: 'intervals', title: 'Intervals', type: 'multiselect', required: true, values: INTERVAL_VALUES },
|
||||
{ key: 'default_interval', title: 'Default Interval', type: 'select', required: true, values: DEFAULT_INTERVAL_VALUES },
|
||||
{
|
||||
key: 'default_tab',
|
||||
title: 'Default Tab',
|
||||
type: 'select',
|
||||
values: {
|
||||
value: 'Value',
|
||||
trend: 'Trend',
|
||||
details: 'Detail',
|
||||
leaderboard: 'My Team',
|
||||
scoreboard: 'Leaderboard',
|
||||
contacts: 'Doctor List',
|
||||
incremental: 'Incremental',
|
||||
},
|
||||
},
|
||||
{ key: 'widget_sort_order', title: 'Sort Order', type: 'number', required: true },
|
||||
{ key: 'doctor_type', title: 'Doctor Type', type: 'text' },
|
||||
{
|
||||
|
|
@ -167,6 +199,15 @@ const metricviews = {
|
|||
values: { all: 'All', loggedin: 'Logged In' },
|
||||
},
|
||||
{ key: 'contact_attributes', title: 'Include Doctor Attributes', type: 'multiselect', values: contactAttributes },
|
||||
{ key: 'show_user_attributes', title: 'Show User Attributes', type: 'yesno', required: true, default: 'no' },
|
||||
{
|
||||
key: 'user_attributes',
|
||||
title: 'Include User Attributes',
|
||||
type: 'multiselect',
|
||||
values: userAttributes,
|
||||
default: [],
|
||||
showIf: { show_user_attributes: 'yes' },
|
||||
},
|
||||
];
|
||||
},
|
||||
beforeForm: (item) => {
|
||||
|
|
@ -176,7 +217,11 @@ const metricviews = {
|
|||
sort_order: 0,
|
||||
show_positions: 'yes',
|
||||
show_positions_type: 'all',
|
||||
show_user_attributes: 'no',
|
||||
user_attributes: [],
|
||||
list_chart_type: 'progressbar',
|
||||
value_views: ['value'],
|
||||
default_tab: '',
|
||||
...item,
|
||||
};
|
||||
next.designations = asArray(next.designations);
|
||||
|
|
@ -185,9 +230,18 @@ const metricviews = {
|
|||
next.details_views = asArray(next.details_views);
|
||||
next.trend_views = asArray(next.trend_views);
|
||||
next.leaderboard_views = asArray(next.leaderboard_views);
|
||||
next.scoreboard_views = item && Object.prototype.hasOwnProperty.call(item, 'scoreboard_views')
|
||||
? asArray(next.scoreboard_views)
|
||||
: asArray(next.leaderboard_views);
|
||||
next.value_views = item && Object.prototype.hasOwnProperty.call(item, 'value_views')
|
||||
? asArray(next.value_views)
|
||||
: ['value'];
|
||||
next.contacts_views = asArray(next.contacts_views);
|
||||
next.default_tab = next.default_tab || '';
|
||||
next.intervals = asArray(next.intervals);
|
||||
next.contact_attributes = asArray(next.contact_attributes);
|
||||
next.show_user_attributes = next.show_user_attributes === 'yes' ? 'yes' : 'no';
|
||||
next.user_attributes = asArray(next.user_attributes);
|
||||
next.combined_metrics = parseCombinedMetrics(next.combined_metrics);
|
||||
next.validity_start = toDateInput(next.validity_start);
|
||||
next.validity_end = toDateInput(next.validity_end);
|
||||
|
|
@ -249,9 +303,14 @@ const metricviews = {
|
|||
details_views: asArray(data.details_views),
|
||||
trend_views: asArray(data.trend_views),
|
||||
leaderboard_views: asArray(data.leaderboard_views),
|
||||
scoreboard_views: asArray(data.scoreboard_views),
|
||||
value_views: asArray(data.value_views),
|
||||
contacts_views: asArray(data.contacts_views),
|
||||
default_tab: data.default_tab || '',
|
||||
intervals: asArray(data.intervals),
|
||||
contact_attributes: asArray(data.contact_attributes),
|
||||
show_user_attributes: data.show_user_attributes === 'yes' ? 'yes' : 'no',
|
||||
user_attributes: asArray(data.user_attributes),
|
||||
combined_metrics: stringifyCombinedMetrics(
|
||||
(data.combined_metrics || [])
|
||||
.filter((row) => row.type)
|
||||
|
|
|
|||
|
|
@ -248,6 +248,13 @@ const milestones = {
|
|||
values: YES_NO,
|
||||
showIf: { send_report: 'yes' },
|
||||
},
|
||||
{
|
||||
key: 'ai_action_items',
|
||||
title: 'AI Action Items',
|
||||
type: 'select',
|
||||
values: YES_NO,
|
||||
showIf: { send_report: 'yes' },
|
||||
},
|
||||
{
|
||||
key: 'send_pending_users',
|
||||
title: 'Send Pending Users',
|
||||
|
|
@ -315,6 +322,7 @@ const milestones = {
|
|||
next.send_report_roles = toStringList(reportConfig.send_report_roles);
|
||||
next.report_roles = toStringList(reportConfig.report_roles);
|
||||
next.send_user_performance = fromBool(reportConfig.send_user_performance ?? false);
|
||||
next.ai_action_items = fromBool(reportConfig.ai_action_items ?? false);
|
||||
next.send_pending_users = fromBool(reportConfig.send_pending_users ?? false);
|
||||
next.show_target_average = fromBool(reportConfig.show_target_average ?? false);
|
||||
next.show_overall_average = fromBool(reportConfig.show_overall_average ?? false);
|
||||
|
|
@ -342,6 +350,7 @@ const milestones = {
|
|||
send_report_roles: toStringList(payload.send_report_roles),
|
||||
report_roles: toStringList(payload.report_roles),
|
||||
send_user_performance: toBool(payload.send_user_performance ?? 'no'),
|
||||
ai_action_items: toBool(payload.ai_action_items ?? 'no'),
|
||||
send_pending_users: toBool(payload.send_pending_users ?? 'no'),
|
||||
show_target_average: toBool(payload.show_target_average ?? 'no'),
|
||||
show_overall_average: toBool(payload.show_overall_average ?? 'no'),
|
||||
|
|
@ -357,6 +366,7 @@ const milestones = {
|
|||
delete payload.send_report_roles;
|
||||
delete payload.report_roles;
|
||||
delete payload.send_user_performance;
|
||||
delete payload.ai_action_items;
|
||||
delete payload.send_pending_users;
|
||||
delete payload.show_target_average;
|
||||
delete payload.show_overall_average;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { isSuccess, signedGet } from '@/api/client';
|
||||
|
||||
const workflows = {
|
||||
service: 'workflow',
|
||||
module: 'workflow_definition',
|
||||
title: 'Workflows',
|
||||
subtitle: 'Automation',
|
||||
baseUrl: '/workflow_definition',
|
||||
listPath: '/workflows',
|
||||
listFields: [
|
||||
{
|
||||
key: 'actions',
|
||||
title: 'Actions',
|
||||
buttons: [
|
||||
'edit',
|
||||
'delete',
|
||||
{ name: 'editor', to: (row) => `/workflow/editor?id=${row.id}`, title: 'Editor' },
|
||||
{ name: 'monitor', to: (row) => `/workflow/monitor?definition_id=${row.id}`, title: 'Monitor' },
|
||||
],
|
||||
},
|
||||
{ key: 'name', title: 'Name', type: 'text' },
|
||||
{ key: 'entity', title: 'Entity', type: 'text' },
|
||||
{ key: 'published_version', title: 'Published', type: 'text' },
|
||||
{ key: 'status', title: 'Status', type: 'text' },
|
||||
],
|
||||
formFields: async () => {
|
||||
const formsRes = await signedGet('form', 'formentity/get_all');
|
||||
const forms = {};
|
||||
const formItems = isSuccess(formsRes) ? formsRes.data?.items || [] : [];
|
||||
formItems.forEach((form) => {
|
||||
if (!form?.id) return;
|
||||
forms[form.id] = form.title || form.id;
|
||||
});
|
||||
return [
|
||||
{ key: 'name', title: 'Name', type: 'text', required: true },
|
||||
{ key: 'description', title: 'Description', type: 'textarea' },
|
||||
{
|
||||
key: 'entity',
|
||||
title: 'Entity',
|
||||
type: 'select',
|
||||
required: true,
|
||||
values: {
|
||||
manual: 'Manual',
|
||||
form_submission: 'Form submission',
|
||||
contact_approval: 'Contact approval',
|
||||
},
|
||||
default: 'manual',
|
||||
},
|
||||
{
|
||||
key: 'form_id',
|
||||
title: 'Form',
|
||||
type: 'select',
|
||||
required: true,
|
||||
values: forms,
|
||||
showIf: { entity: 'form_submission' },
|
||||
},
|
||||
];
|
||||
},
|
||||
beforeForm: (item) => ({
|
||||
...item,
|
||||
entity: item.entity || 'manual',
|
||||
description: item.description || '',
|
||||
form_id: item.form_id || '',
|
||||
}),
|
||||
beforeSave: (data) => {
|
||||
const payload = { ...data };
|
||||
if (payload.entity !== 'form_submission') payload.form_id = '';
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflows;
|
||||
|
|
@ -43,6 +43,7 @@ import formactions from '@/modules/formactions';
|
|||
import formexports from '@/modules/formexports';
|
||||
import reminders from '@/modules/reminders';
|
||||
import leaderboards from '@/modules/leaderboards';
|
||||
import workflows from '@/modules/workflows';
|
||||
|
||||
const Login = lazy(() => import('@/views/auth/Login'));
|
||||
const LoginOtp = lazy(() => import('@/views/auth/LoginOtp'));
|
||||
|
|
@ -60,6 +61,8 @@ const HierarchyTree = lazy(() => import('@/views/organisation/HierarchyTree'));
|
|||
const ImportWizard = lazy(() => import('@/views/organisation/ImportWizard'));
|
||||
const TeamPlayers = lazy(() => import('@/views/organisation/TeamPlayers'));
|
||||
const MagMediaViewers = lazy(() => import('@/views/content/MagMediaViewers'));
|
||||
const WorkflowEditor = lazy(() => import('@/views/workflow/Editor'));
|
||||
const WorkflowMonitor = lazy(() => import('@/views/workflow/Monitor'));
|
||||
|
||||
function moduleRoutes(config) {
|
||||
const routes = [
|
||||
|
|
@ -150,6 +153,9 @@ export const routes = [
|
|||
...moduleRoutes(formexports),
|
||||
...moduleRoutes(reminders),
|
||||
...moduleRoutes(leaderboards),
|
||||
...moduleRoutes(workflows),
|
||||
{ path: '/workflow/editor', element: <WorkflowEditor /> },
|
||||
{ path: '/workflow/monitor', element: <WorkflowMonitor /> },
|
||||
{ path: '/metricreports', element: <MetricReportList /> },
|
||||
{ path: '/metricreport/report', element: <MetricReport /> },
|
||||
{ path: '/coming-soon', element: <ComingSoon /> },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,415 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
ReactFlow,
|
||||
addEdge,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Button, Card, Col, Container, Form, Modal, Row, Spinner } from 'react-bootstrap';
|
||||
import PageBreadcrumb from '@/components/PageBreadcrumb';
|
||||
import { isSuccess, signedGet, signedPost } from '@/api/client';
|
||||
import { defaultConfig, fromFlow, labelFor, toFlow, NODE_TYPES, edgeLabel } from '@/views/workflow/graph';
|
||||
|
||||
function newId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export default function WorkflowEditor() {
|
||||
const [params] = useSearchParams();
|
||||
const id = params.get('id') || '';
|
||||
const [item, setItem] = useState(null);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [contextText, setContextText] = useState('{}');
|
||||
const [hierarchyId, setHierarchyId] = useState('');
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const response = await signedGet('workflow', 'workflow_definition/get_one', { id });
|
||||
if (!isSuccess(response)) {
|
||||
setError(response?.message || 'Unable to load workflow');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const definition = response.data.item;
|
||||
const flow = toFlow(definition);
|
||||
setItem(definition);
|
||||
setNodes(flow.nodes);
|
||||
setEdges(flow.edges);
|
||||
setLoading(false);
|
||||
};
|
||||
if (id) load();
|
||||
}, [id, setEdges, setNodes]);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find((node) => node.id === selected && node.data),
|
||||
[nodes, selected]
|
||||
);
|
||||
const selectedEdge = useMemo(() => edges.find((edge) => edge.id === selected), [edges, selected]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection) => {
|
||||
setEdges((current) =>
|
||||
addEdge(
|
||||
{
|
||||
...connection,
|
||||
id: newId(),
|
||||
data: { when: null },
|
||||
},
|
||||
current
|
||||
)
|
||||
);
|
||||
},
|
||||
[setEdges]
|
||||
);
|
||||
|
||||
const addNode = (type) => {
|
||||
const config = defaultConfig(type);
|
||||
const node = {
|
||||
id: newId(),
|
||||
position: { x: 120 + nodes.length * 24, y: 80 + nodes.length * 24 },
|
||||
data: { nodeType: type, config, label: labelFor({ type, config }) },
|
||||
};
|
||||
setNodes((current) => current.concat(node));
|
||||
};
|
||||
|
||||
const patchNode = (config) => {
|
||||
setNodes((current) =>
|
||||
current.map((node) => {
|
||||
if (node.id !== selectedNode.id) return node;
|
||||
const next = { ...node.data.config, ...config };
|
||||
return { ...node, data: { ...node.data, config: next, label: labelFor({ type: node.data.nodeType, config: next }) } };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const deleteNode = (nodeId) => {
|
||||
setNodes((current) => current.filter((node) => node.id !== nodeId));
|
||||
setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const deleteEdge = (edgeId) => {
|
||||
setEdges((current) => current.filter((edge) => edge.id !== edgeId));
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const onNodesDelete = useCallback((deleted) => {
|
||||
const ids = new Set(deleted.map((node) => node.id));
|
||||
setEdges((current) => current.filter((edge) => !ids.has(edge.source) && !ids.has(edge.target)));
|
||||
setSelected(null);
|
||||
}, [setEdges]);
|
||||
|
||||
const patchEdge = (when) => {
|
||||
setEdges((current) =>
|
||||
current.map((edge) => {
|
||||
if (edge.id !== selectedEdge.id) return edge;
|
||||
const next = { ...(edge.data?.when || {}), ...when };
|
||||
const stored = next.field ? next : null;
|
||||
return { ...edge, data: { when: stored }, label: edgeLabel(stored) };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const persist = async (publish) => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
setErrors([]);
|
||||
const graph = fromFlow(nodes, edges);
|
||||
const saved = await signedPost('workflow', 'workflow_definition/update', {
|
||||
id,
|
||||
name: item.name,
|
||||
entity: item.entity,
|
||||
description: item.description || '',
|
||||
...graph,
|
||||
});
|
||||
if (!isSuccess(saved)) {
|
||||
setError(saved?.message || 'Save failed');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
if (!publish) {
|
||||
setMessage('Draft saved');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const published = await signedPost('workflow', 'workflow_definition/publish', { id });
|
||||
if (!isSuccess(published)) {
|
||||
setErrors(published?.data?.errors || []);
|
||||
setError(published?.message || 'Publish failed');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
setItem((current) => ({ ...current, published_version: published.data.item.published_version }));
|
||||
setMessage(`Published version ${published.data.item.published_version}`);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
setError('');
|
||||
setMessage('');
|
||||
let context = {};
|
||||
try {
|
||||
context = contextText.trim() ? JSON.parse(contextText) : {};
|
||||
} catch {
|
||||
setError('Test context must be JSON');
|
||||
return;
|
||||
}
|
||||
const response = await signedPost('workflow', 'workflow_instance/start', {
|
||||
definition_id: id,
|
||||
entity: 'manual',
|
||||
entity_id: `test-${Date.now()}`,
|
||||
hierarchy_id: hierarchyId,
|
||||
context,
|
||||
});
|
||||
if (!isSuccess(response)) {
|
||||
setError(response?.message || 'Test run failed');
|
||||
return;
|
||||
}
|
||||
window.location.assign(`/workflow/monitor?id=${response.data.item.id}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container className="py-4">
|
||||
<Spinner animation="border" size="sm" /> Loading workflow
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container fluid className="py-3">
|
||||
<PageBreadcrumb title={item?.name || 'Workflow'} subtitle="Workflows" />
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
<Button as={Link} to="/workflows" variant="light">Back</Button>
|
||||
<Button disabled={saving} onClick={() => persist(false)}>Save draft</Button>
|
||||
<Button disabled={saving} variant="primary" onClick={() => persist(true)}>Publish</Button>
|
||||
<Button variant="outline-primary" onClick={() => setTestOpen(true)}>Test run</Button>
|
||||
<span className="text-muted align-self-center">
|
||||
Published v{item?.published_version || 0}
|
||||
</span>
|
||||
</div>
|
||||
{message ? <div className="alert alert-success">{message}</div> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{errors.length ? (
|
||||
<ul className="text-danger">
|
||||
{errors.map((itemError) => (
|
||||
<li key={`${itemError.message}-${(itemError.nodeIds || []).join(',')}`}>
|
||||
{itemError.message}
|
||||
{itemError.nodeIds?.length ? ` (${itemError.nodeIds.join(', ')})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<Row>
|
||||
<Col md={2}>
|
||||
<Card>
|
||||
<Card.Header>Add node</Card.Header>
|
||||
<Card.Body className="d-grid gap-2">
|
||||
{NODE_TYPES.map((entry) => (
|
||||
<Button key={entry.type} variant="outline-secondary" onClick={() => addNode(entry.type)}>
|
||||
{entry.label}
|
||||
</Button>
|
||||
))}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<div style={{ height: '70vh', border: '1px solid #dee2e6', borderRadius: 8 }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodesDelete={onNodesDelete}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
edgesReconnectable
|
||||
onPaneClick={() => setSelected(null)}
|
||||
onNodeClick={(_, node) => setSelected(node.id)}
|
||||
onEdgeClick={(_, edge) => setSelected(edge.id)}
|
||||
fitView
|
||||
>
|
||||
<MiniMap />
|
||||
<Controls />
|
||||
<Background />
|
||||
{selectedNode || selectedEdge ? (
|
||||
<Panel position="top-right">
|
||||
<Card style={{ width: 320 }}>
|
||||
<Card.Header className="d-flex align-items-center justify-content-between">
|
||||
<span>{selectedNode ? selectedNode.data.label : 'Relation'}</span>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={() => setSelected(null)} />
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<NodeForm node={selectedNode} onChange={patchNode} />
|
||||
<Button variant="outline-danger" className="mt-3" onClick={() => deleteNode(selectedNode.id)}>
|
||||
Delete node
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EdgeForm edge={selectedEdge} onChange={patchEdge} />
|
||||
<Button variant="outline-danger" className="mt-3" onClick={() => deleteEdge(selectedEdge.id)}>
|
||||
Delete relation
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Panel>
|
||||
) : null}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Modal show={testOpen} onHide={() => setTestOpen(false)}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Test run</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Hierarchy id</Form.Label>
|
||||
<Form.Control value={hierarchyId} onChange={(event) => setHierarchyId(event.target.value)} />
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Context JSON</Form.Label>
|
||||
<Form.Control as="textarea" rows={8} value={contextText} onChange={(event) => setContextText(event.target.value)} />
|
||||
</Form.Group>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="light" onClick={() => setTestOpen(false)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={runTest} disabled={!item?.published_version}>
|
||||
Run published version
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeForm({ node, onChange }) {
|
||||
const config = node.data.config || {};
|
||||
const type = node.data.nodeType;
|
||||
if (type === 'approval') {
|
||||
return (
|
||||
<>
|
||||
<Form.Group className="mb-2">
|
||||
<Form.Label>Mode</Form.Label>
|
||||
<Form.Select value={config.mode || 'single'} onChange={(event) => onChange({ mode: event.target.value })}>
|
||||
<option value="single">Single</option>
|
||||
<option value="all">All</option>
|
||||
<option value="sequential">Sequential</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-2">
|
||||
<Form.Label>Approvers</Form.Label>
|
||||
<Form.Control
|
||||
value={(config.approvers || []).join(', ')}
|
||||
onChange={(event) => onChange({ approvers: event.target.value.split(',').map((part) => part.trim()).filter(Boolean) })}
|
||||
/>
|
||||
<Form.Text>l1manager to l4manager, a role id, or a user id.</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Check
|
||||
label="Escalate when the approver seat is vacant"
|
||||
checked={config.escalate_vacant !== false}
|
||||
onChange={(event) => onChange({ escalate_vacant: event.target.checked })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (type === 'notify') {
|
||||
return (
|
||||
<>
|
||||
<Form.Group className="mb-2">
|
||||
<Form.Label>Recipients</Form.Label>
|
||||
<Form.Control
|
||||
value={(config.recipients || []).join(', ')}
|
||||
onChange={(event) => onChange({ recipients: event.target.value.split(',').map((part) => part.trim()).filter(Boolean) })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-2">
|
||||
<Form.Label>Subject</Form.Label>
|
||||
<Form.Control value={config.subject || ''} onChange={(event) => onChange({ subject: event.target.value })} />
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Body</Form.Label>
|
||||
<Form.Control as="textarea" rows={3} value={config.body || ''} onChange={(event) => onChange({ body: event.target.value })} />
|
||||
</Form.Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (type === 'action') {
|
||||
return (
|
||||
<>
|
||||
<Form.Group className="mb-2">
|
||||
<Form.Label>Action</Form.Label>
|
||||
<Form.Select value={config.name || 'set_status'} onChange={(event) => onChange({ name: event.target.value })}>
|
||||
<option value="set_status">Set status</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Status</Form.Label>
|
||||
<Form.Control value={config.status || ''} onChange={(event) => onChange({ status: event.target.value })} />
|
||||
</Form.Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (type === 'end') {
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Label>Outcome</Form.Label>
|
||||
<Form.Select value={config.outcome || 'completed'} onChange={(event) => onChange({ outcome: event.target.value })}>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="completed">Completed</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
if (type === 'condition') {
|
||||
return <p className="text-muted mb-0">Set the branch on each outgoing edge. One edge can stay empty as the default.</p>;
|
||||
}
|
||||
return <p className="text-muted mb-0">Start has no settings.</p>;
|
||||
}
|
||||
|
||||
function EdgeForm({ edge, onChange }) {
|
||||
const when = edge.data?.when || { field: '', op: 'eq', value: '' };
|
||||
return (
|
||||
<>
|
||||
<Form.Group className="mb-2">
|
||||
<Form.Label>Field</Form.Label>
|
||||
<Form.Control value={when.field || ''} onChange={(event) => onChange({ field: event.target.value })} />
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-2">
|
||||
<Form.Label>Operator</Form.Label>
|
||||
<Form.Select value={when.op || 'eq'} onChange={(event) => onChange({ op: event.target.value })}>
|
||||
{['eq', 'neq', 'in', 'gt', 'lt', 'exists'].map((op) => (
|
||||
<option key={op} value={op}>{op}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Value</Form.Label>
|
||||
<Form.Control value={when.value ?? ''} onChange={(event) => onChange({ value: event.target.value })} />
|
||||
</Form.Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Background, Controls, ReactFlow } from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Button, Card, Col, Container, Row, Spinner, Table } from 'react-bootstrap';
|
||||
import PageBreadcrumb from '@/components/PageBreadcrumb';
|
||||
import { isSuccess, signedGet, signedPost } from '@/api/client';
|
||||
import { getSession } from '@/auth/storage';
|
||||
import { toFlow } from '@/views/workflow/graph';
|
||||
|
||||
export default function WorkflowMonitor() {
|
||||
const [params] = useSearchParams();
|
||||
const initialId = params.get('id') || '';
|
||||
const definitionId = params.get('definition_id') || '';
|
||||
const [items, setItems] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(initialId);
|
||||
const [instance, setInstance] = useState(null);
|
||||
const [definition, setDefinition] = useState(null);
|
||||
const [steps, setSteps] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const loadList = async () => {
|
||||
const query = definitionId ? { definition_id: definitionId } : {};
|
||||
const response = await signedGet('workflow', 'workflow_instance/get_all', query);
|
||||
if (!isSuccess(response)) {
|
||||
setError(response?.message || 'Unable to load runs');
|
||||
return [];
|
||||
}
|
||||
const rows = response.data?.items || [];
|
||||
setItems(rows);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const loadRun = async (id) => {
|
||||
if (!id) {
|
||||
setInstance(null);
|
||||
setDefinition(null);
|
||||
setSteps([]);
|
||||
return;
|
||||
}
|
||||
const [one, log] = await Promise.all([
|
||||
signedGet('workflow', 'workflow_instance/get_one', { id }),
|
||||
signedGet('workflow', 'workflow_step/list', { instance_id: id }),
|
||||
]);
|
||||
if (!isSuccess(one)) {
|
||||
setError(one?.message || 'Unable to load run');
|
||||
return;
|
||||
}
|
||||
const row = one.data.item;
|
||||
setInstance(row);
|
||||
setSteps(isSuccess(log) ? log.data?.items || [] : []);
|
||||
const versionId = `${row.definition_id}#v${row.definition_version}`;
|
||||
const graph = await signedGet('workflow', 'workflow_definition/get_one', { id: versionId });
|
||||
setDefinition(isSuccess(graph) ? graph.data.item : null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const boot = async () => {
|
||||
setLoading(true);
|
||||
const rows = await loadList();
|
||||
const next = initialId || rows[0]?.id || '';
|
||||
setSelectedId(next);
|
||||
await loadRun(next);
|
||||
setLoading(false);
|
||||
};
|
||||
boot();
|
||||
}, [definitionId, initialId]);
|
||||
|
||||
const flow = useMemo(() => {
|
||||
if (!definition) return { nodes: [], edges: [] };
|
||||
const mapped = toFlow(definition);
|
||||
return {
|
||||
...mapped,
|
||||
nodes: mapped.nodes.map((node) => ({
|
||||
...node,
|
||||
style: node.id === instance?.current_node_id ? { border: '2px solid #0d6efd', borderRadius: 8 } : undefined,
|
||||
})),
|
||||
};
|
||||
}, [definition, instance]);
|
||||
|
||||
const session = getSession() || {};
|
||||
const user = session.user || {};
|
||||
const canAct = (instance?.waiting_for || []).some(
|
||||
(token) => token === user.id || (session.roles || user.roles || []).includes(token)
|
||||
);
|
||||
|
||||
const signal = async (action) => {
|
||||
setError('');
|
||||
const response = await signedPost('workflow', 'workflow_instance/signal', {
|
||||
instance_id: instance.id,
|
||||
action,
|
||||
comment,
|
||||
});
|
||||
if (!isSuccess(response)) {
|
||||
setError(response?.message || 'Unable to update the run');
|
||||
return;
|
||||
}
|
||||
await loadList();
|
||||
await loadRun(instance.id);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container className="py-4">
|
||||
<Spinner animation="border" size="sm" /> Loading runs
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container fluid className="py-3">
|
||||
<PageBreadcrumb title="Workflow monitor" subtitle="Workflows" />
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<Row>
|
||||
<Col md={4}>
|
||||
<Card>
|
||||
<Card.Header>Runs</Card.Header>
|
||||
<Table responsive hover className="mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Definition</th>
|
||||
<th>Status</th>
|
||||
<th>Waiting</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={row.id} onClick={() => { setSelectedId(row.id); loadRun(row.id); }} role="button">
|
||||
<td className={row.id === selectedId ? 'fw-bold' : ''}>{row.definition_name || row.definition_id}</td>
|
||||
<td>{row.status}</td>
|
||||
<td>{(row.waiting_for || []).join(', ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!items.length ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-muted">No runs yet.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col md={8}>
|
||||
{instance ? (
|
||||
<>
|
||||
<div className="d-flex justify-content-between mb-2">
|
||||
<div>
|
||||
<strong>{instance.definition_name}</strong> v{instance.definition_version} · {instance.status}
|
||||
{instance.outcome ? ` · ${instance.outcome}` : ''}
|
||||
</div>
|
||||
<Button as={Link} to={`/workflow/editor?id=${instance.definition_id}`} variant="light" size="sm">
|
||||
Open definition
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ height: 360, border: '1px solid #dee2e6', borderRadius: 8 }} className="mb-3">
|
||||
<ReactFlow nodes={flow.nodes} edges={flow.edges} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} fitView>
|
||||
<Controls showInteractive={false} />
|
||||
<Background />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{canAct && instance.status === 'waiting' ? (
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
<input className="form-control" placeholder="Comment" value={comment} onChange={(event) => setComment(event.target.value)} />
|
||||
<Button onClick={() => signal('approve')}>Approve</Button>
|
||||
<Button variant="danger" onClick={() => signal('reject')}>Reject</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Card>
|
||||
<Card.Header>Step log</Card.Header>
|
||||
<Table responsive className="mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Event</th>
|
||||
<th>Node</th>
|
||||
<th>Actor</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{steps.map((step) => (
|
||||
<tr key={step.id}>
|
||||
<td>{step.seq}</td>
|
||||
<td>{step.event}</td>
|
||||
<td>{step.node_id}</td>
|
||||
<td>{step.actor}</td>
|
||||
<td>{step.at}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted">Select a run to see its graph and log.</p>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
export const NODE_TYPES = [
|
||||
{ type: 'start', label: 'Start' },
|
||||
{ type: 'condition', label: 'Condition' },
|
||||
{ type: 'approval', label: 'Approval' },
|
||||
{ type: 'notify', label: 'Notify' },
|
||||
{ type: 'action', label: 'Action' },
|
||||
{ type: 'end', label: 'End' },
|
||||
];
|
||||
|
||||
export function labelFor(node) {
|
||||
const type = node.type || node.data?.nodeType;
|
||||
const config = node.config || node.data?.config || {};
|
||||
if (type === 'approval') return `Approval (${config.mode || 'single'})`;
|
||||
if (type === 'end') return `End (${config.outcome || 'completed'})`;
|
||||
if (type === 'notify') return 'Notify';
|
||||
if (type === 'action') return config.name || 'Action';
|
||||
if (type === 'condition') return 'Condition';
|
||||
return 'Start';
|
||||
}
|
||||
|
||||
export function edgeLabel(when) {
|
||||
if (!when || !when.field) return '';
|
||||
return `${when.field} ${when.op || 'eq'} ${when.value ?? ''}`;
|
||||
}
|
||||
|
||||
export function toFlow(definition) {
|
||||
const layout = definition.layout || {};
|
||||
return {
|
||||
nodes: (definition.nodes || []).map((node, index) => ({
|
||||
id: node.id,
|
||||
position: layout[node.id] || { x: 80 + (index % 3) * 220, y: 80 + Math.floor(index / 3) * 140 },
|
||||
data: {
|
||||
label: labelFor(node),
|
||||
nodeType: node.type,
|
||||
config: node.config || {},
|
||||
},
|
||||
})),
|
||||
edges: (definition.edges || []).map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edgeLabel(edge.when),
|
||||
data: { when: edge.when || null },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function fromFlow(nodes, edges) {
|
||||
return {
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.data.nodeType,
|
||||
config: node.data.config || {},
|
||||
})),
|
||||
edges: edges.map((edge) => {
|
||||
const when = edge.data && edge.data.when && edge.data.when.field ? edge.data.when : null;
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
...(when ? { when } : {}),
|
||||
};
|
||||
}),
|
||||
layout: Object.fromEntries(nodes.map((node) => [node.id, node.position])),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultConfig(type) {
|
||||
if (type === 'approval') return { mode: 'sequential', approvers: ['l1manager'], escalate_vacant: true };
|
||||
if (type === 'notify') return { recipients: ['l1manager'], subject: '', body: '' };
|
||||
if (type === 'action') return { name: 'set_status', status: 'approved' };
|
||||
if (type === 'end') return { outcome: 'completed' };
|
||||
return {};
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ const localServices = {
|
|||
rcpa: 3022,
|
||||
events: 3023,
|
||||
contact: 3024,
|
||||
workflow: 3025,
|
||||
forum: 4002,
|
||||
vgreet: 4003,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue