From c032b0d84f6d4e79895970fcb7997c8798384b49 Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Wed, 23 Sep 2026 14:39:16 +0530 Subject: [PATCH] Workflow Changes --- deploy.sh | 52 ++++ package-lock.json | 233 ++++++++++++++++- package.json | 2 + src/api/config.js | 1 + src/layouts/menu.js | 10 + src/modules/contactattributes.js | 27 +- src/modules/formentitys.js | 49 +++- src/modules/metricviews.js | 63 ++++- src/modules/milestones.js | 10 + src/modules/workflows.js | 72 ++++++ src/routes/index.jsx | 6 + src/views/workflow/Editor.jsx | 415 +++++++++++++++++++++++++++++++ src/views/workflow/Monitor.jsx | 203 +++++++++++++++ src/views/workflow/graph.js | 74 ++++++ vite.config.js | 1 + 15 files changed, 1211 insertions(+), 7 deletions(-) create mode 100755 deploy.sh create mode 100644 src/modules/workflows.js create mode 100644 src/views/workflow/Editor.jsx create mode 100644 src/views/workflow/Monitor.jsx create mode 100644 src/views/workflow/graph.js diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..1e843c9 --- /dev/null +++ b/deploy.sh @@ -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}" diff --git a/package-lock.json b/package-lock.json index f142ce3..edd1c18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/package.json b/package.json index fdc23c4..e4ac5fe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/config.js b/src/api/config.js index cf7395b..e050257 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -17,6 +17,7 @@ export const LOCAL_SERVICES = { rcpa: 3022, events: 3023, contact: 3024, + workflow: 3025, forum: 4002, vgreet: 4003, }; diff --git a/src/layouts/menu.js b/src/layouts/menu.js index edb1466..6b60a25 100644 --- a/src/layouts/menu.js +++ b/src/layouts/menu.js @@ -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')); } diff --git a/src/modules/contactattributes.js b/src/modules/contactattributes.js index 788088c..c685bcf 100644 --- a/src/modules/contactattributes.js +++ b/src/modules/contactattributes.js @@ -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']; } diff --git a/src/modules/formentitys.js b/src/modules/formentitys.js index 52c67eb..5870ec5 100644 --- a/src/modules/formentitys.js +++ b/src/modules/formentitys.js @@ -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') { diff --git a/src/modules/metricviews.js b/src/modules/metricviews.js index ab8e583..bce992b 100644 --- a/src/modules/metricviews.js +++ b/src/modules/metricviews.js @@ -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) diff --git a/src/modules/milestones.js b/src/modules/milestones.js index 8ab04f1..1e52cf3 100644 --- a/src/modules/milestones.js +++ b/src/modules/milestones.js @@ -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; diff --git a/src/modules/workflows.js b/src/modules/workflows.js new file mode 100644 index 0000000..da3d430 --- /dev/null +++ b/src/modules/workflows.js @@ -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; diff --git a/src/routes/index.jsx b/src/routes/index.jsx index 77e760a..1747142 100644 --- a/src/routes/index.jsx +++ b/src/routes/index.jsx @@ -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: }, + { path: '/workflow/monitor', element: }, { path: '/metricreports', element: }, { path: '/metricreport/report', element: }, { path: '/coming-soon', element: }, diff --git a/src/views/workflow/Editor.jsx b/src/views/workflow/Editor.jsx new file mode 100644 index 0000000..15f7fca --- /dev/null +++ b/src/views/workflow/Editor.jsx @@ -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 ( + + Loading workflow + + ); + } + + return ( + + +
+ + + + + + Published v{item?.published_version || 0} + +
+ {message ?
{message}
: null} + {error ?
{error}
: null} + {errors.length ? ( +
    + {errors.map((itemError) => ( +
  • + {itemError.message} + {itemError.nodeIds?.length ? ` (${itemError.nodeIds.join(', ')})` : ''} +
  • + ))} +
+ ) : null} + + + + Add node + + {NODE_TYPES.map((entry) => ( + + ))} + + + + +
+ setSelected(null)} + onNodeClick={(_, node) => setSelected(node.id)} + onEdgeClick={(_, edge) => setSelected(edge.id)} + fitView + > + + + + {selectedNode || selectedEdge ? ( + + + + {selectedNode ? selectedNode.data.label : 'Relation'} + + + ) : ( + <> + + + + )} + + + + ) : null} + +
+ +
+ setTestOpen(false)}> + + Test run + + + + Hierarchy id + setHierarchyId(event.target.value)} /> + + + Context JSON + setContextText(event.target.value)} /> + + + + + + + +
+ ); +} + +function NodeForm({ node, onChange }) { + const config = node.data.config || {}; + const type = node.data.nodeType; + if (type === 'approval') { + return ( + <> + + Mode + onChange({ mode: event.target.value })}> + + + + + + + Approvers + onChange({ approvers: event.target.value.split(',').map((part) => part.trim()).filter(Boolean) })} + /> + l1manager to l4manager, a role id, or a user id. + + onChange({ escalate_vacant: event.target.checked })} + /> + + ); + } + if (type === 'notify') { + return ( + <> + + Recipients + onChange({ recipients: event.target.value.split(',').map((part) => part.trim()).filter(Boolean) })} + /> + + + Subject + onChange({ subject: event.target.value })} /> + + + Body + onChange({ body: event.target.value })} /> + + + ); + } + if (type === 'action') { + return ( + <> + + Action + onChange({ name: event.target.value })}> + + + + + Status + onChange({ status: event.target.value })} /> + + + ); + } + if (type === 'end') { + return ( + + Outcome + onChange({ outcome: event.target.value })}> + + + + + + ); + } + if (type === 'condition') { + return

Set the branch on each outgoing edge. One edge can stay empty as the default.

; + } + return

