+
@@ -8467,7 +8770,61 @@ function renderReportFields(frm) {
}
function editReportField(frm, fieldId) {
- openReportFieldModal(frm, fieldId);
+ try {
+ const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}");
+ const field = reportFields[fieldId];
+
+ if (!field) {
+ frappe.throw("Поле отчета не найдено");
+ return;
+ }
+
+ // Важно: создаем копию поля, чтобы не изменять оригинал в хранилище
+ const fieldCopy = {...field};
+
+ // Обрабатываем фильтры перед открытием диалога
+ if (fieldCopy.filters_json) {
+ try {
+ let filters = JSON.parse(fieldCopy.filters_json);
+ const processedFilters = {};
+
+ // Проходим по всем фильтрам и обрабатываем специальные значения
+ Object.keys(filters).forEach(key => {
+ let value = filters[key];
+
+ // Обработка дат
+ if (key.toLowerCase().includes('date') && value === "$AyIlSpecial") {
+ // Заменяем специальное значение на обычную дату для временного использования
+ // Потом мы восстановим специальное значение при сохранении
+ processedFilters["__original_" + key] = "$AyIlSpecial"; // Сохраняем оригинальное значение
+ processedFilters[key] = frappe.datetime.get_today(); // Временная валидная дата
+ } else if (typeof value === 'string' && value.startsWith("doc.") &&
+ (key.toLowerCase().includes('date') || key.toLowerCase().includes('from') || key.toLowerCase().includes('to'))) {
+ // Обрабатываем также ссылки на поля документа
+ processedFilters["__original_" + key] = value; // Сохраняем оригинальное значение
+ processedFilters[key] = frappe.datetime.get_today(); // Временная валидная дата
+ } else {
+ processedFilters[key] = value;
+ }
+ });
+
+ // Обновляем JSON фильтров в копии
+ fieldCopy.filters_json = JSON.stringify(processedFilters);
+ fieldCopy.__has_special_dates = true; // Маркер для последующего восстановления
+ } catch (e) {
+ console.error("Ошибка при обработке JSON фильтров:", e);
+ }
+ }
+
+ // Открываем модальное окно с обработанной копией поля
+ openReportFieldModal(frm, fieldId, fieldCopy);
+ } catch (error) {
+ frappe.msgprint({
+ title: __('Ошибка'),
+ message: __('Не удалось открыть поле отчета для редактирования: ') + error.message,
+ indicator: 'red'
+ });
+ }
}
function deleteReportField(frm, fieldId) {
@@ -8496,3 +8853,136 @@ function deleteReportField(frm, fieldId) {
}
);
}
+
+function processTableRowFormulas(table_row_formulas, table_doctypes) {
+ // Хранилище для объединенных обработчиков
+ const triggerHandlers = {};
+
+ // Группируем формулы по триггерам
+ Object.values(table_row_formulas).forEach(formula => {
+ const tableName = formula.table_name;
+ const rowIndex = parseInt(formula.row_index);
+ const usesUniversal = formula.formula && formula.formula.includes("univ(");
+ const usesReports = formula.formula && formula.formula.includes("report(");
+
+ // Если есть триггеры, добавляем их
+ if (formula.triggers) {
+ const triggers = formula.triggers.split(',').map(t => t.trim());
+
+ triggers.forEach(trigger => {
+ // Пропускаем триггер "При открытии документа", он обрабатывается отдельно
+ if (!trigger || trigger === "При открытии документа" ||
+ trigger === "when_document_opens" || trigger === "document_opens") {
+ return;
+ }
+
+ // Инициализируем обработчик триггера, если его еще нет
+ if (!triggerHandlers[trigger]) {
+ triggerHandlers[trigger] = {
+ isAsync: usesUniversal || usesReports,
+ handlers: []
+ };
+ } else if (usesUniversal || usesReports) {
+ // Если текущая формула требует async, помечаем весь обработчик как async
+ triggerHandlers[trigger].isAsync = true;
+ }
+
+ // Код для вычисления формулы
+ const handlerCode = `
+ // Проверяем строку ${rowIndex} в таблице ${tableName}
+ if (frm.doc.${tableName} && frm.doc.${tableName}.length >= ${rowIndex}) {
+ try {
+ // Индекс строки на 1 меньше номера строки
+ const rowIndex = ${rowIndex - 1};
+ const formula = "${formula.formula}";
+
+ ${usesUniversal || usesReports ? 'await ' : ''}calculate_table_row_field(frm, "${tableName}", rowIndex, "${formula.field_name}", formula, "${trigger}");
+ } catch (error) {
+ console.error("Error calculating table row formula for ${formula.field_name}:", error);
+ }
+ }`;
+
+ triggerHandlers[trigger].handlers.push(handlerCode);
+ });
+ }
+ });
+
+ // Добавляем обработчики для событий add_row и remove_row для каждой таблицы
+ const tables = new Set();
+ Object.values(table_row_formulas).forEach(formula => {
+ tables.add(formula.table_name);
+ });
+
+ tables.forEach(tableName => {
+ // Add handler
+ triggerHandlers[`${tableName}_add`] = {
+ isAsync: false,
+ handlers: [
+ `// Обновляем индексы строк после добавления новой строки`,
+ `setTimeout(() => initialize_table_row_formulas(frm), 100);`
+ ]
+ };
+
+ // Remove handler
+ triggerHandlers[`${tableName}_remove`] = {
+ isAsync: false,
+ handlers: [
+ `// Обновляем индексы строк после удаления строки`,
+ `setTimeout(() => initialize_table_row_formulas(frm), 100);`
+ ]
+ };
+ });
+
+ return triggerHandlers;
+}
+
+// Функция для обновления JSON фильтров
+function updateFiltersJson(d) {
+ try {
+ // Собираем все значения фильтров
+ const values = {};
+ Object.keys(d.filter_controls || {}).forEach(fieldname => {
+ const ctrl = d.filter_controls[fieldname];
+ if (ctrl) {
+ try {
+ const value = ctrl.get_value();
+ if (value !== undefined && value !== null && value !== '') {
+ // Сохраняем все значения как есть
+ values[fieldname] = value;
+
+ // Проверка на специальные значения дат
+ if ((value === "$AyIlSpecial" || (typeof value === 'string' && value.startsWith("doc."))) &&
+ (fieldname.toLowerCase().includes('date') ||
+ fieldname.toLowerCase().includes('from') ||
+ fieldname.toLowerCase().includes('to'))) {
+
+ // Сохраняем оригинальное значение отдельно
+ values[`__original_${fieldname}`] = value;
+
+ // Временно заменяем значение на валидную дату для проверок
+ values[fieldname] = frappe.datetime.get_today();
+
+ // Отмечаем, что у нас есть специальные даты
+ d.set_value('has_special_dates', 1);
+ }
+ }
+ } catch (e) {
+ frappe.show_alert({
+ message: `Ошибка при получении значения фильтра ${fieldname}: ${e.message}`,
+ indicator: 'red'
+ });
+ }
+ }
+ });
+
+ // Обновляем JSON фильтров
+ if (Object.keys(values).length > 0) {
+ d.set_value('filters_json', JSON.stringify(values, null, 2));
+ }
+ } catch (e) {
+ frappe.show_alert({
+ message: `Ошибка при обновлении фильтров: ${e.message}`,
+ indicator: 'red'
+ });
+ }
+}
\ No newline at end of file
diff --git a/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js b/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js
index b1ca8ac..ea81f1b 100644
--- a/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js
+++ b/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js
@@ -34,7 +34,7 @@ frappe.ui.form.on('Report Simulator', {
// Функция для загрузки фильтров отчета
function load_report_filters(frm) {
frappe.call({
- method: 'formula_editor.formula_editor.doctype.report_simulator.report_simulator.get_report_filters_dynamic',
+ method: 'formula_editor_js.formula_editor_js.doctype.report_simulator.report_simulator.get_report_filters_dynamic',
args: {
report_name: frm.doc.report
},
diff --git a/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js b/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js
index d55a772..19dcb4a 100644
--- a/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js
+++ b/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js
@@ -44,7 +44,7 @@ frappe.listview_settings['Report Simulator'] = {
// Выполняем отчет
frappe.call({
- method: 'formula_editor.formula_editor.doctype.report_simulator.report_simulator.run_report_simulation',
+ method: 'formula_editor_js.formula_editor_js.doctype.report_simulator.report_simulator.run_report_simulation',
args: {
report_name: values.report,
filters_json: values.filters_json
diff --git a/formula_editor/hooks.py b/formula_editor/hooks.py
index 2e500b6..ac01635 100644
--- a/formula_editor/hooks.py
+++ b/formula_editor/hooks.py
@@ -1,32 +1,27 @@
app_name = "formula_editor"
app_title = "Formula Editor"
-app_publisher = "Jey ERP"
-app_description = "App for adding formulas for any doctype"
-app_email = "info@jey_erp.az"
-app_license = "unlicense"
-
-# Apps
-# ------------------
-
-# required_apps = []
-
-# Each item in the list will be shown as an app in the apps page
-# add_to_apps_screen = [
-# {
-# "name": "formula_editor",
-# "logo": "/assets/formula_editor/logo.png",
-# "title": "Formula Editor",
-# "route": "/formula_editor",
-# "has_permission": "formula_editor.api.permission.has_app_permission"
-# }
-# ]
+app_publisher = "Your Company"
+app_description = "Advanced formula editing capabilities for Frappe/ERPNext"
+app_email = "your.email@example.com"
+app_license = "MIT"
# Includes in
# ------------------
# include js, css files in header of desk.html
-# app_include_css = "/assets/formula_editor/css/formula_editor.css"
-# app_include_js = "/assets/formula_editor/js/formula_editor.js"
+app_include_css = [
+ "/assets/formula_editor/css/formula_editor.css"
+]
+app_include_js = [
+ "/assets/formula_editor/js/core.js",
+ "/assets/formula_editor/js/index.js",
+ "/assets/formula_editor/js/report_fields.js",
+ "/assets/formula_editor/js/table_row_formulas.js",
+ "/assets/formula_editor/js/triggers.js",
+ "/assets/formula_editor/js/ui.js",
+ "/assets/formula_editor/js/universal_fields.js",
+ "/assets/formula_editor/js/utils.js"
+]
# include js, css files in header of web template
# web_include_css = "/assets/formula_editor/css/formula_editor.css"
@@ -43,15 +38,12 @@ app_license = "unlicense"
# page_js = {"page" : "public/js/file.js"}
# include js in doctype views
-# doctype_js = {"doctype" : "public/js/doctype.js"}
-# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
-# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
-# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
+doctype_js = {
+ "DocType": "public/js/doctype_formula_editor.js",
+}
-# Svg Icons
-# ------------------
-# include app icons in desk
-# app_include_icons = "formula_editor/public/icons.svg"
+# include js in doctype list views
+# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
# Home Pages
# ----------
@@ -61,7 +53,7 @@ app_license = "unlicense"
# website user home page (by Role)
# role_home_page = {
-# "Role": "home_page"
+# "Role": "home_page"
# }
# Generators
@@ -70,16 +62,13 @@ app_license = "unlicense"
# automatically create page for each record of this doctype
# website_generators = ["Web Page"]
-# automatically load and sync documents of this doctype from downstream apps
-# importable_doctypes = [doctype_1]
-
# Jinja
# ----------
# add methods and filters to jinja environment
# jinja = {
-# "methods": "formula_editor.utils.jinja_methods",
-# "filters": "formula_editor.utils.jinja_filters"
+# "methods": "formula_editor.utils.jinja_methods",
+# "filters": "formula_editor.utils.jinja_filters"
# }
# Installation
@@ -94,22 +83,6 @@ app_license = "unlicense"
# before_uninstall = "formula_editor.uninstall.before_uninstall"
# after_uninstall = "formula_editor.uninstall.after_uninstall"
-# Integration Setup
-# ------------------
-# To set up dependencies/integrations with other apps
-# Name of the app being installed is passed as an argument
-
-# before_app_install = "formula_editor.utils.before_app_install"
-# after_app_install = "formula_editor.utils.after_app_install"
-
-# Integration Cleanup
-# -------------------
-# To clean up dependencies/integrations with other apps
-# Name of the app being uninstalled is passed as an argument
-
-# before_app_uninstall = "formula_editor.utils.before_app_uninstall"
-# after_app_uninstall = "formula_editor.utils.after_app_uninstall"
-
# Desk Notifications
# ------------------
# See frappe.core.notifications.get_notification_config
@@ -121,11 +94,11 @@ app_license = "unlicense"
# Permissions evaluated in scripted ways
# permission_query_conditions = {
-# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
+# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# }
#
# has_permission = {
-# "Event": "frappe.desk.doctype.event.event.has_permission",
+# "Event": "frappe.desk.doctype.event.event.has_permission",
# }
# DocType Class
@@ -133,7 +106,7 @@ app_license = "unlicense"
# Override standard doctype classes
# override_doctype_class = {
-# "ToDo": "custom_app.overrides.CustomToDo"
+# "ToDo": "custom_app.overrides.CustomToDo"
# }
# Document Events
@@ -150,21 +123,21 @@ doc_events = {
# ---------------
# scheduler_events = {
-# "all": [
-# "formula_editor.tasks.all"
-# ],
-# "daily": [
-# "formula_editor.tasks.daily"
-# ],
-# "hourly": [
-# "formula_editor.tasks.hourly"
-# ],
-# "weekly": [
-# "formula_editor.tasks.weekly"
-# ],
-# "monthly": [
-# "formula_editor.tasks.monthly"
-# ],
+# "all": [
+# "formula_editor.tasks.all"
+# ],
+# "daily": [
+# "formula_editor.tasks.daily"
+# ],
+# "hourly": [
+# "formula_editor.tasks.hourly"
+# ],
+# "weekly": [
+# "formula_editor.tasks.weekly"
+# ],
+# "monthly": [
+# "formula_editor.tasks.monthly"
+# ],
# }
# Testing
@@ -176,14 +149,14 @@ doc_events = {
# ------------------------------
#
# override_whitelisted_methods = {
-# "frappe.desk.doctype.event.event.get_events": "formula_editor.event.get_events"
+# "frappe.desk.doctype.event.event.get_events": "formula_editor.event.get_events"
# }
#
# each overriding function accepts a `data` argument;
# generated from the base implementation of the doctype dashboard,
# along with any modifications made in other Frappe apps
# override_doctype_dashboards = {
-# "Task": "formula_editor.task.get_dashboard_data"
+# "Task": "formula_editor.task.get_dashboard_data"
# }
# exempt linked doctypes from being automatically cancelled
@@ -209,37 +182,37 @@ doc_events = {
# --------------------
# user_data_fields = [
-# {
-# "doctype": "{doctype_1}",
-# "filter_by": "{filter_by}",
-# "redact_fields": ["{field_1}", "{field_2}"],
-# "partial": 1,
-# },
-# {
-# "doctype": "{doctype_2}",
-# "filter_by": "{filter_by}",
-# "partial": 1,
-# },
-# {
-# "doctype": "{doctype_3}",
-# "strict": False,
-# },
-# {
-# "doctype": "{doctype_4}"
-# }
+# {
+# "doctype": "{doctype_1}",
+# "filter_by": "{filter_by}",
+# "redact_fields": ["{field_1}", "{field_2}"],
+# "partial": 1,
+# },
+# {
+# "doctype": "{doctype_2}",
+# "filter_by": "{filter_by}",
+# "partial": 1,
+# },
+# {
+# "doctype": "{doctype_3}",
+# "strict": False,
+# },
+# {
+# "doctype": "{doctype_4}"
+# }
# ]
# Authentication and authorization
# --------------------------------
# auth_hooks = [
-# "formula_editor.auth.validate"
+# "formula_editor.auth.validate"
# ]
-# Automatically update python controller files with type annotations for this app.
-# export_python_type_annotations = True
-
-# default_log_clearing_doctypes = {
-# "Logging DocType Name": 30 # days to retain logs
-# }
+# Translation
+# --------------------------------
+# Make link fields search translated document names for these doctypes
+# Recommended only for DocTypes which have limited documents with untranslated names
+# For example: Role, Gender, etc.
+# translated_search_doctypes = []
\ No newline at end of file
diff --git a/formula_editor/public/css/formula_editor.css b/formula_editor/public/css/formula_editor.css
new file mode 100644
index 0000000..e3093a0
--- /dev/null
+++ b/formula_editor/public/css/formula_editor.css
@@ -0,0 +1,359 @@
+/**
+ * Formula Editor Styles
+ */
+
+ .formula-editor-container {
+ position: relative;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ min-height: 100px;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .formula-editor-container.has-error {
+ border-color: var(--red-500);
+ }
+
+ .formula-editor-wrapper {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ }
+
+ .formula-editor-top {
+ border-bottom: 1px solid var(--border-color);
+ padding: 4px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .formula-editor-main {
+ display: flex;
+ flex: 1;
+ min-height: 200px;
+ }
+
+ .formula-editor-section {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .formula-editor-code-section {
+ flex: 2;
+ border-right: 1px solid var(--border-color);
+ }
+
+ .formula-editor-panels-section {
+ flex: 1;
+ min-width: 250px;
+ max-width: 300px;
+ }
+
+ .formula-editor-bottom {
+ border-top: 1px solid var(--border-color);
+ padding: 4px 8px;
+ display: flex;
+ align-items: center;
+ }
+
+ .formula-editor-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .formula-editor-code-container {
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .formula-editor-code {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ font-family: monospace;
+ font-size: 13px;
+ height: 100%;
+ width: 100%;
+ }
+
+ .formula-editor-textarea {
+ width: 100%;
+ height: 100%;
+ resize: none;
+ border: none;
+ padding: 8px;
+ font-family: monospace;
+ font-size: 13px;
+ }
+
+ .formula-editor-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ .panel-header {
+ padding: 8px;
+ font-weight: bold;
+ border-bottom: 1px solid var(--border-color);
+ }
+
+ .panel-search {
+ padding: 4px 8px;
+ border-bottom: 1px solid var(--border-color);
+ }
+
+ .panel-content {
+ padding: 0;
+ overflow-y: auto;
+ flex: 1;
+ }
+
+ .field-group {
+ margin-bottom: 2px;
+ }
+
+ .group-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 6px 8px;
+ cursor: pointer;
+ background-color: var(--bg-light-gray);
+ }
+
+ .group-header:hover {
+ background-color: var(--bg-gray);
+ }
+
+ .group-name {
+ font-weight: 500;
+ }
+
+ .group-count {
+ font-size: 0.8em;
+ color: var(--text-muted);
+ margin-right: 4px;
+ }
+
+ .group-content {
+ padding: 4px 0;
+ }
+
+ .collapsed .group-content {
+ display: none;
+ }
+
+ .field-item {
+ display: flex;
+ align-items: center;
+ padding: 4px 8px 4px 16px;
+ cursor: pointer;
+ border-left: 2px solid transparent;
+ }
+
+ .field-item:hover {
+ background-color: var(--bg-light-gray);
+ border-left-color: var(--primary);
+ }
+
+ .field-icon {
+ margin-right: 8px;
+ color: var(--text-muted);
+ width: 16px;
+ text-align: center;
+ }
+
+ .field-label {
+ flex: 1;
+ margin-right: 4px;
+ }
+
+ .field-type {
+ font-size: 0.8em;
+ color: var(--text-muted);
+ }
+
+ .category-item {
+ padding: 6px 12px;
+ cursor: pointer;
+ border-left: 2px solid transparent;
+ }
+
+ .category-item:hover,
+ .category-item.selected {
+ background-color: var(--bg-light-gray);
+ }
+
+ .category-item.selected {
+ border-left-color: var(--primary);
+ font-weight: 500;
+ }
+
+ .function-item {
+ padding: 8px;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ }
+
+ .function-item:hover {
+ background-color: var(--bg-light-gray);
+ }
+
+ .function-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 4px;
+ }
+
+ .function-name {
+ font-weight: 500;
+ margin-right: 8px;
+ }
+
+ .function-syntax {
+ font-family: monospace;
+ font-size: 0.9em;
+ color: var(--text-muted);
+ }
+
+ .function-description {
+ font-size: 0.9em;
+ color: var(--text-muted);
+ }
+
+ .formula-editor-error-panel {
+ color: var(--red-500);
+ display: flex;
+ align-items: center;
+ width: 100%;
+ }
+
+ .formula-editor-error-panel i {
+ margin-right: 8px;
+ }
+
+ .formula-editor-error-panel.hidden {
+ display: none;
+ }
+
+ .formula-editor-btn {
+ margin-left: 4px;
+ }
+
+ .formula-list-container {
+ padding: 8px;
+ }
+
+ .formula-list-item {
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ margin-bottom: 8px;
+ padding: 8px;
+ }
+
+ .formula-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+ }
+
+ .formula-name {
+ font-weight: 500;
+ margin-right: 8px;
+ }
+
+ .formula-field {
+ font-size: 0.9em;
+ color: var(--text-muted);
+ }
+
+ .formula-actions {
+ display: flex;
+ gap: 4px;
+ }
+
+ .formula-code {
+ padding: 8px;
+ background-color: var(--bg-light-gray);
+ border-radius: 4px;
+ font-family: monospace;
+ font-size: 0.9em;
+ margin-bottom: 8px;
+ white-space: pre-wrap;
+ }
+
+ .formula-description {
+ font-size: 0.9em;
+ color: var(--text-muted);
+ }
+
+ .formula-toggle-btn.disabled {
+ opacity: 0.5;
+ }
+
+ .formula-calculated-field {
+ background-color: rgba(var(--primary-rgb), 0.1);
+ position: relative;
+ }
+
+ .formula-calculated-field::after {
+ content: "ƒ";
+ position: absolute;
+ top: 0;
+ right: 4px;
+ font-size: 12px;
+ color: var(--primary);
+ font-weight: bold;
+ }
+
+ .empty-state {
+ padding: 16px;
+ text-align: center;
+ color: var(--text-muted);
+ }
+
+ .formula-editor-container.read-only .formula-editor-code,
+ .formula-editor-container.read-only .formula-editor-textarea {
+ background-color: var(--bg-light-gray);
+ cursor: not-allowed;
+ }
+
+ .formula-editor-report-btn {
+ margin-left: 8px;
+ }
+
+ .category-badge {
+ background-color: var(--bg-light-gray);
+ font-size: 0.8em;
+ padding: 2px 4px;
+ border-radius: 4px;
+ margin-left: auto;
+ }
+
+ /* For trigger mode */
+ .formula-editor-container.trigger-mode .formula-editor-panels-section {
+ flex: 1.5;
+ max-width: 400px;
+ }
+
+ /* For report mode */
+ .formula-editor-container.report-mode .formula-editor-code-section {
+ flex: 1.5;
+ }
+
+ /* Hidden elements */
+ .hidden {
+ display: none !important;
+ }
\ No newline at end of file
diff --git a/formula_editor/public/js/core.js b/formula_editor/public/js/core.js
new file mode 100644
index 0000000..244992a
--- /dev/null
+++ b/formula_editor/public/js/core.js
@@ -0,0 +1,466 @@
+/**
+ * Formula Editor Core
+ * Contains the main FormulaEditor class and core functionality
+ */
+
+import {
+ safeEval,
+ validateFormula,
+ extractReferencedFields,
+ generateUniqueId,
+ debounce,
+ formatErrorMessage,
+ showToast,
+ isEmpty,
+ deepClone,
+ safeParseJSON
+} from './utils';
+
+class FormulaEditor {
+ /**
+ * Create a new FormulaEditor instance
+ * @param {Object} options - Configuration options
+ * @param {string} options.parent - Parent element selector or element
+ * @param {string} options.doctype - DocType name
+ * @param {string} [options.fieldname] - Field name
+ * @param {Object} [options.doc] - Current document
+ * @param {Function} [options.on_change] - Change callback
+ * @param {Array} [options.custom_fields] - Additional custom fields
+ * @param {boolean} [options.is_report] - Whether this is used in a report
+ * @param {boolean} [options.is_trigger] - Whether this is used for triggers
+ */
+ constructor(options) {
+ this.options = Object.assign({
+ parent: null,
+ doctype: null,
+ fieldname: null,
+ doc: null,
+ on_change: null,
+ custom_fields: [],
+ is_report: false,
+ is_trigger: false
+ }, options);
+
+ // Validate required options
+ if (!this.options.parent) {
+ throw new Error(__('Parent element is required for Formula Editor'));
+ }
+
+ if (!this.options.doctype) {
+ throw new Error(__('DocType is required for Formula Editor'));
+ }
+
+ // Initialize properties
+ this.initialized = false;
+ this.formula = '';
+ this.fields = [];
+ this.errors = [];
+ this.meta = null;
+ this.parent = typeof this.options.parent === 'string'
+ ? document.querySelector(this.options.parent)
+ : this.options.parent;
+ this.editor_id = generateUniqueId('formula-editor');
+
+ // Create debounced functions
+ this.debouncedValidate = debounce(this.validate.bind(this), 500);
+ this.debouncedOnChange = debounce(this.handleChange.bind(this), 300);
+
+ // Internal data storage
+ this._cached_fields = null;
+ this._field_options = null;
+ this._referenced_fields = [];
+
+ // Initialize the editor
+ this.init();
+ }
+
+ /**
+ * Initialize the editor
+ * @private
+ */
+ async init() {
+ try {
+ if (this.initialized) return;
+
+ // Fetch metadata for the DocType
+ await this.fetchMeta();
+
+ // Get available fields
+ await this.getFields();
+
+ // Create the UI elements
+ this.createElements();
+
+ // Bind events
+ this.bindEvents();
+
+ this.initialized = true;
+
+ // Initialize with current value if available
+ if (this.options.doc && this.options.fieldname) {
+ this.setValue(this.options.doc[this.options.fieldname] || '');
+ }
+
+ } catch (error) {
+ console.error('Error initializing Formula Editor:', error);
+ this.showError(__('Failed to initialize Formula Editor'));
+ }
+ }
+
+ /**
+ * Fetch DocType metadata
+ * @private
+ */
+ async fetchMeta() {
+ try {
+ this.meta = frappe.get_meta(this.options.doctype);
+
+ if (!this.meta) {
+ // If meta is not in cache, fetch it
+ this.meta = await frappe.model.with_doctype(this.options.doctype);
+ }
+ } catch (error) {
+ console.error('Error fetching metadata:', error);
+ throw new Error(__('Could not fetch metadata for {0}', [this.options.doctype]));
+ }
+ }
+
+ /**
+ * Get available fields for the formula
+ * @private
+ */
+ async getFields() {
+ // Return cached fields if available
+ if (this._cached_fields) {
+ this.fields = deepClone(this._cached_fields);
+ return;
+ }
+
+ try {
+ const fields = [];
+
+ // Add standard fields
+ this.addStandardFields(fields);
+
+ // Add fields from the DocType
+ this.addDocTypeFields(fields);
+
+ // Add custom fields if provided
+ if (Array.isArray(this.options.custom_fields)) {
+ fields.push(...this.options.custom_fields);
+ }
+
+ // Save and cache the fields
+ this.fields = fields;
+ this._cached_fields = deepClone(fields);
+
+ } catch (error) {
+ console.error('Error getting fields:', error);
+ throw new Error(__('Could not get fields for Formula Editor'));
+ }
+ }
+
+ /**
+ * Add standard fields like 'doc', etc.
+ * @param {Array} fields - Fields array to add to
+ * @private
+ */
+ addStandardFields(fields) {
+ // Add 'doc' as a standard field
+ fields.push({
+ fieldname: 'doc',
+ label: __('Current Document'),
+ type: 'Object',
+ standard: true
+ });
+
+ // Add other standard fields based on context
+ if (this.options.is_report) {
+ fields.push({
+ fieldname: 'row',
+ label: __('Current Row'),
+ type: 'Object',
+ standard: true
+ });
+ }
+ }
+
+ /**
+ * Add fields from the DocType
+ * @param {Array} fields - Fields array to add to
+ * @private
+ */
+ addDocTypeFields(fields) {
+ if (!this.meta || !this.meta.fields) return;
+
+ // Add all fields from the DocType
+ for (const field of this.meta.fields) {
+ // Skip certain field types
+ if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) {
+ continue;
+ }
+
+ fields.push({
+ fieldname: field.fieldname,
+ label: field.label || field.fieldname,
+ type: field.fieldtype,
+ options: field.options,
+ table_fields: this.getTableFields(field),
+ is_child_table: field.fieldtype === 'Table'
+ });
+ }
+ }
+
+ /**
+ * Get fields for a Table type field
+ * @param {Object} field - The table field
+ * @returns {Array} Child table fields
+ * @private
+ */
+ getTableFields(field) {
+ if (field.fieldtype !== 'Table' || !field.options) return [];
+
+ const childFields = [];
+ const childMeta = frappe.get_meta(field.options);
+
+ if (!childMeta || !childMeta.fields) return [];
+
+ for (const childField of childMeta.fields) {
+ // Skip certain field types
+ if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(childField.fieldtype)) {
+ continue;
+ }
+
+ childFields.push({
+ fieldname: childField.fieldname,
+ label: childField.label || childField.fieldname,
+ type: childField.fieldtype,
+ options: childField.options
+ });
+ }
+
+ return childFields;
+ }
+
+ /**
+ * Create UI elements for the editor
+ * @private
+ */
+ createElements() {
+ // This is a placeholder - UI creation will be moved to ui.js
+ // Here we just create the basic container
+ this.container = document.createElement('div');
+ this.container.id = this.editor_id;
+ this.container.className = 'formula-editor-container';
+
+ // Create a simple textarea for now (will be replaced by proper UI)
+ this.textarea = document.createElement('textarea');
+ this.textarea.className = 'formula-editor-input';
+ this.textarea.placeholder = __('Enter your formula here...');
+
+ // Error display element
+ this.errorEl = document.createElement('div');
+ this.errorEl.className = 'formula-editor-error hidden';
+
+ // Append to container
+ this.container.appendChild(this.textarea);
+ this.container.appendChild(this.errorEl);
+
+ // Append to parent
+ this.parent.appendChild(this.container);
+ }
+
+ /**
+ * Bind events to the editor elements
+ * @private
+ */
+ bindEvents() {
+ if (!this.textarea) return;
+
+ // Input event for formula changes
+ this.textarea.addEventListener('input', () => {
+ this.formula = this.textarea.value;
+ this.debouncedValidate();
+ this.debouncedOnChange();
+ });
+
+ // Focus/blur events
+ this.textarea.addEventListener('focus', this.handleFocus.bind(this));
+ this.textarea.addEventListener('blur', this.handleBlur.bind(this));
+ }
+
+ /**
+ * Handle focus event
+ * @private
+ */
+ handleFocus() {
+ this.container.classList.add('focused');
+ }
+
+ /**
+ * Handle blur event
+ * @private
+ */
+ handleBlur() {
+ this.container.classList.remove('focused');
+ this.validate();
+ }
+
+ /**
+ * Handle formula change
+ * @private
+ */
+ handleChange() {
+ if (typeof this.options.on_change === 'function') {
+ this.options.on_change(this.formula, this._referenced_fields);
+ }
+ }
+
+ /**
+ * Set the formula value
+ * @param {string} value - Formula to set
+ * @public
+ */
+ setValue(value) {
+ this.formula = value || '';
+
+ if (this.textarea) {
+ this.textarea.value = this.formula;
+ }
+
+ this.validate();
+ }
+
+ /**
+ * Get the current formula value
+ * @returns {string} Current formula
+ * @public
+ */
+ getValue() {
+ return this.formula;
+ }
+
+ /**
+ * Validate the current formula
+ * @returns {boolean} Whether the formula is valid
+ * @public
+ */
+ validate() {
+ // Clear previous errors
+ this.errors = [];
+
+ // Skip validation for empty formula
+ if (!this.formula.trim()) {
+ this.hideError();
+ return true;
+ }
+
+ // Validate formula syntax
+ const validation = validateFormula(this.formula);
+
+ if (!validation.isValid) {
+ this.errors.push(validation.error);
+ this.showError(validation.error);
+ return false;
+ }
+
+ // Get referenced fields
+ this._referenced_fields = extractReferencedFields(
+ this.formula,
+ this.fields.map(f => f.fieldname)
+ );
+
+ // Hide error if validation passed
+ this.hideError();
+ return true;
+ }
+
+ /**
+ * Show error message
+ * @param {string} message - Error message
+ * @private
+ */
+ showError(message) {
+ if (!this.errorEl) return;
+
+ this.errorEl.textContent = formatErrorMessage(message);
+ this.errorEl.classList.remove('hidden');
+ this.container.classList.add('has-error');
+ }
+
+ /**
+ * Hide error message
+ * @private
+ */
+ hideError() {
+ if (!this.errorEl) return;
+
+ this.errorEl.textContent = '';
+ this.errorEl.classList.add('hidden');
+ this.container.classList.remove('has-error');
+ }
+
+ /**
+ * Evaluate the current formula
+ * @param {Object} [context] - Additional context for evaluation
+ * @returns {*} Result of the evaluation
+ * @public
+ */
+ evaluate(context = {}) {
+ if (!this.formula.trim()) return null;
+
+ // Validate before evaluation
+ if (!this.validate()) {
+ return null;
+ }
+
+ // Create evaluation context
+ const evalContext = {
+ doc: this.options.doc || {},
+ ...context
+ };
+
+ try {
+ return safeEval(this.formula, evalContext);
+ } catch (error) {
+ console.error('Error evaluating formula:', error);
+ this.showError(error.message);
+ return null;
+ }
+ }
+
+ /**
+ * Gets the fields referenced in the current formula
+ * @returns {Array} Array of referenced field names
+ * @public
+ */
+ getReferencedFields() {
+ return [...this._referenced_fields];
+ }
+
+ /**
+ * Destroy the editor and clean up
+ * @public
+ */
+ destroy() {
+ // Remove event listeners
+ if (this.textarea) {
+ this.textarea.removeEventListener('input', this.debouncedOnChange);
+ this.textarea.removeEventListener('focus', this.handleFocus);
+ this.textarea.removeEventListener('blur', this.handleBlur);
+ }
+
+ // Remove DOM elements
+ if (this.container && this.container.parentNode) {
+ this.container.parentNode.removeChild(this.container);
+ }
+
+ // Clean up properties
+ this.initialized = false;
+ this._cached_fields = null;
+ this._field_options = null;
+ this._referenced_fields = [];
+ }
+}
+
+export default FormulaEditor;
\ No newline at end of file
diff --git a/formula_editor/public/js/index.js b/formula_editor/public/js/index.js
new file mode 100644
index 0000000..d5bded0
--- /dev/null
+++ b/formula_editor/public/js/index.js
@@ -0,0 +1,376 @@
+/**
+ * Formula Editor
+ * Main entry point for the Formula Editor module
+ */
+
+import FormulaEditor from './core';
+import FormulaEditorUI from './ui';
+import FormulaEditorTriggers from './triggers';
+import FormulaEditorUniversalFields from './universal_fields';
+import FormulaEditorTableRows from './table_row_formulas';
+import FormulaEditorReportFields from './report_fields';
+import * as utils from './utils';
+
+// Create the top-level namespace for the formula editor
+frappe.formula_editor = {
+ // Core classes
+ FormulaEditor,
+ FormulaEditorUI,
+ FormulaEditorTriggers,
+ FormulaEditorUniversalFields,
+ FormulaEditorTableRows,
+ FormulaEditorReportFields,
+
+ // Utilities
+ utils,
+
+ // Instances cache
+ _instances: {},
+
+ /**
+ * Create a new formula editor instance
+ * @param {Object} options - Configuration options
+ * @returns {FormulaEditor} Formula editor instance
+ */
+ create(options) {
+ const editor = new FormulaEditor(options);
+ const id = options.id || `formula_editor_${Math.random().toString(36).substr(2, 9)}`;
+
+ this._instances[id] = editor;
+ return editor;
+ },
+
+ /**
+ * Get a formula editor instance by ID
+ * @param {string} id - Instance ID
+ * @returns {FormulaEditor|null} Formula editor instance or null if not found
+ */
+ get(id) {
+ return this._instances[id] || null;
+ },
+
+ /**
+ * Destroy a formula editor instance
+ * @param {string} id - Instance ID
+ * @returns {boolean} Success status
+ */
+ destroy(id) {
+ const editor = this.get(id);
+ if (!editor) return false;
+
+ editor.destroy();
+ delete this._instances[id];
+ return true;
+ },
+
+ /**
+ * Initialize formula editor for a form field
+ * @param {frappe.ui.form.Field} field - Form field to initialize editor for
+ * @param {Object} [options={}] - Additional options
+ * @returns {FormulaEditor} Formula editor instance
+ */
+ initForField(field, options = {}) {
+ if (!field || !field.frm || !field.df) {
+ console.error('Invalid field provided to initForField');
+ return null;
+ }
+
+ const fieldId = `${field.frm.doctype}_${field.df.fieldname}`;
+
+ // Check if editor already exists for this field
+ const existingEditor = this.get(fieldId);
+ if (existingEditor) return existingEditor;
+
+ // Create wrapper element
+ const $wrapper = $('
');
+ field.$wrapper.find('.control-input-wrapper').append($wrapper);
+
+ // Create editor
+ const editorOptions = {
+ id: fieldId,
+ parent: $wrapper[0],
+ doctype: field.frm.doctype,
+ fieldname: field.df.fieldname,
+ doc: field.frm.doc,
+ on_change: (value, referencedFields) => {
+ field.set_input(value);
+
+ // Store referenced fields to update formula when they change
+ if (Array.isArray(referencedFields)) {
+ field._formulaReferencedFields = referencedFields;
+ }
+ },
+ ...options
+ };
+
+ const editor = this.create(editorOptions);
+
+ // Set up field change listener to keep formula in sync
+ field._originalRefresh = field.refresh;
+ field.refresh = () => {
+ const result = field._originalRefresh();
+
+ // Update editor value if it doesn't match field value
+ if (editor.getValue() !== field.value) {
+ editor.setValue(field.value);
+ }
+
+ return result;
+ };
+
+ // Set up listener for referenced fields
+ this._setupReferencedFieldsListeners(field.frm, field);
+
+ return editor;
+ },
+
+ /**
+ * Set up listeners for fields referenced in a formula
+ * @param {frappe.ui.form.Form} frm - Form instance
+ * @param {frappe.ui.form.Field} formulaField - Formula field
+ * @private
+ */
+ _setupReferencedFieldsListeners(frm, formulaField) {
+ if (!frm || !formulaField || !formulaField._formulaReferencedFields) return;
+
+ const referencedFields = formulaField._formulaReferencedFields;
+
+ referencedFields.forEach(fieldname => {
+ const field = frm.fields_dict[fieldname];
+ if (!field) return;
+
+ // Skip if already set up
+ if (field._formulaListenerSetup) return;
+
+ // Set up change listener
+ const originalOnChange = field.df.change;
+ field.df.change = () => {
+ if (typeof originalOnChange === 'function') {
+ originalOnChange();
+ }
+
+ // Update all formula fields that reference this field
+ Object.values(frm.fields_dict).forEach(f => {
+ if (f._formulaReferencedFields &&
+ f._formulaReferencedFields.includes(fieldname) &&
+ f !== formulaField) {
+
+ // Get the editor
+ const editorId = `${frm.doctype}_${f.df.fieldname}`;
+ const editor = frappe.formula_editor.get(editorId);
+
+ if (editor) {
+ // Re-evaluate the formula
+ const result = editor.evaluate();
+ if (result !== undefined) {
+ f.set_input(result);
+ }
+ }
+ }
+ });
+ };
+
+ field._formulaListenerSetup = true;
+ });
+ },
+
+ /**
+ * Initialize formula editor for a report
+ * @param {frappe.views.ReportView|frappe.query_reports.QueryReport} report - Report instance
+ * @param {string} reportName - Report name
+ * @param {string} [reportType='report'] - Report type
+ * @returns {FormulaEditorReportFields} Report fields handler instance
+ */
+ initForReport(report, reportName, reportType = 'report') {
+ if (!report || !reportName) {
+ console.error('Invalid report provided to initForReport');
+ return null;
+ }
+
+ const reportId = `${reportType}_${reportName}`;
+
+ // Check if already initialized
+ if (report._formulaEditor) return report._formulaEditor;
+
+ // Create a minimal editor instance for the report
+ const editorOptions = {
+ id: reportId,
+ doctype: reportName,
+ is_report: true
+ };
+
+ const editor = new FormulaEditor(editorOptions);
+
+ // Create report fields handler
+ const reportFields = new FormulaEditorReportFields({
+ editor: editor,
+ reportFormulas: []
+ });
+
+ // Integrate with the report
+ reportFields.integrateWithReport(report, reportName, reportType);
+
+ // Store reference on the report
+ report._formulaEditor = reportFields;
+
+ return reportFields;
+ },
+
+ /**
+ * Initialize table row formulas for a form
+ * @param {frappe.ui.form.Form} frm - Form instance
+ * @param {Object} [options={}] - Additional options
+ * @returns {FormulaEditorTableRows} Table rows handler instance
+ */
+ initTableFormulas(frm, options = {}) {
+ if (!frm || !frm.doctype) {
+ console.error('Invalid form provided to initTableFormulas');
+ return null;
+ }
+
+ const formId = `table_formulas_${frm.doctype}`;
+
+ // Check if already initialized
+ if (frm._tableFormulasEditor) return frm._tableFormulasEditor;
+
+ // Create a minimal editor instance for the form
+ const editorOptions = {
+ id: formId,
+ doctype: frm.doctype,
+ doc: frm.doc
+ };
+
+ const editor = new FormulaEditor(editorOptions);
+
+ // Create table rows handler
+ const tableRows = new FormulaEditorTableRows({
+ editor: editor,
+ tableFormulas: options.tableFormulas || [],
+ onChange: options.onChange
+ });
+
+ // Attach to the form
+ tableRows.attachToForm(frm);
+
+ // Store reference on the form
+ frm._tableFormulasEditor = tableRows;
+
+ return tableRows;
+ },
+
+ /**
+ * Initialize triggers for a form
+ * @param {frappe.ui.form.Form} frm - Form instance
+ * @param {Object} [options={}] - Additional options
+ * @returns {FormulaEditorTriggers} Triggers handler instance
+ */
+ initTriggers(frm, options = {}) {
+ if (!frm || !frm.doctype) {
+ console.error('Invalid form provided to initTriggers');
+ return null;
+ }
+
+ const formId = `triggers_${frm.doctype}`;
+
+ // Check if already initialized
+ if (frm._triggersEditor) return frm._triggersEditor;
+
+ // Create a minimal editor instance for the form
+ const editorOptions = {
+ id: formId,
+ doctype: frm.doctype,
+ doc: frm.doc,
+ is_trigger: true
+ };
+
+ const editor = new FormulaEditor(editorOptions);
+
+ // Create triggers handler
+ const triggers = new FormulaEditorTriggers({
+ editor: editor,
+ triggers: options.triggers || [],
+ onChange: options.onChange
+ });
+
+ // Attach to the form
+ triggers.attachToForm(frm);
+
+ // Store reference on the form
+ frm._triggersEditor = triggers;
+
+ return triggers;
+ },
+
+ /**
+ * Make a field into a formula editor
+ * @param {frappe.ui.form.Form} frm - Form instance
+ * @param {string} fieldname - Field to convert
+ * @param {Object} [options={}] - Additional options
+ */
+ makeFieldFormula(frm, fieldname, options = {}) {
+ if (!frm || !fieldname) return;
+
+ const field = frm.get_field(fieldname);
+ if (!field) return;
+
+ // Add formula button to the field
+ if (!field.$wrapper.find('.formula-editor-btn').length) {
+ const $btn = $(`
+
+ `);
+
+ field.$wrapper.find('.control-input-wrapper').append($btn);
+
+ // Show formula editor on click
+ $btn.on('click', () => {
+ this.showFieldFormulaDialog(frm, fieldname, options);
+ });
+ }
+ },
+
+ /**
+ * Show formula editor dialog for a field
+ * @param {frappe.ui.form.Form} frm - Form instance
+ * @param {string} fieldname - Field name
+ * @param {Object} [options={}] - Additional options
+ */
+ showFieldFormulaDialog(frm, fieldname, options = {}) {
+ const field = frm.get_field(fieldname);
+ if (!field) return;
+
+ // Create dialog
+ const dialog = new frappe.ui.Dialog({
+ title: __('Formula Editor: {0}', [field.df.label || fieldname]),
+ fields: [
+ {
+ fieldtype: 'Code',
+ fieldname: 'formula',
+ label: __('Formula'),
+ options: 'JavaScript',
+ default: field.value || ''
+ }
+ ],
+ primary_action_label: __('Apply'),
+ primary_action: (values) => {
+ field.set_input(values.formula);
+ dialog.hide();
+ }
+ });
+
+ dialog.show();
+ }
+};
+
+// Register setup hooks
+$(document).on('form-load', function(event, frm) {
+ // Find formula fields and convert them to formula editors
+ frm.meta.fields.forEach(df => {
+ if (df.fieldtype === 'Code' && df.options === 'Formula') {
+ frappe.formula_editor.makeFieldFormula(frm, df.fieldname);
+ }
+ });
+});
+
+export default frappe.formula_editor;
\ No newline at end of file
diff --git a/formula_editor/public/js/report_fields.js b/formula_editor/public/js/report_fields.js
new file mode 100644
index 0000000..53882e2
--- /dev/null
+++ b/formula_editor/public/js/report_fields.js
@@ -0,0 +1,1161 @@
+/**
+ * Formula Editor Report Fields
+ * Handles formula functionality for reports and analytical views
+ */
+
+import {
+ safeEval,
+ validateFormula,
+ formatErrorMessage,
+ debounce,
+ isEmpty,
+ deepClone,
+ showToast
+} from './utils';
+
+class FormulaEditorReportFields {
+ /**
+ * Create a new FormulaEditorReportFields instance
+ * @param {Object} options - Configuration options
+ * @param {Object} options.editor - Reference to the main FormulaEditor instance
+ * @param {Array} [options.reportFormulas=[]] - Initial report formulas
+ * @param {Function} [options.onChange] - Callback for formula changes
+ */
+ constructor(options) {
+ this.options = Object.assign({
+ editor: null,
+ reportFormulas: [],
+ onChange: null
+ }, options);
+
+ // Validate required options
+ if (!this.options.editor) {
+ throw new Error(__('Editor instance is required for FormulaEditorReportFields'));
+ }
+
+ // Initialize properties
+ this.editor = this.options.editor;
+ this.reportFormulas = deepClone(this.options.reportFormulas || []);
+ this.debouncedOnChange = debounce(this.handleChange.bind(this), 300);
+
+ // Initialize
+ this.init();
+ }
+
+ /**
+ * Initialize report formulas
+ * @private
+ */
+ init() {
+ // Ensure all report formulas have proper structure
+ this.validateReportFormulas();
+ }
+
+ /**
+ * Validate report formulas structure
+ * @private
+ */
+ validateReportFormulas() {
+ if (!Array.isArray(this.reportFormulas)) {
+ this.reportFormulas = [];
+ return;
+ }
+
+ // Process each report formula to ensure it has all required properties
+ this.reportFormulas = this.reportFormulas.map(formula => {
+ // Skip if not an object
+ if (typeof formula !== 'object' || formula === null) {
+ return null;
+ }
+
+ // Ensure required properties
+ return {
+ id: formula.id || `report_formula_${Date.now()}`,
+ name: formula.name || __('Unnamed Report Formula'),
+ formula: formula.formula || '',
+ reportType: formula.reportType || 'report', // 'report', 'query-report', 'custom'
+ reportName: formula.reportName || '',
+ fieldName: formula.fieldName || '',
+ columnLabel: formula.columnLabel || formula.fieldName || __('Calculated'),
+ position: formula.position || 'end', // 'start', 'end', after:fieldname
+ positionField: formula.positionField || null,
+ is_numeric: typeof formula.is_numeric === 'boolean' ? formula.is_numeric : true,
+ format: formula.format || null, // 'Currency', 'Percent', etc.
+ enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true,
+ description: formula.description || '',
+ precision: formula.precision !== undefined ? formula.precision : 2,
+ aggregation: formula.aggregation || null // 'sum', 'avg', 'min', 'max', 'count'
+ };
+ }).filter(Boolean); // Remove invalid formulas
+ }
+
+ /**
+ * Get all report formulas
+ * @returns {Array} List of report formulas
+ * @public
+ */
+ getReportFormulas() {
+ return deepClone(this.reportFormulas);
+ }
+
+ /**
+ * Get report formulas for a specific report
+ * @param {string} reportName - Report name
+ * @param {string} [reportType='report'] - Report type ('report', 'query-report', 'custom')
+ * @returns {Array} List of formulas for the report
+ * @public
+ */
+ getFormulasForReport(reportName, reportType = 'report') {
+ return this.reportFormulas.filter(f =>
+ f.reportName === reportName && f.reportType === reportType
+ ).map(f => deepClone(f));
+ }
+
+ /**
+ * Get a report formula by ID
+ * @param {string} id - Formula ID
+ * @returns {Object|null} Formula object or null if not found
+ * @public
+ */
+ getReportFormula(id) {
+ const formula = this.reportFormulas.find(f => f.id === id);
+ return formula ? deepClone(formula) : null;
+ }
+
+ /**
+ * Add a new report formula
+ * @param {Object} formula - Formula data
+ * @returns {string} New formula ID
+ * @public
+ */
+ addReportFormula(formula = {}) {
+ if (!formula.reportName) {
+ throw new Error(__('Report name is required'));
+ }
+
+ const id = formula.id || `report_formula_${Date.now()}`;
+
+ const newFormula = {
+ id: id,
+ name: formula.name || __('Unnamed Report Formula'),
+ formula: formula.formula || '',
+ reportType: formula.reportType || 'report', // 'report', 'query-report', 'custom'
+ reportName: formula.reportName,
+ fieldName: formula.fieldName || `calculated_${id.substring(id.length - 6)}`,
+ columnLabel: formula.columnLabel || formula.fieldName || __('Calculated'),
+ position: formula.position || 'end', // 'start', 'end', after:fieldname
+ positionField: formula.positionField || null,
+ is_numeric: typeof formula.is_numeric === 'boolean' ? formula.is_numeric : true,
+ format: formula.format || null, // 'Currency', 'Percent', etc.
+ enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true,
+ description: formula.description || '',
+ precision: formula.precision !== undefined ? formula.precision : 2,
+ aggregation: formula.aggregation || null // 'sum', 'avg', 'min', 'max', 'count'
+ };
+
+ this.reportFormulas.push(newFormula);
+ this.debouncedOnChange();
+
+ return newFormula.id;
+ }
+
+ /**
+ * Update a report formula
+ * @param {string} id - Formula ID
+ * @param {Object} updates - Properties to update
+ * @returns {boolean} Success status
+ * @public
+ */
+ updateReportFormula(id, updates) {
+ const index = this.reportFormulas.findIndex(f => f.id === id);
+ if (index === -1) return false;
+
+ // Clone the formula
+ const formula = deepClone(this.reportFormulas[index]);
+
+ // Apply updates
+ Object.keys(updates).forEach(key => {
+ if (key !== 'id') { // Don't allow changing the ID
+ formula[key] = updates[key];
+ }
+ });
+
+ // Update the formula
+ this.reportFormulas[index] = formula;
+ this.debouncedOnChange();
+
+ return true;
+ }
+
+ /**
+ * Delete a report formula
+ * @param {string} id - Formula ID
+ * @returns {boolean} Success status
+ * @public
+ */
+ deleteReportFormula(id) {
+ const index = this.reportFormulas.findIndex(f => f.id === id);
+ if (index === -1) return false;
+
+ this.reportFormulas.splice(index, 1);
+ this.debouncedOnChange();
+
+ return true;
+ }
+
+ /**
+ * Enable/disable a report formula
+ * @param {string} id - Formula ID
+ * @param {boolean} enabled - Whether to enable the formula
+ * @returns {boolean} Success status
+ * @public
+ */
+ setReportFormulaEnabled(id, enabled) {
+ return this.updateReportFormula(id, { enabled: !!enabled });
+ }
+
+ /**
+ * Validate a report formula
+ * @param {string} id - Formula ID, or formula string if testOnly is true
+ * @param {boolean} [testOnly=false] - Whether to validate a formula string instead of a saved formula
+ * @returns {Object} Validation result with isValid and error
+ * @public
+ */
+ validateReportFormula(id, testOnly = false) {
+ let formula;
+
+ if (testOnly) {
+ formula = id; // In this case, id is actually the formula string
+ } else {
+ const formulaObj = this.getReportFormula(id);
+ if (!formulaObj) {
+ return { isValid: false, error: __('Formula not found') };
+ }
+ formula = formulaObj.formula;
+ }
+
+ if (!formula || typeof formula !== 'string' || !formula.trim()) {
+ return { isValid: true, error: null };
+ }
+
+ // Use basic formula validation
+ return validateFormula(formula);
+ }
+
+ /**
+ * Apply formulas to report data
+ * @param {Array} data - Report data rows
+ * @param {Array} columns - Report columns
+ * @param {string} reportName - Report name
+ * @param {string} [reportType='report'] - Report type ('report', 'query-report', 'custom')
+ * @returns {Object} Modified data and columns
+ * @public
+ */
+ applyFormulasToReport(data, columns, reportName, reportType = 'report') {
+ try {
+ if (!Array.isArray(data) || !Array.isArray(columns) || !reportName) {
+ return { data, columns };
+ }
+
+ // Get formulas for this report
+ const formulas = this.getFormulasForReport(reportName, reportType)
+ .filter(f => f.enabled);
+
+ if (formulas.length === 0) {
+ return { data, columns };
+ }
+
+ // Create a column map for easy lookup
+ const columnMap = {};
+ columns.forEach((col, index) => {
+ columnMap[col.fieldname] = index;
+ });
+
+ // Process each formula
+ const newData = deepClone(data);
+ const newColumns = deepClone(columns);
+
+ formulas.forEach(formula => {
+ try {
+ // Check if column already exists
+ const existingColumnIndex = columnMap[formula.fieldName];
+
+ // Create new column if needed
+ if (existingColumnIndex === undefined) {
+ const newColumn = {
+ fieldname: formula.fieldName,
+ label: formula.columnLabel,
+ fieldtype: formula.is_numeric ? 'Float' : 'Data',
+ options: formula.format,
+ precision: formula.precision,
+ width: 120,
+ is_formula: true,
+ formula_reference: formula.id
+ };
+
+ // Determine position of the new column
+ if (formula.position === 'start') {
+ newColumns.unshift(newColumn);
+
+ // Update column map
+ Object.keys(columnMap).forEach(key => {
+ columnMap[key]++;
+ });
+ columnMap[formula.fieldName] = 0;
+ } else if (formula.position === 'end' || !formula.positionField) {
+ newColumns.push(newColumn);
+ columnMap[formula.fieldName] = newColumns.length - 1;
+ } else {
+ // Position after specific field
+ const posIndex = columnMap[formula.positionField];
+ if (posIndex !== undefined) {
+ newColumns.splice(posIndex + 1, 0, newColumn);
+
+ // Update column map
+ Object.keys(columnMap).forEach(key => {
+ if (columnMap[key] > posIndex) {
+ columnMap[key]++;
+ }
+ });
+ columnMap[formula.fieldName] = posIndex + 1;
+ } else {
+ // Fallback to end
+ newColumns.push(newColumn);
+ columnMap[formula.fieldName] = newColumns.length - 1;
+ }
+ }
+ }
+
+ // Apply formula to each row
+ newData.forEach((row, rowIndex) => {
+ try {
+ const context = {
+ row: row,
+ data: newData,
+ rowIndex: rowIndex,
+ columns: newColumns,
+ columnMap: columnMap,
+ reportName: reportName,
+ reportType: reportType
+ };
+
+ const result = this.evaluateReportFormula(formula.formula, context);
+
+ // Find where to put the result
+ const targetIdx = columnMap[formula.fieldName];
+
+ if (targetIdx !== undefined) {
+ // If row is an array, extend it if needed
+ if (Array.isArray(row)) {
+ if (row.length <= targetIdx) {
+ while (row.length < targetIdx) {
+ row.push(null);
+ }
+ row.push(result);
+ } else {
+ row[targetIdx] = result;
+ }
+ }
+ // If row is an object, add/update the property
+ else if (typeof row === 'object' && row !== null) {
+ row[formula.fieldName] = result;
+ }
+ }
+ } catch (rowError) {
+ console.error(`Error applying formula to row ${rowIndex}:`, rowError);
+ // Set error value
+ const targetIdx = columnMap[formula.fieldName];
+ if (targetIdx !== undefined) {
+ if (Array.isArray(row)) {
+ row[targetIdx] = '#ERROR';
+ } else if (typeof row === 'object' && row !== null) {
+ row[formula.fieldName] = '#ERROR';
+ }
+ }
+ }
+ });
+
+ // Apply aggregation if specified
+ if (formula.aggregation && formula.is_numeric) {
+ this.applyAggregation(newData, formula);
+ }
+
+ } catch (formulaError) {
+ console.error(`Error processing formula ${formula.id}:`, formulaError);
+ }
+ });
+
+ return { data: newData, columns: newColumns };
+
+ } catch (error) {
+ console.error('Error applying formulas to report:', error);
+ showToast(__('Error applying formulas to report: {0}', [formatErrorMessage(error)]), 'error');
+ return { data, columns };
+ }
+ }
+
+ /**
+ * Evaluate a formula in the context of a report row
+ * @param {string} formula - Formula to evaluate
+ * @param {Object} context - Evaluation context
+ * @returns {*} Evaluation result
+ * @private
+ */
+ evaluateReportFormula(formula, context) {
+ return safeEval(formula, context);
+ }
+
+ /**
+ * Apply aggregation to a formula field
+ * @param {Array} data - Report data
+ * @param {Object} formula - Formula configuration
+ * @private
+ */
+ applyAggregation(data, formula) {
+ if (!Array.isArray(data) || data.length === 0) return;
+
+ const fieldName = formula.fieldName;
+ let result;
+
+ switch (formula.aggregation) {
+ case 'sum':
+ result = this.calculateSum(data, fieldName);
+ break;
+ case 'avg':
+ result = this.calculateAverage(data, fieldName);
+ break;
+ case 'min':
+ result = this.calculateMin(data, fieldName);
+ break;
+ case 'max':
+ result = this.calculateMax(data, fieldName);
+ break;
+ case 'count':
+ result = this.calculateCount(data, fieldName);
+ break;
+ default:
+ return;
+ }
+
+ // Look for total row (last row or one with is_total=true)
+ let totalRow = null;
+
+ for (let i = data.length - 1; i >= 0; i--) {
+ const row = data[i];
+
+ if (typeof row === 'object' && row !== null && row.is_total_row) {
+ totalRow = row;
+ break;
+ }
+ }
+
+ // If no total row found, create one
+ if (!totalRow) {
+ totalRow = { is_total_row: true };
+ data.push(totalRow);
+ }
+
+ // Set the aggregated value
+ if (typeof totalRow === 'object') {
+ totalRow[fieldName] = result;
+ }
+ }
+
+ /**
+ * Calculate sum of a field across all rows
+ * @param {Array} data - Report data
+ * @param {string} fieldName - Field to sum
+ * @returns {number} Sum value
+ * @private
+ */
+ calculateSum(data, fieldName) {
+ let sum = 0;
+
+ data.forEach(row => {
+ if (row.is_total_row) return; // Skip total rows
+
+ const value = this.getRowValue(row, fieldName);
+ if (typeof value === 'number' && !isNaN(value)) {
+ sum += value;
+ }
+ });
+
+ return sum;
+ }
+
+ /**
+ * Calculate average of a field across all rows
+ * @param {Array} data - Report data
+ * @param {string} fieldName - Field to average
+ * @returns {number} Average value
+ * @private
+ */
+ calculateAverage(data, fieldName) {
+ let sum = 0;
+ let count = 0;
+
+ data.forEach(row => {
+ if (row.is_total_row) return; // Skip total rows
+
+ const value = this.getRowValue(row, fieldName);
+ if (typeof value === 'number' && !isNaN(value)) {
+ sum += value;
+ count++;
+ }
+ });
+
+ return count > 0 ? sum / count : 0;
+ }
+
+ /**
+ * Calculate minimum value of a field across all rows
+ * @param {Array} data - Report data
+ * @param {string} fieldName - Field to check
+ * @returns {number} Minimum value
+ * @private
+ */
+ calculateMin(data, fieldName) {
+ let min = Infinity;
+
+ data.forEach(row => {
+ if (row.is_total_row) return; // Skip total rows
+
+ const value = this.getRowValue(row, fieldName);
+ if (typeof value === 'number' && !isNaN(value) && value < min) {
+ min = value;
+ }
+ });
+
+ return min === Infinity ? 0 : min;
+ }
+
+ /**
+ * Calculate maximum value of a field across all rows
+ * @param {Array} data - Report data
+ * @param {string} fieldName - Field to check
+ * @returns {number} Maximum value
+ * @private
+ */
+ calculateMax(data, fieldName) {
+ let max = -Infinity;
+
+ data.forEach(row => {
+ if (row.is_total_row) return; // Skip total rows
+
+ const value = this.getRowValue(row, fieldName);
+ if (typeof value === 'number' && !isNaN(value) && value > max) {
+ max = value;
+ }
+ });
+
+ return max === -Infinity ? 0 : max;
+ }
+
+ /**
+ * Count non-empty values of a field across all rows
+ * @param {Array} data - Report data
+ * @param {string} fieldName - Field to count
+ * @returns {number} Count of non-empty values
+ * @private
+ */
+ calculateCount(data, fieldName) {
+ let count = 0;
+
+ data.forEach(row => {
+ if (row.is_total_row) return; // Skip total rows
+
+ const value = this.getRowValue(row, fieldName);
+ if (value !== null && value !== undefined && value !== '') {
+ count++;
+ }
+ });
+
+ return count;
+ }
+
+ /**
+ * Get a value from a row, handling both array and object formats
+ * @param {Object|Array} row - Report row
+ * @param {string} fieldName - Field name
+ * @returns {*} Field value
+ * @private
+ */
+ getRowValue(row, fieldName) {
+ if (Array.isArray(row)) {
+ // Find the column index
+ const columns = this.options.editor.reportColumns || [];
+ const colIndex = columns.findIndex(c => c.fieldname === fieldName);
+
+ return colIndex !== -1 ? row[colIndex] : null;
+ } else if (typeof row === 'object' && row !== null) {
+ return row[fieldName];
+ }
+
+ return null;
+ }
+
+ /**
+ * Integrate with a Frappe report
+ * @param {Object} report - Frappe report instance
+ * @param {string} reportName - Report name
+ * @param {string} [reportType='report'] - Report type
+ * @returns {boolean} Success status
+ * @public
+ */
+ integrateWithReport(report, reportName, reportType = 'report') {
+ if (!report || !reportName) {
+ return false;
+ }
+
+ try {
+ // Store reference to the report
+ this.currentReport = report;
+ this.currentReportName = reportName;
+ this.currentReportType = reportType;
+
+ // Save original functions
+ if (!report._original_get_data) {
+ report._original_get_data = report.get_data;
+
+ // Override get_data to apply formulas
+ report.get_data = (...args) => {
+ const result = report._original_get_data.apply(report, args);
+
+ if (result && typeof result.then === 'function') {
+ // It's a promise
+ return result.then(response => {
+ const data = response.data || response;
+ const columns = response.columns || report.columns;
+
+ // Apply formulas to the data
+ const modified = this.applyFormulasToReport(
+ data,
+ columns,
+ reportName,
+ reportType
+ );
+
+ // Update the response
+ if (typeof response === 'object') {
+ response.data = modified.data;
+ response.columns = modified.columns;
+ return response;
+ }
+
+ return modified.data;
+ });
+ } else {
+ // It's direct data
+ const data = result || [];
+ const columns = report.columns || [];
+
+ // Apply formulas to the data
+ const modified = this.applyFormulasToReport(
+ data,
+ columns,
+ reportName,
+ reportType
+ );
+
+ // Update the report columns
+ report.columns = modified.columns;
+
+ return modified.data;
+ }
+ };
+ }
+
+ // Also hook into refresh if available
+ if (report.refresh && !report._original_refresh) {
+ report._original_refresh = report.refresh;
+
+ report.refresh = () => {
+ // Call original refresh
+ const result = report._original_refresh.apply(report);
+
+ // Add formula controls to the report
+ setTimeout(() => {
+ this.addFormulaControlsToReport(report, reportName, reportType);
+ }, 500);
+
+ return result;
+ };
+ }
+
+ return true;
+
+ } catch (error) {
+ console.error('Error integrating with report:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Add formula controls to a report UI
+ * @param {Object} report - Frappe report instance
+ * @param {string} reportName - Report name
+ * @param {string} reportType - Report type
+ * @private
+ */
+ addFormulaControlsToReport(report, reportName, reportType) {
+ try {
+ // Check if controls already exist
+ if (report.$formulaControls) return;
+
+ // Find report toolbar
+ const $toolbar = $(report.wrapper).find('.report-action-buttons, .grid-report-action-buttons');
+ if (!$toolbar.length) return;
+
+ // Create formula button
+ const $formulaBtn = $(`
+
+ `);
+
+ // Add button to toolbar
+ $toolbar.append($formulaBtn);
+
+ // Store reference
+ report.$formulaControls = $formulaBtn;
+
+ // Add click handler
+ $formulaBtn.on('click', () => {
+ this.showReportFormulaDialog(reportName, reportType, report);
+ });
+
+ } catch (error) {
+ console.error('Error adding formula controls to report:', error);
+ }
+ }
+
+ /**
+ * Show dialog to manage report formulas
+ * @param {string} reportName - Report name
+ * @param {string} reportType - Report type
+ * @param {Object} report - Report instance
+ * @private
+ */
+ showReportFormulaDialog(reportName, reportType, report) {
+ const formulas = this.getFormulasForReport(reportName, reportType);
+
+ // Get available columns
+ const columns = report.columns || [];
+ const columnOptions = columns.map(col => ({
+ label: col.label || col.fieldname,
+ value: col.fieldname
+ }));
+
+ // Create dialog
+ const dialog = new frappe.ui.Dialog({
+ title: __('Report Formulas'),
+ fields: [
+ {
+ fieldtype: 'HTML',
+ fieldname: 'formula_list',
+ label: __('Formulas'),
+ options: `
`
+ },
+ {
+ fieldtype: 'Section Break',
+ label: __('Add New Formula')
+ },
+ {
+ fieldtype: 'Data',
+ fieldname: 'name',
+ label: __('Name'),
+ reqd: true
+ },
+ {
+ fieldtype: 'Code',
+ fieldname: 'formula',
+ label: __('Formula'),
+ options: 'JavaScript',
+ description: __('Use row to access the current row. Example: row.amount * 1.1')
+ },
+ {
+ fieldtype: 'Column Break'
+ },
+ {
+ fieldtype: 'Data',
+ fieldname: 'fieldName',
+ label: __('Field Name'),
+ description: __('Unique identifier for this formula field')
+ },
+ {
+ fieldtype: 'Data',
+ fieldname: 'columnLabel',
+ label: __('Column Label'),
+ description: __('Display label for the column')
+ },
+ {
+ fieldtype: 'Section Break'
+ },
+ {
+ fieldtype: 'Select',
+ fieldname: 'position',
+ label: __('Position'),
+ options: [
+ { label: __('At Start'), value: 'start' },
+ { label: __('At End'), value: 'end' },
+ { label: __('After Field'), value: 'after' }
+ ],
+ default: 'end'
+ },
+ {
+ fieldtype: 'Select',
+ fieldname: 'positionField',
+ label: __('After Field'),
+ options: columnOptions,
+ depends_on: 'eval:doc.position === "after"'
+ },
+ {
+ fieldtype: 'Column Break'
+ },
+ {
+ fieldtype: 'Check',
+ fieldname: 'is_numeric',
+ label: __('Numeric'),
+ default: 1
+ },
+ {
+ fieldtype: 'Select',
+ fieldname: 'format',
+ label: __('Format'),
+ options: [
+ { label: __('None'), value: '' },
+ { label: __('Currency'), value: 'Currency' },
+ { label: __('Percent'), value: 'Percent' },
+ { label: __('Number'), value: 'Number' }
+ ],
+ default: ''
+ },
+ {
+ fieldtype: 'Int',
+ fieldname: 'precision',
+ label: __('Precision'),
+ default: 2
+ },
+ {
+ fieldtype: 'Section Break'
+ },
+ {
+ fieldtype: 'Select',
+ fieldname: 'aggregation',
+ label: __('Aggregation'),
+ options: [
+ { label: __('None'), value: '' },
+ { label: __('Sum'), value: 'sum' },
+ { label: __('Average'), value: 'avg' },
+ { label: __('Minimum'), value: 'min' },
+ { label: __('Maximum'), value: 'max' },
+ { label: __('Count'), value: 'count' }
+ ],
+ default: ''
+ },
+ {
+ fieldtype: 'Text',
+ fieldname: 'description',
+ label: __('Description')
+ }
+ ],
+ primary_action_label: __('Add Formula'),
+ primary_action: (values) => {
+ // Generate field name if not provided
+ if (!values.fieldName) {
+ values.fieldName = `calculated_${Math.random().toString(36).substr(2, 6)}`;
+ }
+
+ // Add the formula
+ this.addReportFormula({
+ name: values.name,
+ formula: values.formula,
+ reportType: reportType,
+ reportName: reportName,
+ fieldName: values.fieldName,
+ columnLabel: values.columnLabel || values.name,
+ position: values.position,
+ positionField: values.position === 'after' ? values.positionField : null,
+ is_numeric: values.is_numeric,
+ format: values.format,
+ precision: values.precision,
+ aggregation: values.aggregation,
+ description: values.description
+ });
+
+ // Refresh formulas list
+ this.refreshFormulasList(dialog);
+
+ // Clear form
+ dialog.set_values({
+ name: '',
+ formula: '',
+ fieldName: '',
+ columnLabel: '',
+ description: ''
+ });
+
+ // Refresh report
+ if (report && report.refresh) {
+ report.refresh();
+ }
+ }
+ });
+
+ // Render formulas list
+ dialog.show();
+ this.refreshFormulasList(dialog);
+ }
+
+ /**
+ * Refresh the formulas list in the dialog
+ * @param {Object} dialog - Frappe dialog instance
+ * @private
+ */
+ refreshFormulasList(dialog) {
+ const $list = dialog.$wrapper.find('.formula-list');
+ const formulas = this.getFormulasForReport(
+ this.currentReportName,
+ this.currentReportType
+ );
+
+ if (formulas.length === 0) {
+ $list.html(`
${__('No formulas defined for this report')}
`);
+ return;
+ }
+
+ let html = '
';
+ $list.html(html);
+
+ // Add event handlers
+ $list.find('.formula-edit-btn').on('click', (e) => {
+ const $item = $(e.target).closest('.formula-list-item');
+ const id = $item.data('id');
+ this.editReportFormula(id, dialog);
+ });
+
+ $list.find('.formula-toggle-btn').on('click', (e) => {
+ const $item = $(e.target).closest('.formula-list-item');
+ const id = $item.data('id');
+ const formula = this.getReportFormula(id);
+
+ if (formula) {
+ this.setReportFormulaEnabled(id, !formula.enabled);
+ this.refreshFormulasList(dialog);
+
+ // Refresh report
+ if (this.currentReport && this.currentReport.refresh) {
+ this.currentReport.refresh();
+ }
+ }
+ });
+
+ $list.find('.formula-delete-btn').on('click', (e) => {
+ const $item = $(e.target).closest('.formula-list-item');
+ const id = $item.data('id');
+
+ frappe.confirm(
+ __('Delete this formula?'),
+ () => {
+ this.deleteReportFormula(id);
+ this.refreshFormulasList(dialog);
+
+ // Refresh report
+ if (this.currentReport && this.currentReport.refresh) {
+ this.currentReport.refresh();
+ }
+ }
+ );
+ });
+ }
+
+ /**
+ * Edit a report formula
+ * @param {string} id - Formula ID
+ * @param {Object} dialog - Frappe dialog instance
+ * @private
+ */
+ editReportFormula(id, dialog) {
+ const formula = this.getReportFormula(id);
+ if (!formula) return;
+
+ // Switch dialog to edit mode
+ dialog.set_title(__('Edit Formula'));
+
+ // Set values
+ dialog.set_values({
+ name: formula.name,
+ formula: formula.formula,
+ fieldName: formula.fieldName,
+ columnLabel: formula.columnLabel,
+ position: formula.position,
+ positionField: formula.positionField,
+ is_numeric: formula.is_numeric,
+ format: formula.format,
+ precision: formula.precision,
+ aggregation: formula.aggregation,
+ description: formula.description
+ });
+
+ // Change primary action
+ dialog.set_primary_action_label(__('Update Formula'));
+
+ // Store original primary action
+ const originalAction = dialog.primary_action;
+
+ // Set new primary action
+ dialog.primary_action = (values) => {
+ // Update the formula
+ this.updateReportFormula(id, {
+ name: values.name,
+ formula: values.formula,
+ fieldName: values.fieldName,
+ columnLabel: values.columnLabel || values.name,
+ position: values.position,
+ positionField: values.position === 'after' ? values.positionField : null,
+ is_numeric: values.is_numeric,
+ format: values.format,
+ precision: values.precision,
+ aggregation: values.aggregation,
+ description: values.description
+ });
+
+ // Refresh formulas list
+ this.refreshFormulasList(dialog);
+
+ // Reset dialog
+ dialog.set_title(__('Report Formulas'));
+ dialog.set_primary_action_label(__('Add Formula'));
+ dialog.primary_action = originalAction;
+
+ // Clear form
+ dialog.set_values({
+ name: '',
+ formula: '',
+ fieldName: '',
+ columnLabel: '',
+ description: ''
+ });
+
+ // Refresh report
+ if (this.currentReport && this.currentReport.refresh) {
+ this.currentReport.refresh();
+ }
+ };
+ }
+
+ /**
+ * Handle formula changes
+ * @private
+ */
+ handleChange() {
+ if (typeof this.options.onChange === 'function') {
+ this.options.onChange(this.reportFormulas);
+ }
+ }
+
+ /**
+ * Import report formulas from JSON
+ * @param {string|Object} json - JSON string or object
+ * @returns {boolean} Success status
+ * @public
+ */
+ importReportFormulas(json) {
+ try {
+ let data;
+
+ if (typeof json === 'string') {
+ data = JSON.parse(json);
+ } else if (typeof json === 'object' && json !== null) {
+ data = json;
+ } else {
+ throw new Error(__('Invalid import data format'));
+ }
+
+ if (!Array.isArray(data)) {
+ throw new Error(__('Import data must be an array'));
+ }
+
+ // Validate each formula
+ const validFormulas = data.filter(formula =>
+ typeof formula === 'object' &&
+ formula !== null &&
+ typeof formula.reportName === 'string' &&
+ typeof formula.fieldName === 'string'
+ );
+
+ if (validFormulas.length === 0) {
+ throw new Error(__('No valid report formulas found in import data'));
+ }
+
+ // Replace report formulas
+ this.reportFormulas = validFormulas.map(formula => ({
+ id: formula.id || `report_formula_${Date.now()}`,
+ name: formula.name || __('Unnamed Report Formula'),
+ formula: formula.formula || '',
+ reportType: formula.reportType || 'report',
+ reportName: formula.reportName,
+ fieldName: formula.fieldName,
+ columnLabel: formula.columnLabel || formula.fieldName || __('Calculated'),
+ position: formula.position || 'end',
+ positionField: formula.positionField || null,
+ is_numeric: typeof formula.is_numeric === 'boolean' ? formula.is_numeric : true,
+ format: formula.format || null,
+ enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true,
+ description: formula.description || '',
+ precision: formula.precision !== undefined ? formula.precision : 2,
+ aggregation: formula.aggregation || null
+ }));
+
+ this.debouncedOnChange();
+ return true;
+
+ } catch (error) {
+ console.error('Error importing report formulas:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Export report formulas to JSON
+ * @param {boolean} [prettyPrint=false] - Whether to pretty-print the JSON
+ * @returns {string} JSON string
+ * @public
+ */
+ exportReportFormulas(prettyPrint = false) {
+ try {
+ return JSON.stringify(this.reportFormulas, null, prettyPrint ? 2 : 0);
+ } catch (error) {
+ console.error('Error exporting report formulas:', error);
+ return '[]';
+ }
+ }
+}
+
+export default FormulaEditorReportFields;
\ No newline at end of file
diff --git a/formula_editor/public/js/table_row_formulas.js b/formula_editor/public/js/table_row_formulas.js
new file mode 100644
index 0000000..b41efdd
--- /dev/null
+++ b/formula_editor/public/js/table_row_formulas.js
@@ -0,0 +1,851 @@
+/**
+ * Formula Editor Table Row Formulas
+ * Handles formulas for table rows in documents
+ */
+
+import {
+ safeEval,
+ validateFormula,
+ extractReferencedFields,
+ formatErrorMessage,
+ debounce,
+ isEmpty,
+ deepClone,
+ showToast
+} from './utils';
+
+class FormulaEditorTableRows {
+ /**
+ * Create a new FormulaEditorTableRows instance
+ * @param {Object} options - Configuration options
+ * @param {Object} options.editor - Reference to the main FormulaEditor instance
+ * @param {Array} [options.tableFormulas=[]] - Initial table formulas
+ * @param {Function} [options.onChange] - Callback for formula changes
+ */
+ constructor(options) {
+ this.options = Object.assign({
+ editor: null,
+ tableFormulas: [],
+ onChange: null
+ }, options);
+
+ // Validate required options
+ if (!this.options.editor) {
+ throw new Error(__('Editor instance is required for FormulaEditorTableRows'));
+ }
+
+ // Initialize properties
+ this.editor = this.options.editor;
+ this.tableFormulas = deepClone(this.options.tableFormulas || []);
+ this.childTableFields = [];
+ this.debouncedOnChange = debounce(this.handleChange.bind(this), 300);
+
+ // Initialize
+ this.init();
+ }
+
+ /**
+ * Initialize table formulas
+ * @private
+ */
+ init() {
+ // Find all child table fields in the doctype
+ this.loadChildTableFields();
+
+ // Ensure all table formulas have proper structure
+ this.validateTableFormulas();
+ }
+
+ /**
+ * Load child table fields from the doctype
+ * @private
+ */
+ loadChildTableFields() {
+ const allFields = this.editor.fields || [];
+
+ // Filter to only table type fields
+ this.childTableFields = allFields.filter(field =>
+ field.type === 'Table' && field.options
+ );
+ }
+
+ /**
+ * Validate table formulas structure and add missing properties
+ * @private
+ */
+ validateTableFormulas() {
+ if (!Array.isArray(this.tableFormulas)) {
+ this.tableFormulas = [];
+ return;
+ }
+
+ // Process each table formula to ensure it has all required properties
+ this.tableFormulas = this.tableFormulas.map(formula => {
+ // Skip if not an object
+ if (typeof formula !== 'object' || formula === null) {
+ return null;
+ }
+
+ // Check if table field exists
+ const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field);
+ if (!tableField) {
+ return null;
+ }
+
+ // Ensure required properties
+ return {
+ id: formula.id || `${formula.table_field}_${formula.target_field}_formula`,
+ name: formula.name || __('Formula for {0}', [formula.target_field]),
+ table_field: formula.table_field,
+ target_field: formula.target_field,
+ source_fields: Array.isArray(formula.source_fields) ? formula.source_fields : [],
+ formula: formula.formula || '',
+ child_doctype: tableField.options,
+ enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true,
+ apply_on: formula.apply_on || 'change', // change, save, manual
+ is_cumulative: !!formula.is_cumulative,
+ description: formula.description || ''
+ };
+ }).filter(Boolean); // Remove invalid formulas
+ }
+
+ /**
+ * Get all table formulas
+ * @returns {Array} List of table formulas
+ * @public
+ */
+ getTableFormulas() {
+ return deepClone(this.tableFormulas);
+ }
+
+ /**
+ * Get a table formula by ID
+ * @param {string} id - Formula ID
+ * @returns {Object|null} Formula object or null if not found
+ * @public
+ */
+ getTableFormula(id) {
+ const formula = this.tableFormulas.find(f => f.id === id);
+ return formula ? deepClone(formula) : null;
+ }
+
+ /**
+ * Get table formulas for a specific table field
+ * @param {string} tableField - Table field name
+ * @returns {Array} List of formulas for the table field
+ * @public
+ */
+ getFormulasForTable(tableField) {
+ return this.tableFormulas.filter(f => f.table_field === tableField).map(f => deepClone(f));
+ }
+
+ /**
+ * Add a new table formula
+ * @param {Object} formula - Formula data
+ * @returns {string} New formula ID
+ * @public
+ */
+ addTableFormula(formula = {}) {
+ if (!formula.table_field || !formula.target_field) {
+ throw new Error(__('Table field and target field are required'));
+ }
+
+ // Check if table field exists
+ const tableField = this.childTableFields.find(f => f.fieldname === formula.table_field);
+ if (!tableField) {
+ throw new Error(__('Table field {0} not found', [formula.table_field]));
+ }
+
+ const newFormula = {
+ id: formula.id || `${formula.table_field}_${formula.target_field}_formula`,
+ name: formula.name || __('Formula for {0}', [formula.target_field]),
+ table_field: formula.table_field,
+ target_field: formula.target_field,
+ source_fields: Array.isArray(formula.source_fields) ? deepClone(formula.source_fields) : [],
+ formula: formula.formula || '',
+ child_doctype: tableField.options,
+ enabled: typeof formula.enabled === 'boolean' ? formula.enabled : true,
+ apply_on: formula.apply_on || 'change',
+ is_cumulative: !!formula.is_cumulative,
+ description: formula.description || ''
+ };
+
+ this.tableFormulas.push(newFormula);
+ this.debouncedOnChange();
+
+ return newFormula.id;
+ }
+
+ /**
+ * Update a table formula
+ * @param {string} id - Formula ID
+ * @param {Object} updates - Properties to update
+ * @returns {boolean} Success status
+ * @public
+ */
+ updateTableFormula(id, updates) {
+ const index = this.tableFormulas.findIndex(f => f.id === id);
+ if (index === -1) return false;
+
+ // Clone the formula
+ const formula = deepClone(this.tableFormulas[index]);
+
+ // Apply updates
+ Object.keys(updates).forEach(key => {
+ // Handle special properties
+ if (key === 'source_fields' && Array.isArray(updates.source_fields)) {
+ formula.source_fields = deepClone(updates.source_fields);
+ } else if (key === 'table_field') {
+ // If table field is updated, verify it exists
+ const tableField = this.childTableFields.find(f => f.fieldname === updates.table_field);
+ if (tableField) {
+ formula.table_field = updates.table_field;
+ formula.child_doctype = tableField.options;
+ }
+ } else if (key !== 'id' && key !== 'child_doctype') { // Don't allow changing ID or child_doctype directly
+ formula[key] = updates[key];
+ }
+ });
+
+ // Update the formula
+ this.tableFormulas[index] = formula;
+ this.debouncedOnChange();
+
+ return true;
+ }
+
+ /**
+ * Delete a table formula
+ * @param {string} id - Formula ID
+ * @returns {boolean} Success status
+ * @public
+ */
+ deleteTableFormula(id) {
+ const index = this.tableFormulas.findIndex(f => f.id === id);
+ if (index === -1) return false;
+
+ this.tableFormulas.splice(index, 1);
+ this.debouncedOnChange();
+
+ return true;
+ }
+
+ /**
+ * Enable/disable a table formula
+ * @param {string} id - Formula ID
+ * @param {boolean} enabled - Whether to enable the formula
+ * @returns {boolean} Success status
+ * @public
+ */
+ setTableFormulaEnabled(id, enabled) {
+ return this.updateTableFormula(id, { enabled: !!enabled });
+ }
+
+ /**
+ * Validate a table formula
+ * @param {string} id - Formula ID
+ * @returns {Object} Validation result with isValid and error
+ * @public
+ */
+ validateTableFormula(id) {
+ const formula = this.getTableFormula(id);
+ if (!formula) {
+ return { isValid: false, error: __('Formula not found') };
+ }
+
+ if (!formula.formula.trim()) {
+ return { isValid: true, error: null };
+ }
+
+ // Use basic formula validation
+ return validateFormula(formula.formula);
+ }
+
+ /**
+ * Get field options for a child table
+ * @param {string} childDoctype - Child doctype name
+ * @returns {Promise
} List of field options
+ * @public
+ */
+ async getChildTableFields(childDoctype) {
+ try {
+ let meta;
+
+ // Try to get meta from cache
+ meta = frappe.get_meta(childDoctype);
+
+ if (!meta) {
+ // If not in cache, fetch it
+ meta = await frappe.model.with_doctype(childDoctype);
+ }
+
+ if (!meta || !meta.fields) {
+ throw new Error(__('Could not fetch metadata for {0}', [childDoctype]));
+ }
+
+ const fields = [];
+
+ // Add fields from the child DocType
+ for (const field of meta.fields) {
+ // Skip certain field types
+ if (['Section Break', 'Column Break', 'Tab Break', 'HTML', 'Button'].includes(field.fieldtype)) {
+ continue;
+ }
+
+ fields.push({
+ fieldname: field.fieldname,
+ label: field.label || field.fieldname,
+ type: field.fieldtype,
+ options: field.options,
+ is_child_table: field.fieldtype === 'Table'
+ });
+ }
+
+ return fields;
+
+ } catch (error) {
+ console.error(`Error getting fields for child doctype ${childDoctype}:`, error);
+ return [];
+ }
+ }
+
+ /**
+ * Apply formulas to a table
+ * @param {Object} doc - Parent document
+ * @param {string} tableField - Table field name
+ * @param {Object} [options] - Options for applying formulas
+ * @param {boolean} [options.silent=false] - Whether to suppress notifications
+ * @returns {Promise