This commit is contained in:
Ravi Gulhane 2026-10-22 13:03:29 +05:30
parent 212cf0091d
commit ef3bb15db5
14 changed files with 596 additions and 78 deletions

View File

@ -19,9 +19,11 @@ import {
TbListDetails,
TbPencil,
TbPhoto,
TbPlayerPlay,
TbPlaylist,
TbRefresh,
TbSchool,
TbSitemap,
TbTrash,
TbTrophy,
TbUsers,
@ -96,6 +98,8 @@ const ACTION_ICONS = {
topic: TbBook,
viewers: TbEye,
duplicate: TbCopy,
editor: TbSitemap,
monitor: TbPlayerPlay,
report: TbChartBar,
questions: TbHelpCircle,
responses: TbPlaylist,
@ -238,6 +242,25 @@ export default function EntityList({ module: config }) {
};
}, [config.module]);
const onDuplicate = async (row) => {
if (typeof config.duplicateItem !== 'function') return;
setBusyAction(`duplicate-${row.id}`);
setError('');
try {
const result = await config.duplicateItem(row);
if (!result?.ok) {
setError(result?.message || 'Unable to duplicate');
return;
}
setMessage(result.message || 'Duplicated');
load();
} catch (err) {
setError(err.message || 'Unable to duplicate');
} finally {
setBusyAction('');
}
};
const onDelete = async (id) => {
if (!window.confirm('Delete this record?')) return;
const path = `${config.module}/delete`;
@ -404,6 +427,20 @@ export default function EntityList({ module: config }) {
);
}
if (button.name === 'duplicate') {
if (typeof config.duplicateItem === 'function') {
return (
<Button
key="duplicate"
size="sm"
variant="soft-info"
title="Duplicate"
disabled={busyAction === `duplicate-${row.id}`}
onClick={() => onDuplicate(row)}
>
<TbCopy />
</Button>
);
}
const href =
typeof button.to === 'function' ? button.to(row) : button.to || `${config.baseUrl}/addnew?duplicate=${row.id}`;
return (

View File

@ -11,6 +11,11 @@ import {
const hasAny = (permissions, keys) => keys.some((key) => permissions.includes(key));
const ZENOVA_DASHBOARD_ROLES = [
'1650360764-424692c0-654a-4af2-zenova-64ce55c66088', // HO
'1787808155-9945ee91-7a22-4be6-b184-733610e66ddc', // Admin
];
const child = (key, label, url, permission, extra = {}) => ({
key,
label,
@ -109,6 +114,15 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
});
}
if (domain === 'zenova' && roles.some((role) => ZENOVA_DASHBOARD_ROLES.includes(role))) {
items.push({
key: 'glipiq-sitazit',
label: 'Custom Dashboard',
icon: LuFileText,
url: '/zenovareport/dashboard',
});
}
if (domain === 'pulmocare' && roles.includes('1650360330-5bf35594-674a-4f31-HO_Audit-47738d2151ab')) {
items.push({
key: 'healthy-lung',
@ -266,23 +280,18 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
'formaction_get_all',
'all_data_access',
'formexports_get_all',
'quiz_get_all',
'roles_leaderboard_get_all',
'workflow_definition_get_all',
'workflow_instance_get_all',
'formproduct_get_all',
])
) {
const formChildren = [
child('forms', 'Forms', '/formentitys', 'formentity_get_all'),
child('formhooks', 'Form Hook', '/formhooks', 'form_hooks_get_all'),
child('surveys', 'Survey', '/surveys', 'survey_get_all'),
child('quizs', 'Quiz', '/quizs', 'quiz_get_all'),
child('formactions', 'Quick Action', '/formactions', 'formaction_get_all'),
child('formexports', 'Form Data Export', '/formexports', 'formexports_get_all'),
child('reminders', 'Reminders', '/reminders', 'reminder_get_all'),
child('leaderboards', 'Leaderboard', '/leaderboards', 'roles_leaderboard_get_all'),
child('formproducts', 'Products', '/formproducts', 'formproduct_get_all'),
].filter((item) => !item.permission || permissions.includes(item.permission));
if (hasAny(permissions, ['workflow_definition_get_all', 'workflow_instance_get_all'])) {

View File

@ -7,14 +7,7 @@ function splitHash(value) {
}
async function formOptions() {
const values = {};
const [forms, surveys, quizzes] = await Promise.all([
dropdownMap('form', 'formentity', (item) => item.title || item.name),
dropdownMap('form', 'survey', (item) => `${item.title || item.name} (Survey)`),
dropdownMap('form', 'quiz', (item) => `${item.name || item.title} (Quiz)`),
]);
Object.assign(values, forms, surveys, quizzes);
return values;
return dropdownMap('form', 'formentity', (item) => item.title || item.name);
}
const banners = {
@ -68,8 +61,6 @@ const banners = {
values: {
internal_action: 'Internal Action',
form: 'Web Form',
survey: 'Surveys',
quiz: 'Quizs',
doctorlist: 'Doctor List',
external: 'External Webpage',
external_with_token: 'External Webpage with Token',

View File

@ -4,14 +4,7 @@ import { splitHash } from '@/helpers/metric';
const STATUS = { active: 'Active', disabled: 'Disabled' };
async function formOptions() {
const values = {};
const [forms, surveys, quizzes] = await Promise.all([
dropdownMap('form', 'formentity', (item) => item.title || item.name),
dropdownMap('form', 'survey', (item) => `${item.title || item.name} (Survey)`),
dropdownMap('form', 'quiz', (item) => `${item.name || item.title} (Quiz)`),
]);
Object.assign(values, forms, surveys, quizzes);
return values;
return dropdownMap('form', 'formentity', (item) => item.title || item.name);
}
function joinHash(value) {
@ -52,8 +45,6 @@ const formactions = {
required: true,
values: {
form: 'Web Form',
survey: 'Surveys',
quiz: 'Quizs',
doctorlist: 'Doctor List',
external: 'External Webpage',
internal_action: 'Internal Action',

View File

@ -1,3 +1,4 @@
import { signedGet, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns';
import {
DEFAULT_INTERVAL_VALUES,
@ -10,6 +11,12 @@ import {
const STATUS = { active: 'Active', disabled: 'Disabled' };
const YES_NO = { yes: 'Yes', no: 'No' };
const MANAGER_LEVELS = {
l1manager: 'L1 Manager',
l2manager: 'L2 Manager',
l3manager: 'L3 Manager',
l4manager: 'L4 Manager',
};
function asArray(value) {
if (Array.isArray(value)) return value.filter((part) => part !== null && part !== undefined && part !== '');
@ -39,7 +46,17 @@ const leaderboards = {
sort_order: item.sort_order ?? 0,
})),
formFields: async () => {
const [metrics, roles] = await Promise.all([metricOptions(), dropdownMap('ums', 'roles', (item) => item.name)]);
const [metrics, roles, userAttrRes] = await Promise.all([
metricOptions(),
dropdownMap('ums', 'roles', (item) => item.name),
signedGet('ums', 'userattribute/get_all'),
]);
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;
});
return [
{ key: 'name', title: 'Name', type: 'text', required: true },
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true },
@ -68,6 +85,20 @@ const leaderboards = {
required: true,
values: { all: 'All', loggedin: 'Logged In' },
},
{ key: 'show_units_export', title: 'Show Units Export', type: 'select', required: true, values: YES_NO, default: 'yes' },
{ key: 'show_user_attributes', title: 'Show User Attributes', type: 'select', required: true, values: YES_NO, default: 'no' },
{
key: 'user_attributes',
title: 'Include User Attributes',
type: 'multiselect',
values: userAttributes,
default: [],
showIf: { show_user_attributes: 'yes' },
},
{ key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
{ key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS, default: ['l1manager'] },
{ key: 'total_units_name', title: 'Total Units Name', type: 'text', default: '' },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
];
},
@ -76,6 +107,14 @@ const leaderboards = {
next.sort_order = next.sort_order ?? 0;
next.show_positions = next.show_positions || 'no';
next.show_positions_type = next.show_positions_type || 'all';
next.show_units_export = next.show_units_export || 'yes';
next.show_user_attributes = next.show_user_attributes === 'yes' ? 'yes' : 'no';
next.user_attributes = asArray(next.user_attributes);
next.show_normal_metrics = next.show_normal_metrics || 'yes';
next.show_combined_metrics = next.show_combined_metrics || 'yes';
next.manager_details = asArray(next.manager_details);
if (!next.manager_details.length) next.manager_details = ['l1manager'];
next.total_units_name = next.total_units_name || '';
next.status = next.status || 'active';
next.default_interval = next.default_interval ?? '';
next.validity_start = toDateInput(next.validity_start);
@ -119,6 +158,14 @@ const leaderboards = {
weight: row.weight === '' || row.weight == null ? '1' : String(row.weight),
}));
payload.combined_metrics = Array.isArray(payload.combined_metrics) ? payload.combined_metrics : [];
payload.show_units_export = payload.show_units_export || 'yes';
payload.show_user_attributes = payload.show_user_attributes === 'yes' ? 'yes' : 'no';
payload.user_attributes = asArray(payload.user_attributes);
payload.show_normal_metrics = payload.show_normal_metrics || 'yes';
payload.show_combined_metrics = payload.show_combined_metrics || 'yes';
payload.manager_details = asArray(payload.manager_details);
if (!payload.manager_details.length) payload.manager_details = ['l1manager'];
payload.total_units_name = payload.total_units_name || '';
return payload;
},
};

View File

@ -2,18 +2,11 @@ import { signedGet, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns';
const FILE_TYPES = ['image', 'video', 'pdf', 'audio', 'scorm'];
const FORM_TYPES = ['form', 'quiz', 'survey'];
const FORM_TYPES = ['form'];
const LINK_TYPES = ['form', 'external'];
async function formOptions() {
const values = {};
const [forms, surveys, quizzes] = await Promise.all([
dropdownMap('form', 'formentity', (item) => item.title || item.name),
dropdownMap('form', 'survey', (item) => `${item.title || item.name} (Survey)`),
dropdownMap('form', 'quiz', (item) => `${item.name || item.title} (Quiz)`),
]);
Object.assign(values, forms, surveys, quizzes);
return values;
return dropdownMap('form', 'formentity', (item) => item.title || item.name);
}
async function metricOptions() {
@ -102,8 +95,6 @@ const magmedias = {
audio: 'Audio',
gallery: 'Gallery',
form: 'Form',
quiz: 'Quiz',
survey: 'Survey',
external: 'External Url',
scorm: 'Scorm',
},

View File

@ -67,6 +67,12 @@ function normalizeCombined(row) {
}
const YES_NO = { yes: 'Yes', no: 'No' };
const MANAGER_LEVELS = {
l1manager: 'L1 Manager',
l2manager: 'L2 Manager',
l3manager: 'L3 Manager',
l4manager: 'L4 Manager',
};
const metriccampaigns = {
service: 'metric',
@ -96,6 +102,7 @@ const metriccampaigns = {
signedGet('ums', 'userattribute/get_all'),
]);
const attributes = {};
const userAttributes = {};
const attributesMeta = {};
const attrItems = isSuccess(attrsRes) ? attrsRes.data?.items || [] : [];
attrItems.forEach((attr) => {
@ -109,6 +116,7 @@ const metriccampaigns = {
options[value] = String(opt.display ?? value);
});
attributes[code] = `${attr.attribute || code} (${code})`;
userAttributes[code] = attr.attribute || code;
attributesMeta[code] = {
code,
attribute: attr.attribute || code,
@ -171,6 +179,20 @@ const metriccampaigns = {
required: true,
values: { all: 'All', loggedin: 'Logged In' },
},
{ key: 'show_units_export', title: 'Show Units Export', type: 'select', required: true, values: YES_NO, default: 'yes' },
{ key: 'show_user_attributes', title: 'Show User Attributes', type: 'select', required: true, values: YES_NO, default: 'no' },
{
key: 'user_attributes',
title: 'Include User Attributes',
type: 'multiselect',
values: userAttributes,
default: [],
showIf: { show_user_attributes: 'yes' },
},
{ key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
{ key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS, default: ['l1manager'] },
{ key: 'total_units_name', title: 'Total Units Name', type: 'text', default: '' },
{ key: 'highlight_top', title: 'Highlight Top', type: 'text', placeholder: 'e.g. 10, 20, 30' },
{ key: 'highlight_color', title: 'Highlight Color', type: 'text', placeholder: 'R-G-B-A or CSS color' },
{
@ -214,6 +236,14 @@ const metriccampaigns = {
next.validity_end = toDateInput(next.validity_end);
if (next.teams_rank) next.teams_rank = String(next.teams_rank).toLowerCase().slice(0, 2);
if (next.sort_order === '' || next.sort_order == null) next.sort_order = 0;
next.show_units_export = next.show_units_export || 'yes';
next.show_user_attributes = next.show_user_attributes === 'yes' ? 'yes' : 'no';
next.user_attributes = asArray(next.user_attributes);
next.show_normal_metrics = next.show_normal_metrics || 'yes';
next.show_combined_metrics = next.show_combined_metrics || 'yes';
next.manager_details = asArray(next.manager_details);
if (!next.manager_details.length) next.manager_details = ['l1manager'];
next.total_units_name = next.total_units_name || '';
return next;
},
beforeSave: (data) => {
@ -254,6 +284,13 @@ const metriccampaigns = {
reporting_roles: asArray(data.reporting_roles),
intervals: asArray(data.intervals),
sort_order: Number.parseInt(data.sort_order, 10) || 0,
show_units_export: data.show_units_export || 'yes',
show_user_attributes: data.show_user_attributes === 'yes' ? 'yes' : 'no',
user_attributes: asArray(data.user_attributes),
show_normal_metrics: data.show_normal_metrics || 'yes',
show_combined_metrics: data.show_combined_metrics || 'yes',
manager_details: asArray(data.manager_details).length ? asArray(data.manager_details) : ['l1manager'],
total_units_name: data.total_units_name || '',
validity_start: toApiDate(data.validity_start),
validity_end: toApiDate(data.validity_end),
};

View File

@ -189,8 +189,10 @@ const metricviews = {
types: VIEW_COMBINED_TYPES,
},
{ key: 'report_roles', title: 'Data Report from Roles', type: 'multiselect', required: true, values: roles },
{ key: 'team_leaderboard_widget_roles', title: 'Team Leaderboard Widget Roles', type: 'multiselect', values: roles },
{ key: 'status', title: 'Status', type: 'select', required: true, values: { active: 'Active', disabled: 'Disabled' } },
{ key: 'show_positions', title: 'Show Positions', type: 'select', required: true, values: { yes: 'Yes', no: 'No' } },
{ key: 'stateheadwise_leaderboard', title: 'Stateheadwise Leaderboard', type: 'select', required: true, values: { yes: 'Yes', no: 'No' }, default: 'no' },
{
key: 'show_positions_type',
title: 'Show Positions Type',
@ -218,6 +220,7 @@ const metricviews = {
show_positions: 'yes',
show_positions_type: 'all',
show_user_attributes: 'no',
stateheadwise_leaderboard: 'no',
user_attributes: [],
list_chart_type: 'progressbar',
value_views: ['value'],
@ -226,6 +229,7 @@ const metricviews = {
};
next.designations = asArray(next.designations);
next.report_roles = asArray(next.report_roles);
next.team_leaderboard_widget_roles = asArray(next.team_leaderboard_widget_roles);
next.graphical_views = asArray(next.graphical_views);
next.details_views = asArray(next.details_views);
next.trend_views = asArray(next.trend_views);
@ -241,6 +245,7 @@ const metricviews = {
next.intervals = asArray(next.intervals);
next.contact_attributes = asArray(next.contact_attributes);
next.show_user_attributes = next.show_user_attributes === 'yes' ? 'yes' : 'no';
next.stateheadwise_leaderboard = next.stateheadwise_leaderboard === 'yes' ? 'yes' : 'no';
next.user_attributes = asArray(next.user_attributes);
next.combined_metrics = parseCombinedMetrics(next.combined_metrics);
next.validity_start = toDateInput(next.validity_start);
@ -299,6 +304,7 @@ const metricviews = {
metrics,
designations: asArray(data.designations),
report_roles: asArray(data.report_roles),
team_leaderboard_widget_roles: asArray(data.team_leaderboard_widget_roles),
graphical_views: asArray(data.graphical_views),
details_views: asArray(data.details_views),
trend_views: asArray(data.trend_views),
@ -310,6 +316,7 @@ const metricviews = {
intervals: asArray(data.intervals),
contact_attributes: asArray(data.contact_attributes),
show_user_attributes: data.show_user_attributes === 'yes' ? 'yes' : 'no',
stateheadwise_leaderboard: data.stateheadwise_leaderboard === 'yes' ? 'yes' : 'no',
user_attributes: asArray(data.user_attributes),
combined_metrics: stringifyCombinedMetrics(
(data.combined_metrics || [])

View File

@ -4,6 +4,12 @@ import { metricOptions, toDateInput } from '@/helpers/metric';
const STATUS = { active: 'Active', disabled: 'Disabled' };
const YES_NO = { yes: 'Yes', no: 'No' };
const MANAGER_LEVELS = {
l1manager: 'L1 Manager',
l2manager: 'L2 Manager',
l3manager: 'L3 Manager',
l4manager: 'L4 Manager',
};
const DAYS = {
MON: 'Monday',
TUE: 'Tuesday',
@ -153,7 +159,7 @@ const milestones = {
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: async () => {
const [metrics, roles, badges, categories, images] = await Promise.all([
const [metrics, roles, badges, categories, images, userAttrRes] = await Promise.all([
metricOptions(),
dropdownMap('ums', 'roles', (item) => item.name),
getAllItems('recognition', 'badge_list/get_all').then(titleMap),
@ -165,7 +171,14 @@ const milestones = {
});
return values;
}),
signedGet('ums', 'userattribute/get_all'),
]);
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;
});
return [
{ key: 'title', title: 'Title', type: 'text', required: true },
{ key: 'interval_start_date', title: 'Interval Start Date', type: 'date', required: true },
@ -195,6 +208,20 @@ const milestones = {
required: true,
values: { all: 'All', loggedin: 'Logged In' },
},
{ key: 'show_units_export', title: 'Show Units Export', type: 'select', required: true, values: YES_NO, default: 'yes' },
{ key: 'show_user_attributes', title: 'Show User Attributes', type: 'select', required: true, values: YES_NO, default: 'no' },
{
key: 'user_attributes',
title: 'Include User Attributes',
type: 'multiselect',
values: userAttributes,
default: [],
showIf: { show_user_attributes: 'yes' },
},
{ key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
{ key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS, default: ['l1manager'] },
{ key: 'total_units_name', title: 'Total Units Name', type: 'text', default: '' },
{ key: 'send_report', title: 'Send Report', type: 'select', required: true, values: YES_NO },
{
key: 'performance_period',
@ -327,6 +354,14 @@ const milestones = {
next.show_target_average = fromBool(reportConfig.show_target_average ?? false);
next.show_overall_average = fromBool(reportConfig.show_overall_average ?? false);
next.send_report = fromBool(next.send_report ?? false);
next.show_units_export = next.show_units_export || 'yes';
next.show_user_attributes = next.show_user_attributes === 'yes' ? 'yes' : 'no';
next.user_attributes = toStringList(next.user_attributes);
next.show_normal_metrics = next.show_normal_metrics || 'yes';
next.show_combined_metrics = next.show_combined_metrics || 'yes';
next.manager_details = toStringList(next.manager_details);
if (!next.manager_details.length) next.manager_details = ['l1manager'];
next.total_units_name = next.total_units_name || '';
return next;
},
beforeSave: (data) => {
@ -340,6 +375,14 @@ const milestones = {
payload.milestones = mapMilestonesForSave(payload.milestones);
const daysOrDates = payload.days_or_dates || 'days';
payload.show_units_export = payload.show_units_export || 'yes';
payload.show_user_attributes = payload.show_user_attributes === 'yes' ? 'yes' : 'no';
payload.user_attributes = toStringList(payload.user_attributes);
payload.show_normal_metrics = payload.show_normal_metrics || 'yes';
payload.show_combined_metrics = payload.show_combined_metrics || 'yes';
payload.manager_details = toStringList(payload.manager_details);
if (!payload.manager_details.length) payload.manager_details = ['l1manager'];
payload.total_units_name = payload.total_units_name || '';
payload.send_report = toBool(payload.send_report ?? 'no');
payload.report_config = {
days_or_dates: daysOrDates,

View File

@ -8,7 +8,7 @@ const roles = {
baseUrl: '/role',
listPath: '/roles',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'actions', title: 'Actions', buttons: ['edit', 'duplicate', 'delete'] },
{ key: 'name', title: 'Role Name', type: 'text' },
{ key: 'code', title: 'Role Code', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
@ -31,14 +31,20 @@ const roles = {
{ key: 'permissions', title: 'permissions', type: 'bigmultiselect', required: true, values: permissions, fullwidth: true },
];
},
beforeForm: (item) => ({
...item,
permissions: Array.isArray(item.permissions)
? item.permissions.map(String)
: String(item.permissions || '')
.split(/[#~,]/)
.filter(Boolean),
}),
beforeForm: (item, _mode, context) => {
const next = {
...item,
permissions: Array.isArray(item.permissions)
? item.permissions.map(String)
: String(item.permissions || '')
.split(/[#~,]/)
.filter(Boolean),
};
if (context?.duplicate && next.code && !String(next.code).endsWith('-copy')) {
next.code = `${next.code}-copy`;
}
return next;
},
};
export default roles;

View File

@ -1,4 +1,4 @@
import { isSuccess, signedGet } from '@/api/client';
import { isSuccess, signedGet, signedPost } from '@/api/client';
const workflows = {
service: 'workflow',
@ -15,11 +15,13 @@ const workflows = {
'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' },
{ name: 'monitor', to: (row) => `/workflow/monitor?definition_id=${row.id}`, title: 'Runs' },
'duplicate',
],
},
{ key: 'name', title: 'Name', type: 'text' },
{ key: 'entity', title: 'Entity', type: 'text' },
{ key: 'run_on', title: 'Run on', type: 'text' },
{ key: 'published_version', title: 'Published', type: 'text' },
{ key: 'status', title: 'Status', type: 'text' },
],
@ -54,6 +56,15 @@ const workflows = {
values: forms,
showIf: { entity: 'form_submission' },
},
{
key: 'run_on',
title: 'Run on',
type: 'select',
required: true,
values: { insert: 'Insert', update: 'Update', both: 'Both' },
default: 'insert',
showIf: { entity: 'form_submission' },
},
];
},
beforeForm: (item) => ({
@ -61,10 +72,33 @@ const workflows = {
entity: item.entity || 'manual',
description: item.description || '',
form_id: item.form_id || '',
run_on: item.id ? (item.run_on || '') : (item.run_on || 'insert'),
}),
duplicateItem: async (row) => {
const response = await signedGet('workflow', 'workflow_definition/get_one', { id: row.id });
if (!isSuccess(response)) return { ok: false, message: response?.message || 'Unable to load workflow' };
const item = response.data?.item || {};
const created = await signedPost('workflow', 'workflow_definition/create', {
name: `${item.name || 'Workflow'} (Copy)`,
description: item.description || '',
entity: item.entity || 'manual',
form_id: item.entity === 'form_submission' ? item.form_id || '' : '',
...(item.entity === 'form_submission' ? { run_on: item.run_on || '' } : {}),
nodes: item.nodes || [],
edges: item.edges || [],
layout: item.layout || {},
});
if (!isSuccess(created)) return { ok: false, message: created?.message || 'Unable to duplicate workflow' };
return { ok: true, message: 'Workflow duplicated' };
},
beforeSave: (data) => {
const payload = { ...data };
if (payload.entity !== 'form_submission') payload.form_id = '';
if (payload.entity !== 'form_submission') {
payload.form_id = '';
delete payload.run_on;
} else if (!payload.run_on) {
delete payload.run_on;
}
return payload;
},
};

View File

@ -35,10 +35,10 @@ import milestones from '@/modules/milestones';
import formentitys from '@/modules/formentitys';
import formquestions from '@/modules/formquestions';
import formhooks from '@/modules/formhooks';
import surveys from '@/modules/surveys';
import surveyquestions from '@/modules/surveyquestions';
import quizs from '@/modules/quizs';
import quizquestions from '@/modules/quizquestions';
// import surveys from '@/modules/surveys';
// import surveyquestions from '@/modules/surveyquestions';
// import quizs from '@/modules/quizs';
// import quizquestions from '@/modules/quizquestions';
import formactions from '@/modules/formactions';
import formexports from '@/modules/formexports';
import reminders from '@/modules/reminders';
@ -56,7 +56,7 @@ import rcpaproductattributes from '@/modules/rcpaproductattributes';
import brands from '@/modules/brands';
import products from '@/modules/products';
import productattributes from '@/modules/productattributes';
import formproducts from '@/modules/formproducts';
// import formproducts from '@/modules/formproducts';
import formpersonalizations from '@/modules/formpersonalizations';
import courses from '@/modules/courses';
import lessons from '@/modules/lessons';
@ -169,10 +169,10 @@ export const routes = [
...moduleRoutes(formentitys),
...moduleRoutes(formquestions),
...moduleRoutes(formhooks),
...moduleRoutes(surveys),
...moduleRoutes(surveyquestions),
...moduleRoutes(quizs),
...moduleRoutes(quizquestions),
// ...moduleRoutes(surveys),
// ...moduleRoutes(surveyquestions),
// ...moduleRoutes(quizs),
// ...moduleRoutes(quizquestions),
...moduleRoutes(formactions),
...moduleRoutes(formexports),
...moduleRoutes(reminders),
@ -193,7 +193,7 @@ export const routes = [
...moduleRoutes(brands),
...moduleRoutes(products),
...moduleRoutes(productattributes),
...moduleRoutes(formproducts),
// ...moduleRoutes(formproducts),
...moduleRoutes(formpersonalizations),
...moduleRoutes(courses),
...moduleRoutes(lessons),

View File

@ -12,14 +12,50 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Button, Card, Col, Container, Form, Modal, Row, Spinner } from 'react-bootstrap';
import Select from 'react-select';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { isSuccess, signedGet, signedPost } from '@/api/client';
import { defaultConfig, fromFlow, labelFor, toFlow, NODE_TYPES, edgeLabel } from '@/views/workflow/graph';
import { dropdownMap } from '@/api/dropdowns';
import { defaultConfig, fromFlow, labelFor, toFlow, NODE_TYPES, edgeLabel, contactMappings } from '@/views/workflow/graph';
function newId() {
return crypto.randomUUID();
}
const MANAGER_LEVELS = [
{ value: 'l1manager', label: 'L1 Manager' },
{ value: 'l2manager', label: 'L2 Manager' },
{ value: 'l3manager', label: 'L3 Manager' },
{ value: 'l4manager', label: 'L4 Manager' },
];
function approverGroups(roles, users) {
return [
{ label: 'Manager', options: MANAGER_LEVELS },
{ label: 'Roles', options: Object.entries(roles).map(([value, label]) => ({ value, label })) },
{ label: 'Users', options: Object.entries(users).map(([value, label]) => ({ value, label })) },
];
}
function ApproverSelect({ values, groups, onChange }) {
const known = groups.flatMap((group) => group.options || []);
const saved = (values || []).filter((value) => !known.some((option) => option.value === value)).map((value) => ({ value, label: value }));
const options = saved.length ? groups.concat({ label: 'Saved', options: saved }) : groups;
const byValue = new Map(known.concat(saved).map((option) => [option.value, option]));
return (
<Select
isMulti
options={options}
classNamePrefix="react-select"
menuPosition="fixed"
menuPortalTarget={typeof document !== 'undefined' ? document.body : null}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 2000 }) }}
value={(values || []).map((value) => byValue.get(value)).filter(Boolean)}
onChange={(selected) => onChange((selected || []).map((option) => option.value))}
/>
);
}
export default function WorkflowEditor() {
const [params] = useSearchParams();
const id = params.get('id') || '';
@ -35,6 +71,7 @@ export default function WorkflowEditor() {
const [contextText, setContextText] = useState('{}');
const [hierarchyId, setHierarchyId] = useState('');
const [testOpen, setTestOpen] = useState(false);
const [approverOptions, setApproverOptions] = useState(() => approverGroups({}, {}));
useEffect(() => {
const load = async () => {
@ -56,6 +93,27 @@ export default function WorkflowEditor() {
if (id) load();
}, [id, setEdges, setNodes]);
useEffect(() => {
const loadApprovers = async () => {
try {
const [roles, usersRes] = await Promise.all([
dropdownMap('ums', 'roles', (role) => role.name),
signedGet('ums', 'users/dropdown'),
]);
const users = {};
const items = isSuccess(usersRes) ? usersRes.data?.items || [] : [];
items.forEach((user) => {
if (!user?.id) return;
users[user.id] = `${user.firstname || ''} ${user.lastname || ''} (${user.employee_code || ''})`.trim();
});
setApproverOptions(approverGroups(roles || {}, users));
} catch {
setApproverOptions(approverGroups({}, {}));
}
};
loadApprovers();
}, []);
const selectedNode = useMemo(
() => nodes.find((node) => node.id === selected && node.data),
[nodes, selected]
@ -251,15 +309,15 @@ export default function WorkflowEditor() {
<Background />
{selectedNode || selectedEdge ? (
<Panel position="top-right">
<Card style={{ width: 320 }}>
<Card.Header className="d-flex align-items-center justify-content-between">
<Card className="d-flex flex-column" style={{ width: 360, maxHeight: 'calc(70vh - 24px)', overflow: 'hidden' }}>
<Card.Header className="d-flex align-items-center justify-content-between flex-shrink-0">
<span>{selectedNode ? selectedNode.data.label : 'Relation'}</span>
<button type="button" className="btn-close" aria-label="Close" onClick={() => setSelected(null)} />
</Card.Header>
<Card.Body>
<Card.Body className="overflow-auto" style={{ minHeight: 0 }}>
{selectedNode ? (
<>
<NodeForm node={selectedNode} onChange={patchNode} />
<NodeForm node={selectedNode} approverOptions={approverOptions} onChange={patchNode} />
<Button variant="outline-danger" className="mt-3" onClick={() => deleteNode(selectedNode.id)}>
Delete node
</Button>
@ -305,7 +363,7 @@ export default function WorkflowEditor() {
);
}
function NodeForm({ node, onChange }) {
function NodeForm({ node, approverOptions, onChange }) {
const config = node.data.config || {};
const type = node.data.nodeType;
if (type === 'approval') {
@ -321,11 +379,12 @@ function NodeForm({ node, onChange }) {
</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) })}
<ApproverSelect
values={config.approvers || []}
groups={approverOptions}
onChange={(approvers) => onChange({ approvers })}
/>
<Form.Text>l1manager to l4manager, a role id, or a user id.</Form.Text>
<Form.Text>Pick manager levels, roles, or users. In Sequential mode, the order selected is the approval order.</Form.Text>
</Form.Group>
<Form.Check
label="Escalate when the approver seat is vacant"
@ -356,19 +415,195 @@ function NodeForm({ node, onChange }) {
</>
);
}
if (type === 'contact') {
const mappings = contactMappings(config);
const updateRow = (index, patch) => {
const next = mappings.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row));
onChange({ mappings: next.map(({ contact, source }) => ({ contact, source })) });
};
return (
<>
<p className="text-muted">Map each contact field to a field on the incoming object. If that field is not on the object, the text is saved as the value. Phone or email is enough. Application and domain come from this workflow. Position comes from the hierarchy, and user info is built from the user.</p>
{mappings.map((row, index) => (
<div className="mb-2" key={`${row.contact}-${index}`}>
<div className="d-flex gap-2">
<Form.Control
placeholder="Contact field"
value={row.contact || ''}
disabled={row.locked}
onChange={(event) => updateRow(index, { contact: event.target.value })}
/>
{row.contact === 'msl_code' ? (
<Form.Select
value={config.msl_mode === 'auto' ? 'auto' : 'map'}
onChange={(event) => onChange({ msl_mode: event.target.value })}
>
<option value="map">Map field</option>
<option value="auto">Autocreate</option>
</Form.Select>
) : (
<Form.Control
placeholder={row.optional ? 'Incoming field' : 'Incoming field *'}
value={row.source || ''}
onChange={(event) => updateRow(index, { source: event.target.value })}
/>
)}
{row.locked && !row.removable ? <span style={{ width: 38 }} /> : (
<Button
variant="outline-danger"
onClick={() => onChange({ mappings: mappings.filter((_, rowIndex) => rowIndex !== index).map(({ contact, source }) => ({ contact, source })) })}
>
×
</Button>
)}
</div>
{row.contact === 'msl_code' && config.msl_mode === 'auto' ? (
<div className="d-flex gap-2 mt-2">
<Form.Control placeholder="Prefix" value={config.msl_prefix || ''} onChange={(event) => onChange({ msl_prefix: event.target.value })} />
<Form.Control placeholder="Suffix" value={config.msl_suffix || ''} onChange={(event) => onChange({ msl_suffix: event.target.value })} />
</div>
) : null}
{row.contact === 'msl_code' && config.msl_mode !== 'auto' ? (
<Form.Control
className="mt-2"
placeholder="Incoming field *"
value={row.source || ''}
onChange={(event) => updateRow(index, { source: event.target.value })}
/>
) : null}
</div>
))}
<Button variant="outline-secondary" onClick={() => onChange({ mappings: mappings.concat({ contact: '', source: '' }).map(({ contact, source }) => ({ contact, source })) })}>
Add field
</Button>
</>
);
}
if (type === 'ocr') {
return (
<>
<Form.Group className="mb-2">
<Form.Label>Document</Form.Label>
<Form.Control
placeholder="visiting_card"
value={config.source || ''}
onChange={(event) => onChange({ source: event.target.value })}
/>
<Form.Text>Incoming field or a document URL. Textract reads the file. A missing field is skipped.</Form.Text>
</Form.Group>
<Form.Check
className="mb-2"
label="Mandatory"
checked={config.mandatory === true}
onChange={(event) => onChange({ mandatory: event.target.checked })}
/>
<Form.Group>
<Form.Label>Save text as</Form.Label>
<Form.Control value={config.output || 'ocr_text'} onChange={(event) => onChange({ output: event.target.value })} />
</Form.Group>
</>
);
}
if (type === 'delay') {
return (
<Form.Group>
<Form.Label>Seconds</Form.Label>
<Form.Control
type="number"
min={1}
max={86400}
value={config.seconds ?? 60}
onChange={(event) => onChange({ seconds: Number(event.target.value) })}
/>
<Form.Text>The run waits at least this long. EventBridge fires on a one-minute grid, so it can take up to about a minute longer.</Form.Text>
</Form.Group>
);
}
if (type === 'llm') {
return (
<>
<Form.Group className="mb-2">
<Form.Label>Model</Form.Label>
<Form.Select value={config.model || 'meta.llama3-8b-instruct-v1:0'} onChange={(event) => onChange({ model: event.target.value })}>
<option value="meta.llama3-8b-instruct-v1:0">Llama 3 8B</option>
<option value="amazon.titan-text-express-v1">Titan Text Express</option>
<option value="anthropic.claude-3-haiku-20240307-v1:0">Claude 3 Haiku</option>
</Form.Select>
</Form.Group>
<Form.Group className="mb-2">
<Form.Label>Prompt</Form.Label>
<Form.Control
as="textarea"
rows={6}
value={config.prompt || ''}
onChange={(event) => onChange({ prompt: event.target.value })}
/>
<Form.Text>Use {'{{doctor_name}}'} or {'{{ocr_text}}'} for a value from the incoming object.</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Save answer as</Form.Label>
<Form.Control value={config.output || 'llm_text'} onChange={(event) => onChange({ output: event.target.value })} />
</Form.Group>
</>
);
}
if (type === 'action') {
const mappings = Array.isArray(config.mappings) ? config.mappings : [];
const updateRow = (index, patch) => {
onChange({
mappings: mappings.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)),
});
};
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 })}>
<Form.Select
value={config.name || 'set_status'}
onChange={(event) => {
const name = event.target.value;
onChange(name === 'update_submission'
? { name, mappings: mappings.length ? mappings : [{ field: '', source: '' }] }
: { name });
}}
>
<option value="set_status">Set status</option>
<option value="update_submission">Form submission update</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>
{config.name === 'update_submission' ? (
<>
<p className="text-muted">Map each submission field to a field on this run. If that field is not on the run, the text is saved as the value. Use formdata.doctor_name for an answer, or workflow_status for the submission record.</p>
{mappings.map((row, index) => (
<div className="d-flex gap-2 mb-2" key={`submission-field-${index}`}>
<Form.Control
placeholder="Submission field"
value={row.field || ''}
onChange={(event) => updateRow(index, { field: event.target.value })}
/>
<Form.Control
placeholder="Incoming field"
value={row.source || ''}
onChange={(event) => updateRow(index, { source: event.target.value })}
/>
<Button
variant="outline-danger"
onClick={() => onChange({ mappings: mappings.filter((_, rowIndex) => rowIndex !== index) })}
>
×
</Button>
</div>
))}
<Button variant="outline-secondary" onClick={() => onChange({ mappings: mappings.concat({ field: '', source: '' }) })}>
Add field
</Button>
</>
) : (
<Form.Group>
<Form.Label>Status</Form.Label>
<Form.Control value={config.status || ''} onChange={(event) => onChange({ status: event.target.value })} />
</Form.Group>
)}
</>
);
}
@ -401,7 +636,7 @@ function EdgeForm({ edge, onChange }) {
<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) => (
{['eq', 'neq', 'in', 'gt', 'lt', 'exists', 'empty'].map((op) => (
<option key={op} value={op}>{op}</option>
))}
</Form.Select>

View File

@ -4,16 +4,63 @@ export const NODE_TYPES = [
{ type: 'approval', label: 'Approval' },
{ type: 'notify', label: 'Notify' },
{ type: 'action', label: 'Action' },
{ type: 'contact', label: 'Contact' },
{ type: 'ocr', label: 'OCR' },
{ type: 'llm', label: 'LLM' },
{ type: 'delay', label: 'Delay' },
{ type: 'end', label: 'End' },
];
export const CONTACT_FIELDS = [
{ contact: 'id', optional: true },
{ contact: 'name' },
{ contact: 'phone', optional: true },
{ contact: 'email', optional: true },
{ contact: 'msl_code', optional: true, removable: true },
{ contact: 'type' },
{ contact: 'hierarchy' },
{ contact: 'user_id' },
];
export function contactMappings(config) {
const saved = Array.isArray(config && config.mappings) ? config.mappings : [];
const required = CONTACT_FIELDS.filter((field) => !field.removable || saved.some((row) => row && row.contact === field.contact)).map((field) => {
const found = saved.find((row) => row && row.contact === field.contact);
return {
contact: field.contact,
source: (found && found.source) || '',
locked: true,
optional: Boolean(field.optional),
removable: Boolean(field.removable),
};
});
const extra = saved
.filter((row) => {
if (!row) return false;
const contact = String(row.contact || '').trim();
if (['position', 'user_info', 'domain', 'application'].includes(contact)) return false;
if (contact && CONTACT_FIELDS.some((field) => field.contact === contact)) return false;
return true;
})
.map((row) => ({ contact: row.contact || '', source: row.source || '', locked: false }));
return required.concat(extra);
}
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 === 'action') {
if (config.name === 'update_submission') return 'Form submission update';
if (config.name === 'set_status') return 'Set status';
return config.name || 'Action';
}
if (type === 'contact') return 'Update contact';
if (type === 'ocr') return 'OCR';
if (type === 'llm') return 'LLM';
if (type === 'delay') return `Delay (${config.seconds || 60}s)`;
if (type === 'condition') return 'Condition';
return 'Start';
}
@ -45,12 +92,44 @@ export function toFlow(definition) {
};
}
function actionConfig(config) {
const current = config || {};
if (current.name === 'update_submission') {
const mappings = Array.isArray(current.mappings) ? current.mappings : [];
return {
name: 'update_submission',
mappings: mappings
.map((row) => ({
field: String((row && row.field) || '').trim(),
source: String((row && row.source) || '').trim(),
}))
.filter((row) => row.field || row.source),
};
}
return {
name: current.name || 'set_status',
status: current.status || '',
};
}
export function fromFlow(nodes, edges) {
return {
nodes: nodes.map((node) => ({
id: node.id,
type: node.data.nodeType,
config: node.data.config || {},
config: node.data.nodeType === 'contact'
? {
...node.data.config,
msl_mode: node.data.config?.msl_mode === 'auto' ? 'auto' : 'map',
msl_prefix: node.data.config?.msl_prefix || '',
msl_suffix: node.data.config?.msl_suffix || '',
mappings: contactMappings(node.data.config)
.filter((row) => String(row.contact || '').trim())
.map(({ contact, source }) => ({ contact, source })),
}
: node.data.nodeType === 'action'
? actionConfig(node.data.config)
: node.data.config || {},
})),
edges: edges.map((edge) => {
const when = edge.data && edge.data.when && edge.data.when.field ? edge.data.when : null;
@ -69,6 +148,17 @@ 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 === 'contact') {
return {
mappings: contactMappings({ mappings: [{ contact: 'msl_code', source: '' }] }).map(({ contact, source }) => ({ contact, source })),
msl_mode: 'map',
msl_prefix: '',
msl_suffix: '',
};
}
if (type === 'ocr') return { source: '', output: 'ocr_text', mandatory: false };
if (type === 'llm') return { model: 'meta.llama3-8b-instruct-v1:0', prompt: '', output: 'llm_text' };
if (type === 'delay') return { seconds: 60 };
if (type === 'end') return { outcome: 'completed' };
return {};
}