Start has no settings.

; +} + +function EdgeForm({ edge, onChange }) { + const when = edge.data?.when || { field: '', op: 'eq', value: '' }; + return ( + <> + + Field + onChange({ field: event.target.value })} /> + + + Operator + onChange({ op: event.target.value })}> + {['eq', 'neq', 'in', 'gt', 'lt', 'exists'].map((op) => ( + + ))} + + + + Value + onChange({ value: event.target.value })} /> + + + ); +} diff --git a/src/views/workflow/Monitor.jsx b/src/views/workflow/Monitor.jsx new file mode 100644 index 0000000..12c4939 --- /dev/null +++ b/src/views/workflow/Monitor.jsx @@ -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 ( + + Loading runs + + ); + } + + return ( + + + {error ?
{error}
: null} + + + + Runs + + + + + + + + + + {items.map((row) => ( + { setSelectedId(row.id); loadRun(row.id); }} role="button"> + + + + + ))} + {!items.length ? ( + + + + ) : null} + +
DefinitionStatusWaiting
{row.definition_name || row.definition_id}{row.status}{(row.waiting_for || []).join(', ')}
No runs yet.
+
+ + + {instance ? ( + <> +
+
+ {instance.definition_name} v{instance.definition_version} · {instance.status} + {instance.outcome ? ` · ${instance.outcome}` : ''} +
+ +
+
+ + + + +
+ {canAct && instance.status === 'waiting' ? ( +
+ setComment(event.target.value)} /> + + +
+ ) : null} + + Step log + + + + + + + + + + + + {steps.map((step) => ( + + + + + + + + ))} + +
#EventNodeActorWhen
{step.seq}{step.event}{step.node_id}{step.actor}{step.at}
+
+ + ) : ( +

Select a run to see its graph and log.

+ )} + +
+
+ ); +} diff --git a/src/views/workflow/graph.js b/src/views/workflow/graph.js new file mode 100644 index 0000000..8300d5e --- /dev/null +++ b/src/views/workflow/graph.js @@ -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 {}; +} diff --git a/vite.config.js b/vite.config.js index 71331e3..927fcf3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -18,6 +18,7 @@ const localServices = { rcpa: 3022, events: 3023, contact: 3024, + workflow: 3025, forum: 4002, vgreet: 4003, };