Workflow Changes

This commit is contained in:
Ravi Gulhane 2026-09-23 14:39:16 +05:30
parent 41dd41b304
commit c032b0d84f
15 changed files with 1211 additions and 7 deletions

52
deploy.sh Executable file
View File

@ -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}"

233
package-lock.json generated
View File

@ -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
}
}
}
}
}

View File

@ -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",

View File

@ -17,6 +17,7 @@ export const LOCAL_SERVICES = {
rcpa: 3022,
events: 3023,
contact: 3024,
workflow: 3025,
forum: 4002,
vgreet: 4003,
};

View File

@ -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'));
}

View File

@ -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'];
}

View File

@ -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') {

View File

@ -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)

View File

@ -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;

72
src/modules/workflows.js Normal file
View File

@ -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;

View File

@ -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 /> },

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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 {};
}

View File

@ -18,6 +18,7 @@ const localServices = {
rcpa: 3022,
events: 3023,
contact: 3024,
workflow: 3025,
forum: 4002,
vgreet: 4003,
};