work #1

Closed
ali wants to merge 5 commits from work into main
17 changed files with 9253 additions and 9894 deletions

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@ app_description = "Advanced formula editing capabilities for Frappe/ERPNext"
app_email = "your.email@example.com"
app_license = "MIT"
# Includes in <head>
# ------------------
@ -24,7 +25,7 @@ app_license = "MIT"
# include js in doctype views
doctype_js = {
"DocType": "public/js/doctype_formula_editor.js",
"Formula Editor": "formula_editor/doctype/formula_editor/formula_editor_main.js",
}
# include js in doctype list views

View File

@ -0,0 +1,458 @@
function initDragAndDrop($modal, $resultContainer, isTableField, frm) {
// Очищаем предыдущие обработчики
$modal.find('.field-btn, .operator-btn, .universal-field-btn, .report-field-btn').off('dragstart');
$resultContainer.off('dragover dragleave drop');
// Настраиваем перетаскивание существующих токенов
setupTokenDragging($resultContainer);
// Настраиваем перетаскивание полей из панели
$modal.find('.field-btn').off('dragstart').on('dragstart', function(e) {
const fieldname = $(this).data('fieldname');
const label = $(this).data('label');
const group = $(this).data('group');
// Проверяем принадлежность к группе
const isTableFieldGroup = group !== "Основные поля";
const fieldData = {
type: 'field',
fieldname: fieldname,
label: label,
group: group,
isTableField: isTableFieldGroup
};
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify(fieldData));
});
// Настраиваем перетаскивание универсальных полей
$modal.find('.universal-field-btn').off('dragstart').on('dragstart', function(e) {
const fieldId = $(this).data('field-id');
const fieldname = $(this).data('fieldname');
const label = $(this).data('label');
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
type: 'universal-field',
fieldId: fieldId,
fieldname: fieldname,
label: label
}));
});
// Настраиваем перетаскивание полей отчетов
$modal.find('.report-field-btn').off('dragstart').on('dragstart', function(e) {
const fieldId = $(this).data('field-id');
const fieldname = $(this).data('fieldname');
const label = $(this).data('label');
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
type: 'report-field',
fieldId: fieldId,
fieldname: fieldname,
label: label
}));
});
// Настраиваем перетаскивание операторов
$modal.find('.operator-btn').off('dragstart').on('dragstart', function(e) {
const operator = $(this).data('operator');
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
type: 'operator',
value: operator
}));
});
// Обработчики для целевого контейнера
$resultContainer.off('dragover').on('dragover', function(e) {
e.preventDefault();
e.stopPropagation();
$(this).addClass('drag-hover');
updateDropMarker(e, $(this));
});
$resultContainer.off('dragleave').on('dragleave', function(e) {
$(this).removeClass('drag-hover');
$(this).find('.drop-marker').remove();
});
$resultContainer.off('drop').on('drop', function(e) {
e.preventDefault();
e.stopPropagation();
$(this).removeClass('drag-hover');
$(this).find('.drop-marker').remove();
handleDrop(e, $(this), isTableField, frm);
});
}
function initTriggerDragAndDrop($modal, $triggerContainer) {
// Очищаем предыдущие обработчики
$modal.find('.field-btn, .universal-field-btn, .trigger-field-btn').off('dragstart');
$triggerContainer.off('dragover dragleave drop');
// Настраиваем перетаскивание существующих токенов в триггер-контейнере
$triggerContainer.find('.trigger-token').each(function() {
$(this).attr('draggable', 'true');
$(this).off('dragstart').on('dragstart', function(e) {
// Сохраняем ссылку на текущий элемент
window.draggedTriggerElement = this;
// Добавляем данные для переноса
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
type: 'trigger-token',
value: $(this).data('value'),
html: $(this)[0].outerHTML,
tokenClass: $(this).attr('class')
}));
// Добавляем класс перетаскивания
$(this).addClass('dragging');
});
// При завершении перетаскивания
$(this).off('dragend').on('dragend', function() {
$(this).removeClass('dragging');
window.draggedTriggerElement = null;
});
});
// Настраиваем перетаскивание полей из панели в триггер-контейнер
$modal.find('.field-btn, .universal-field-btn, .trigger-field-btn').off('dragstart').on('dragstart', function(e) {
const fieldname = $(this).data('fieldname');
const label = $(this).data('label');
let fieldType = 'field';
if ($(this).hasClass('universal-field-btn')) {
fieldType = 'universal';
} else if ($(this).hasClass('highlight-event')) {
fieldType = 'event';
} else if ($(this).hasClass('table-field-btn')) {
fieldType = 'table';
}
const fieldData = {
type: 'trigger-field',
fieldname: fieldname,
label: label,
fieldType: fieldType
};
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify(fieldData));
});
// Обработка dragover для триггер-контейнера
$triggerContainer.off('dragover').on('dragover', function(e) {
e.preventDefault();
e.stopPropagation();
$(this).addClass('drag-hover');
// Обновляем маркер сброса
updateDropMarker(e, $(this));
});
// Обработка dragleave для триггер-контейнера
$triggerContainer.off('dragleave').on('dragleave', function(e) {
e.preventDefault();
$(this).removeClass('drag-hover');
$(this).find('.drop-marker').remove();
});
// Обработка drop для триггер-контейнера
$triggerContainer.off('drop').on('drop', function(e) {
e.preventDefault();
e.stopPropagation();
$(this).removeClass('drag-hover');
$(this).find('.drop-marker').remove();
// Обрабатываем сброшенные данные
handleTriggerDrop(e, $(this));
});
}
function updateDropMarker(e, $container) {
const x = e.originalEvent.clientX;
const y = e.originalEvent.clientY;
let position = { element: null, position: 'after' };
// Проходим по всем дочерним элементам контейнера
$container.contents().each(function() {
if (this.nodeType === Node.TEXT_NODE) return;
const rect = this.getBoundingClientRect();
// Если курсор находится над элементом
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
position.element = this;
// Определяем, до или после элемента
if (x < rect.left + rect.width / 2) {
position.position = 'before';
} else {
position.position = 'after';
}
return false; // Прерываем перебор
}
});
$container.find('.drop-marker').remove();
if (position.element) {
const $marker = $('<span class="drop-marker" style="display:inline-block;width:2px;height:18px;background-color:#4285f4;vertical-align:middle;"></span>');
if (position.position === 'before') {
$(position.element).before($marker);
} else {
$(position.element).after($marker);
}
}
}
function handleDrop(e, $container, isTableField, frm) {
try {
// Получаем данные из события перетаскивания
const dataTransfer = e.originalEvent.dataTransfer.getData('text/plain');
// Проверяем, что данные не пустые
if (!dataTransfer) {
return;
}
const data = JSON.parse(dataTransfer);
const x = e.originalEvent.clientX;
const y = e.originalEvent.clientY;
let position = findDropPosition(x, y, $container);
// Обработка перетаскивания существующих токенов
if (data.type === 'token' && window.draggedElement) {
handleExistingTokenDrop(window.draggedElement, position, $container);
return;
}
// Создание нового элемента
let $newElement;
if (data.type === 'universal-field') {
// Получаем универсальные поля
const universalFields = JSON.parse(frm.doc.universal_fields_storage || "{}");
const field = universalFields[data.fieldId];
if (field) {
const formulaText = `univ(${field.name})`;
$newElement = $(`<span class="formula-token field universal-field" data-value="${formulaText}" draggable="true" contenteditable="false">${formulaText}<span class="token-remove">×</span></span>`);
}
} else if (data.type === 'report-field') {
// Обработка поля отчета
const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}");
const field = reportFields[data.fieldId];
if (field) {
const formulaText = `report(${field.name})`;
$newElement = $(`<span class="formula-token field report-field" data-value="${formulaText}" draggable="true" contenteditable="false">${formulaText}<span class="token-remove">×</span></span>`);
}
} else if (data.type === 'field') {
$newElement = createFieldToken(data, isTableField);
} else if (data.type === 'operator') {
$newElement = createOperatorToken(data.value);
}
// Вставляем новый элемент
if ($newElement) {
insertElementAtPosition($newElement, position, $container);
// Инициализируем события для нового элемента
initializeTokenEvents($newElement);
}
} catch (error) {
}
}
function handleTriggerDrop(e, $container) {
try {
const data = JSON.parse(e.originalEvent.dataTransfer.getData('text/plain'));
// Получаем позицию сброса
const x = e.originalEvent.clientX;
const y = e.originalEvent.clientY;
let position = findDropPosition(x, y, $container);
// Обработка перетаскивания существующих токенов
if (data.type === 'trigger-token' && window.draggedTriggerElement) {
const $dragged = $(window.draggedTriggerElement);
if (position.element) {
if (position.position === 'before') {
$(position.element).before($dragged);
$(position.element).before(' ');
} else {
$(position.element).after($dragged);
$(position.element).after(' ');
}
} else {
$container.append($dragged);
$container.append(' ');
}
window.draggedTriggerElement = null;
return;
}
// Обработка перетаскивания полей из панели
if (data.type === 'field' || data.type === 'trigger-field') {
// Проверяем, не добавлено ли уже это поле
const fieldname = data.fieldname;
const existingTokens = $container.find(`.trigger-token[data-value="${fieldname}"]`);
if (existingTokens.length > 0) {
return; // Если поле уже добавлено, не добавляем его снова
}
// Определяем тип поля и создаем соответствующий токен
let fieldType = 'field';
if (data.fieldType === 'universal') {
fieldType = 'universal';
} else if (data.fieldType === 'table') {
fieldType = 'table';
} else if (data.fieldType === 'table-row') {
fieldType = 'table-row';
} else if (data.fieldType === 'event') {
fieldType = 'event';
}
// Создаем токен
const $token = createTriggerToken(fieldname, fieldType);
// Вставляем токен в нужную позицию
if (position.element) {
if (position.position === 'before') {
$(position.element).before($token);
$(position.element).before(' ');
} else {
$(position.element).after($token);
$(position.element).after(' ');
}
} else {
$container.append($token);
$container.append(' ');
}
// Инициализируем события для нового токена
initTriggerTokenEvents($token);
}
} catch (error) {
}
}
function findDropPosition(x, y, $container) {
let position = { element: null, position: 'after' };
// Проходим по всем дочерним элементам контейнера
$container.contents().each(function() {
if (this.nodeType === Node.TEXT_NODE) return;
const rect = this.getBoundingClientRect();
// Если курсор находится над элементом
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
position.element = this;
// Определяем, до или после элемента
if (x < rect.left + rect.width / 2) {
position.position = 'before';
} else {
position.position = 'after';
}
return false; // Прерываем перебор
}
});
return position;
}
function handleExistingTokenDrop(draggedElement, position, $container) {
const $dragged = $(draggedElement);
if (position.element) {
if (position.position === 'before') {
$(position.element).before($dragged);
$(position.element).before(' ');
} else {
$(position.element).after($dragged);
$(position.element).after(' ');
}
} else {
$container.append($dragged);
$container.append(' ');
}
window.draggedElement = null;
}
function createFieldToken(data, isTableField) {
let formulaText;
// Определяем тип поля и формулу
const fieldInfo = determineFieldType(data.fieldname, data.group, isTableField);
if (fieldInfo.type === 'row') {
formulaText = `row(${fieldInfo.fieldName})`;
} else if (fieldInfo.type === 'sum') {
formulaText = `sum(${fieldInfo.fieldName})`;
} else {
formulaText = `val(${fieldInfo.fieldName})`;
}
return $(`<span class="formula-token field" data-value="${formulaText}" draggable="true" contenteditable="false">${formulaText}<span class="token-remove">×</span></span>`);
}
function createOperatorToken(value) {
return $(`<span class="formula-token operator" data-value="${value}" draggable="true" contenteditable="false">${value}<span class="token-remove">×</span></span>`);
}
function insertElementAtPosition($element, position, $container) {
if (position.element) {
if (position.position === 'before') {
$(position.element).before($element);
$(position.element).before(' ');
} else {
$(position.element).after($element);
$(position.element).after(' ');
}
} else {
$container.append($element);
$container.append(' ');
}
}
function initializeTokenEvents($token) {
$token.find('.token-remove').on('click', function(e) {
e.stopPropagation();
$(this).parent().remove();
});
$token.attr('draggable', 'true');
$token.off('dragstart').on('dragstart', function(e) {
window.draggedElement = this;
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
type: 'token',
value: $(this).data('value'),
html: $(this)[0].outerHTML
}));
$(this).addClass('dragging');
return true;
});
$token.off('dragend').on('dragend', function(e) {
$(this).removeClass('dragging');
window.draggedElement = null;
});
}

View File

@ -0,0 +1,477 @@
function getFormulaText($container) {
// Собираем все токены и текст в строку
let formula = '';
$container.contents().each(function() {
if (this.nodeType === Node.TEXT_NODE) {
formula += this.textContent;
} else {
const $elem = $(this);
if ($elem.hasClass('formula-token')) {
formula += $elem.data('value');
}
}
});
// Удаляем лишние пробелы
return formula.replace(/\s+/g, ' ').trim();
}
function parseAndRenderFormula($container, formula, isTableField) {
if (!formula) return;
// Регулярные выражения для поиска функций и операторов
const funcRegex = /(val|row|sum|univ|report|table)\(([^)]+)\)/g;
const operatorsRegex = /([+\-*/()%])/g;
// Сохраняем текущую позицию
let lastIndex = 0;
let match;
let formulaParts = [];
// Поиск всех функций
while ((match = funcRegex.exec(formula)) !== null) {
// Добавляем текст до функции
if (match.index > lastIndex) {
formulaParts.push({
type: 'text',
value: formula.substring(lastIndex, match.index)
});
}
// Добавляем функцию
formulaParts.push({
type: 'function',
value: match[0],
func: match[1],
param: match[2]
});
lastIndex = match.index + match[0].length;
}
// Добавляем оставшийся текст после последней функции
if (lastIndex < formula.length) {
formulaParts.push({
type: 'text',
value: formula.substring(lastIndex)
});
}
// Разбираем операторы в частях, которые не являются функциями
let finalParts = [];
formulaParts.forEach(part => {
if (part.type === 'function') {
finalParts.push(part);
} else if (part.type === 'text') {
let text = part.value;
lastIndex = 0;
// Сбрасываем lastIndex для operatorsRegex
operatorsRegex.lastIndex = 0;
// Разбираем операторы
while ((match = operatorsRegex.exec(text)) !== null) {
// Добавляем текст до оператора
if (match.index > lastIndex) {
const textBefore = text.substring(lastIndex, match.index).trim();
if (textBefore) {
finalParts.push({
type: 'text',
value: textBefore
});
}
}
// Добавляем оператор
finalParts.push({
type: 'operator',
value: match[1]
});
lastIndex = match.index + match[1].length;
}
// Добавляем оставшийся текст после последнего оператора
if (lastIndex < text.length) {
const textAfter = text.substring(lastIndex).trim();
if (textAfter) {
finalParts.push({
type: 'text',
value: textAfter
});
}
}
}
});
// Рендерим части формулы
finalParts.forEach(part => {
if (part.type === 'function') {
// Определяем класс в зависимости от типа функции
let tokenClass = 'field';
if (part.func === 'univ') {
tokenClass = 'field universal-field';
} else if (part.func === 'report') {
tokenClass = 'field report-field';
} else if (part.func === 'table') {
tokenClass = 'field table-field';
}
const $token = $(`<span class="formula-token ${tokenClass}" data-value="${part.value}" contenteditable="false" draggable="true">${part.value}<span class="token-remove">×</span></span>`);
$container.append($token).append(' ');
} else if (part.type === 'operator') {
const $token = $(`<span class="formula-token operator" data-value="${part.value}" contenteditable="false" draggable="true">${part.value}<span class="token-remove">×</span></span>`);
$container.append($token).append(' ');
} else if (part.type === 'text') {
$container.append(document.createTextNode(`${part.value} `));
}
});
// Добавляем обработчики для токенов
addTokenHandlers($container);
return formula;
}
function addTokenHandlers($container) {
// Удаление токенов
$container.find('.token-remove').off('click').on('click', function(e) {
e.stopPropagation();
$(this).parent().remove();
});
// Перетаскивание токенов
$container.find('.formula-token').each(function() {
$(this).attr('draggable', 'true');
$(this).off('dragstart').on('dragstart', function(e) {
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
type: 'token',
value: $(this).data('value'),
html: $(this)[0].outerHTML
}));
window.draggedElement = this;
});
$(this).off('dragend').on('dragend', function() {
window.draggedElement = null;
});
});
}
function addTokenToFormula(token, type, $resultContainer, isTableField = false, group = null, frm = null) {
if (type === 'field') {
let formulaText;
// Определяем тип поля и формулу
const fieldInfo = determineFieldType(token, group, isTableField);
if (fieldInfo.type === 'table_row') {
// Для полей конкретных строк таблиц
formulaText = `val(${fieldInfo.fullFieldName})`;
} else if (fieldInfo.type === 'row') {
formulaText = `row(${fieldInfo.fieldName})`;
} else if (fieldInfo.type === 'sum') {
let tableName = fieldInfo.tableName || token.match(/^([a-z_]+)/)[1];
let fieldWithoutPrefix = token.substring(tableName.length);
if (fieldWithoutPrefix.startsWith('_')) {
fieldWithoutPrefix = fieldWithoutPrefix.substring(1);
}
formulaText = `sum(${tableName}_${fieldWithoutPrefix})`;
} else {
formulaText = `val(${fieldInfo.fieldName})`;
}
const $token = $(`<span class="formula-token field" data-value="${formulaText}" contenteditable="false" draggable="true">${formulaText}<span class="token-remove">×</span></span>`);
$resultContainer.append($token).append(' ');
} else if (type === 'operator') {
const $token = $(`<span class="formula-token operator" data-value="${token}" contenteditable="false" draggable="true">${token}<span class="token-remove">×</span></span>`);
$resultContainer.append($token).append(' ');
} else if (type === 'universal-field') {
// Обработка для универсальных полей
const universalFields = JSON.parse(frm.doc.universal_fields_storage || "{}");
const field = universalFields[token];
if (field) {
const formulaText = `univ(${field.name})`;
const $token = $(`<span class="formula-token field universal-field" data-value="${formulaText}" contenteditable="false" draggable="true">${formulaText}<span class="token-remove">×</span></span>`);
$resultContainer.append($token).append(' ');
}
} else if (type === 'report-field') {
// Обработка для полей отчетов
const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}");
const field = reportFields[token];
if (field) {
const formulaText = `report(${field.name})`;
const $token = $(`<span class="formula-token field report-field" data-value="${formulaText}" contenteditable="false" draggable="true">${formulaText}<span class="token-remove">×</span></span>`);
$resultContainer.append($token).append(' ');
}
}
// Переносим курсор в конец контейнера
setEndOfContentEditable($resultContainer[0]);
// Добавляем обработчики для токенов
addTokenHandlers($resultContainer);
}
function determineFieldType(fieldName, group, isInTableField) {
// Проверяем, является ли поле ссылкой на строку табличной части
const rowPattern = /^([a-z_]+)_row_(\d+)_([a-z_]+)$/i;
const rowMatch = fieldName.match(rowPattern);
if (rowMatch) {
const tableName = rowMatch[1];
const rowIndex = rowMatch[2];
const fieldNameInRow = rowMatch[3];
return {
type: 'table_row',
tableName: tableName,
rowIndex: rowIndex,
fieldName: fieldNameInRow,
fullFieldName: fieldName
};
}
// Получаем части имени поля
// Проверяем существование имен групп "items", "taxes" и т.д. без подчеркивания
const tablePrefixes = ["items", "taxes", "packed_items", "payments", "advances", "timesheets", "payment_schedule", "sales_team"];
// Проверяем, начинается ли fieldName с известного префикса таблицы
let isTableField = false;
let tableName = "";
for (const prefix of tablePrefixes) {
if (fieldName.startsWith(prefix)) {
// Это поле табличной части без подчеркивания (например, "itemsqty")
isTableField = true;
tableName = prefix;
break;
}
}
// Если не нашли по префиксу, проверяем по первой части с подчеркиванием
if (!isTableField) {
const parts = fieldName.split('_');
if (parts.length > 1) {
const potentialTableName = parts[0];
if (tablePrefixes.includes(potentialTableName)) {
isTableField = true;
tableName = potentialTableName;
}
}
}
// Определяем тип формулы
if (isTableField) {
// Для полей таблицы
if (isInTableField) {
// В табличной части используем row()
// Извлекаем имя поля без префикса таблицы
let fieldNameWithoutTable = fieldName;
if (fieldName.startsWith(tableName)) {
fieldNameWithoutTable = fieldName.substring(tableName.length);
// Если есть подчеркивание, удаляем его
if (fieldNameWithoutTable.startsWith('_')) {
fieldNameWithoutTable = fieldNameWithoutTable.substring(1);
}
}
return {
type: 'row',
fieldName: fieldNameWithoutTable
};
} else {
// В основном документе используем sum()
return {
type: 'sum',
fieldName: fieldName,
tableName: tableName
};
}
} else {
// Для обычных полей всегда val()
return {
type: 'val',
fieldName: fieldName
};
}
}
function openFormulaBuilder(frm, targetFieldname, isTableField, currentFormula, fields, grouped_fields) {
const $modal = $("#formula_builder_modal");
const $resultContainer = $modal.find(".formula-result");
// Очистка предыдущих обработчиков и данных
$modal.off('click', '.field-btn');
$modal.off('click', '.universal-field-btn');
$modal.off('click', '.report-field-btn');
$modal.off('click', '.operator-btn');
$modal.find('.apply-formula').off('click');
$resultContainer.empty();
$modal.find(".field-groups-container").empty();
// Заполняем текущую формулу, если она есть
if (currentFormula) {
parseAndRenderFormula($resultContainer, currentFormula, isTableField);
}
// Заполняем панель с полями
populateFieldsPanel($modal, frm, grouped_fields, isTableField);
// Сохраняем данные о текущем поле
$modal.data("target-field", targetFieldname);
$modal.data("is-table-field", isTableField);
// Обработчики для кнопок операторов
$modal.find('.operator-btn').off('click').on('click', function() {
const operator = $(this).data('operator');
addTokenToFormula(operator, 'operator', $resultContainer);
});
// Обработчик для кнопки применения формулы
$modal.find('.apply-formula').off('click').on('click', function() {
const formula = getFormulaText($resultContainer);
const $targetInput = $(frm.wrapper).find(`.formula-input[data-fieldname="${targetFieldname}"]`);
$targetInput.val(formula);
$modal.modal('hide');
});
// Обработчики для кнопок полей - используем делегирование событий
$modal.off('click', '.field-btn').on('click', '.field-btn', function() {
const fieldname = $(this).data('fieldname');
addTokenToFormula(fieldname, 'field', $resultContainer, isTableField, $(this).data('group'));
});
// Обработчики для кнопок универсальных полей
$modal.off('click', '.universal-field-btn').on('click', '.universal-field-btn', function() {
const fieldId = $(this).data('field-id');
const universalFields = JSON.parse(frm.doc.universal_fields_storage || "{}");
const field = universalFields[fieldId];
if (field) {
const formulaText = `univ(${field.name})`;
const $token = $(`<span class="formula-token field universal-field" data-value="${formulaText}" contenteditable="false" draggable="true">${formulaText}<span class="token-remove">×</span></span>`);
$resultContainer.append($token).append(' ');
// Добавляем обработчики для токенов
addTokenHandlers($resultContainer);
// Переносим курсор в конец контейнера
setEndOfContentEditable($resultContainer[0]);
}
});
// Обработчики для кнопок полей отчетов
$modal.off('click', '.report-field-btn').on('click', '.report-field-btn', function() {
const fieldId = $(this).data('field-id');
const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}");
const field = reportFields[fieldId];
if (field) {
const formulaText = `report(${field.name})`;
const $token = $(`<span class="formula-token field report-field" data-value="${formulaText}" contenteditable="false" draggable="true">${formulaText}<span class="token-remove">×</span></span>`);
$resultContainer.append($token).append(' ');
// Добавляем обработчики для токенов
addTokenHandlers($resultContainer);
// Переносим курсор в конец контейнера
setEndOfContentEditable($resultContainer[0]);
}
});
// Показываем модальное окно
$modal.modal('show');
// Инициализируем перетаскивание
initDragAndDrop($modal, $resultContainer, isTableField, frm);
// Настраиваем редактируемый контейнер
setupEditableContainer($resultContainer);
// Фокусируемся на редактируемом поле
$resultContainer.focus();
}
function setupEditableContainer($container) {
// Обработчик нажатия клавиш в контейнере формулы
$container.off('keydown').on('keydown', function(e) {
// Если нажата клавиша Delete или Backspace
if (e.keyCode === 8 || e.keyCode === 46) {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.commonAncestorContainer.nodeType === Node.TEXT_NODE) {
// Текстовый узел - обычное удаление
return true;
} else {
// Возможно, выделен токен
const $selectedNode = $(range.commonAncestorContainer);
if ($selectedNode.hasClass('formula-token') || $selectedNode.parents('.formula-token').length) {
// Удаляем токен
const $token = $selectedNode.hasClass('formula-token') ?
$selectedNode : $selectedNode.parents('.formula-token');
$token.remove();
e.preventDefault();
return false;
}
}
}
}
return true;
});
// Обработчик вставки текста
$container.off('paste').on('paste', function(e) {
e.preventDefault();
// Получаем вставляемый текст
let text = '';
if (e.clipboardData || e.originalEvent.clipboardData) {
text = (e.clipboardData || e.originalEvent.clipboardData).getData('text/plain');
} else if (window.clipboardData) {
text = window.clipboardData.getData('Text');
}
// Вставляем текст как обычный текст
if (text) {
document.execCommand('insertText', false, text);
}
});
}
function setupTokenDragging($container) {
$container.find('.formula-token').each(function() {
$(this).attr('draggable', 'true');
$(this).off('dragstart').on('dragstart', function(e) {
// Сохраняем ссылку на текущий элемент
window.draggedElement = this;
// Добавляем данные для переноса
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
type: 'token',
value: $(this).data('value'),
html: $(this)[0].outerHTML
}));
// Добавляем класс перетаскивания
$(this).addClass('dragging');
return true;
});
// При завершении перетаскивания
$(this).off('dragend').on('dragend', function(e) {
$(this).removeClass('dragging');
window.draggedElement = null;
});
});
}

View File

@ -0,0 +1,460 @@
function update_or_create_client_script(target_doctype, script_code) {
// Имя для нашего клиентского скрипта формул
const formula_script_name = `Formula_Editor_Script_for_${target_doctype}`;
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Client Script",
filters: { dt: target_doctype },
fields: ["name", "script"]
},
callback: function(r) {
if (r.message && r.message.length > 0) {
// Находим наш скрипт по имени или по содержимому
let formula_script = r.message.find(script =>
script.name === formula_script_name ||
(script.script && script.script.includes('// Generated by Formula Editor'))
);
if (formula_script) {
frappe.call({
method: "frappe.client.set_value",
args: {
doctype: "Client Script",
name: formula_script.name,
fieldname: {
script: script_code,
// Обновляем имя, если оно было другим
name: formula_script_name
}
},
callback: function(response) {
if (response.message) {
} else {
}
}
});
} else {
createNewFormulaScript(target_doctype, script_code, formula_script_name);
}
} else {
createNewFormulaScript(target_doctype, script_code, formula_script_name);
}
}
});
}
function createNewFormulaScript(target_doctype, script_code, script_name) {
// Добавляем комментарий для идентификации скрипта в будущем
const marked_script_code = `// Generated by Formula Editor\n${script_code}`;
frappe.call({
method: "frappe.client.insert",
args: {
doc: {
doctype: "Client Script",
name: script_name,
dt: target_doctype,
script_type: "Client",
script: marked_script_code,
enabled: 1
}
},
callback: function(response) {
if (response.message) {
} else {
}
}
});
}
function delete_client_script_for_doctype(doctype) {
if (!doctype) {
return;
}
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Client Script",
filters: { dt: doctype },
fields: ["name"]
},
callback: function(r) {
if (r.message && r.message.length > 0) {
const script_names = r.message.map(script => script.name);
// Удаляем каждый найденный скрипт
script_names.forEach(script_name => {
frappe.call({
method: "frappe.client.delete",
args: {
doctype: "Client Script",
name: script_name
},
callback: function(response) {
if (response.exc) {
} else {
}
}
});
});
} else {
}
}
});
}
function generateTransformExpressionFunction(universal_fields, report_fields) {
return `
function transform_expression(expression, for_child_table = false, table_name = null) {
let transformed = expression;
let needsAsync = false;
let reportCalls = [];
// === Обработка report(...) ===
const reportRegex = /report\\(([^)]+)\\)/g;
let match;
// Находим все вызовы report() в выражении
while ((match = reportRegex.exec(expression)) !== null) {
const reportName = match[1]; // Имя поля отчета
const fullMatch = match[0]; // Полный текст match - "report(xyz)"
needsAsync = true;
// Создаем уникальный плейсхолдер
const placeholder = \`__REPORT_PLACEHOLDER_\${reportCalls.length}__\`;
// Ищем соответствующее поле отчета по имени
${Object.keys(report_fields).map(fieldId => {
const field = report_fields[fieldId];
if (!field || !field.name) return '';
let filtersJson = "{}";
try {
const filtersObj = JSON.parse(field.filters_json || "{}");
if (filtersObj.company && typeof filtersObj.company === 'string' &&
!filtersObj.company.startsWith('$')) {
filtersObj.company = "$DefaultCompany";
}
Object.keys(filtersObj).forEach(key => {
if (key.toLowerCase().includes('date') ||
key.toLowerCase().includes('from') ||
key.toLowerCase().includes('to')) {
const value = filtersObj[key];
if (typeof value === 'string' &&
(value === "$AyIlSpecial" || value === "$RubIlSpecial" ||
value.startsWith("doc."))) {
// Сохраняем оригинальное значение
}
else if (typeof value === 'string') {
filtersObj[key] = "$AyIlSpecial";
}
}
});
filtersJson = JSON.stringify(filtersObj);
} catch (e) {
filtersJson = "{}";
}
return `
if (reportName === "${field.name}") {
reportCalls.push({
placeholder: placeholder,
reportName: "${field.report}",
filters: JSON.parse(${JSON.stringify(filtersJson)}),
fieldName: "${field.field_name}",
aggregation: "${field.aggregation || 'sum'}"
});
transformed = transformed.replace(fullMatch, placeholder);
}`;
}).join('')}
}
// === Обработка table(...) ===
const tableRegex = /table\\(([^,)]+),\\s*([^,)]+),\\s*([^)]+)\\)/g;
let tableMatch;
while ((tableMatch = tableRegex.exec(expression)) !== null) {
const fullMatch = tableMatch[0];
const tableName = tableMatch[1].trim().replace(/['"]/g, '');
const rowNumber = tableMatch[2].trim();
const fieldName = tableMatch[3].trim().replace(/['"]/g, '');
const replacement = \`(function() {
try {
if (!frm.doc["\${tableName}"] || !frm.doc["\${tableName}"][\${rowNumber} - 1]) {
return 0;
}
const value = frm.doc["\${tableName}"][\${rowNumber} - 1]["\${fieldName}"];
return parseFloat(value) || 0;
} catch (e) {
return 0;
}
})()\`;
transformed = transformed.replace(fullMatch, replacement);
}
// === Обработка universal полей ===
${Object.keys(universal_fields).map(fieldId => {
const field = universal_fields[fieldId];
if (!field || !field.name) return '';
return `transformed = transformed.replace(/univ\\(${field.name}\\)/g, "(frm.doc.__universalFields && frm.doc.__universalFields['${fieldId}'] || 0)");`;
}).filter(Boolean).join('\n')}
// === Обработка val() и row() ===
const tablePrefixes = ["items", "taxes", "packed_items", "payments", "advances", "timesheets", "payment_schedule", "sales_team"];
for (const prefix of tablePrefixes) {
const sumPattern = new RegExp(\`sum\\\\(\${prefix}_([^)]+)\\\\)\`, 'g');
transformed = transformed.replace(sumPattern, (m, f) =>
\`(frm.doc["\${prefix}"] && frm.doc["\${prefix}"].reduce((s, r) => s + (parseFloat(r["\${f}"]) || 0), 0) || 0)\`
);
const valPattern = new RegExp(\`val\\\\(\${prefix}_([^)]+)\\\\)\`, 'g');
transformed = transformed.replace(valPattern, (m, f) =>
\`(frm.doc["\${prefix}"] && frm.doc["\${prefix}"].reduce((s, r) => s + (parseFloat(r["\${f}"]) || 0), 0) || 0)\`
);
}
if (for_child_table) {
transformed = transformed.replace(/row\\(([^)]+)\\)/g, (m, f) =>
\`(parseFloat(row["\${f}"]) || 0)\`
);
transformed = transformed.replace(/val\\(([^)]+)\\)/g, (m, f) => {
if (f.startsWith(table_name + '_')) {
return \`(parseFloat(row["\${f.slice(table_name.length + 1)}"]) || 0)\`;
}
return \`(parseFloat(frm.doc["\${f}"]) || 0)\`;
});
} else {
transformed = transformed.replace(/val\\(([^)]+)\\)/g, (m, f) =>
\`(parseFloat(frm.doc["\${f}"]) || 0)\`
);
transformed = transformed.replace(/row\\(([^)]+)\\)/g, () =>
\`0 /* ERROR: row() вне табличной части */\`
);
}
return { transformed, needsAsync, reportCalls };
}`;
}
function generateTableRowAccessFunction() {
return `
// Функция для получения индекса строки по её имени (name/cdn)
function get_row_index(frm, tableName, rowName) {
// Проверяем наличие таблицы
if (!frm.doc[tableName]) {
return -1;
}
// Выводим все строки для отладки
frm.doc[tableName].forEach((row, idx) => {
});
// Ищем индекс строки по имени
for (let i = 0; i < frm.doc[tableName].length; i++) {
if (frm.doc[tableName][i].name === rowName) {
return i;
}
}
return -1;
}
// Функция для доступа к полям конкретных строк таблицы
function table_row(tableName, rowIndex, fieldName) {
// Проверяем наличие необходимых аргументов
if (!tableName || rowIndex === undefined || !fieldName) {
return 0;
}
// Преобразуем rowIndex в число (на случай, если передана строка)
const rowIdx = parseInt(rowIndex) - 1; // Индексация с 1 для пользователя, но с 0 в массиве
try {
// Проверяем наличие таблицы и строки
if (!frm.doc[tableName] || !frm.doc[tableName][rowIdx]) {
return 0;
}
// Получаем значение поля
const value = frm.doc[tableName][rowIdx][fieldName];
// Проверяем на NaN и возвращаем значение или 0
const numValue = parseFloat(value);
if (isNaN(numValue)) {
return 0;
}
return numValue;
} catch (error) {
return 0;
}
}
// Функция для обновления значения поля конкретной строки таблицы
function set_table_row(frm, tableName, rowIndex, fieldName, value) {
// Проверяем наличие необходимых аргументов
if (!frm || !tableName || rowIndex === undefined || !fieldName) {
return false;
}
// Преобразуем rowIndex в число (на случай, если передана строка)
const rowIdx = parseInt(rowIndex) - 1; // Индексация с 1 для пользователя, но с 0 в массиве
try {
// Проверяем наличие таблицы и строки
if (!frm.doc[tableName] || !frm.doc[tableName][rowIdx]) {
return false;
}
// Получаем строку таблицы
const row = frm.doc[tableName][rowIdx];
// Получаем сетевое имя cdt и cdn для Frappe
const cdt = frm.fields_dict[tableName].grid.doctype;
const cdn = row.name;
// Проверяем, является ли значение NaN, и если да, заменяем его на 0
let finalValue = value;
if (typeof value === 'number' && isNaN(value)) {
finalValue = 0;
}
// Обновляем значение поля
frappe.model.set_value(cdt, cdn, fieldName, finalValue);
return true;
} catch (error) {
return false;
}
}
// Функция для поиска строки таблицы по значению поля
function find_table_row(frm, tableName, fieldName, value) {
// Проверяем наличие необходимых аргументов
if (!frm || !tableName || !fieldName) {
return -1;
}
try {
// Проверяем наличие таблицы
if (!frm.doc[tableName]) {
return -1;
}
// Ищем строку с указанным значением поля
for (let i = 0; i < frm.doc[tableName].length; i++) {
if (frm.doc[tableName][i][fieldName] == value) {
// Возвращаем индекс строки (начиная с 1 для пользователя)
return i + 1;
}
}
// Если строка не найдена
return -1;
} catch (error) {
return -1;
}
}`;
}
function generate_child_table_script(table_child_scripts, table_doctypes, table_field_triggers, formula_data, functions) {
let child_scripts = '';
Object.keys(table_child_scripts).forEach(tableName => {
let childDoctype = table_child_scripts[tableName].doctype;
let usesUniversalFields = table_child_scripts[tableName].uses_universal;
let table_script = `\n\nfrappe.ui.form.on("${childDoctype}", {\n`;
// Создаем общую функцию для расчета формул в строке таблицы
let calcFunction = `
async function calculate_${tableName}_row(frm, cdt, cdn) {
let row = locals[cdt][cdn];
try {
${usesUniversalFields ? 'await calculate_universal_fields(frm);' : ''}
const { transformed, needsAsync, reportCalls } = transform_expression(${JSON.stringify(calc.formula)}, true, "${tableName}");
let finalExpression = transformed;
if (reportCalls && reportCalls.length > 0) {
const reportResults = [];
for (const call of reportCalls) {
const result = await getReportFieldValue(
frm,
call.reportName,
call.filters,
call.fieldName,
call.aggregation
);
reportResults.push({ placeholder: call.placeholder, value: result });
}
for (const result of reportResults) {
finalExpression = finalExpression.replace(result.placeholder, result.value);
}
}
let result = 0;
try {
result = eval(finalExpression);
} catch (e) {
}
// Обновляем значение
row["${calc.field}"] = isNaN(result) ? 0 : result;
frm.refresh_field("${tableName}");
} catch (error) {
}
}`;
table_child_scripts[tableName].calculations.forEach(calc => {
calcFunction += `
// Расчет для поля ${calc.field}
try {
const transformed = transform_expression(${JSON.stringify(calc.formula)}, true, "${tableName}").transformed;
row["${calc.field}"] = eval(transformed);
} catch (e) {
row["${calc.field}"] = 0;
}
`;
});
calcFunction += `
frm.refresh_field("${tableName}");
} catch (e) {
}
}`;
functions.push(calcFunction);
// Добавляем обработчики для каждого триггерного поля
Array.from(table_child_scripts[tableName].fields).forEach(field => {
if (usesUniversalFields) {
table_script += ` "${field}": async function(frm, cdt, cdn) { await calculate_${tableName}_row(frm, cdt, cdn); },\n`;
} else {
table_script += ` "${field}": function(frm, cdt, cdn) { calculate_${tableName}_row(frm, cdt, cdn); },\n`;
}
});
table_script += `});`;
child_scripts += table_script;
});
return child_scripts;
}

View File

@ -0,0 +1,689 @@
function createModals() {
createFormulaBuilderModal();
createTriggerFieldsModal();
createUniversalFieldsModal();
createReportFieldModal();
createTableRowFormulaModal();
createTableRowFieldSelectorModal();
createTableFieldSelectorModal();
}
function createFormulaBuilderModal() {
if ($("#formula_builder_modal").length) {
return;
}
const modalHtml = `
<div id="formula_builder_modal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Визуальный редактор формул</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="formula-workspace">
<div class="formula-result-container">
<div class="formula-result-title">Формула:</div>
<div class="formula-result" contenteditable="true"></div>
</div>
<div class="operators-panel">
<div class="operators-title">Операторы:</div>
<div class="operators-container">
<button class="btn btn-sm operator-btn" data-operator="+">+</button>
<button class="btn btn-sm operator-btn" data-operator="-">-</button>
<button class="btn btn-sm operator-btn" data-operator="*">*</button>
<button class="btn btn-sm operator-btn" data-operator="/">/</button>
<button class="btn btn-sm operator-btn" data-operator="%">%</button>
<button class="btn btn-sm operator-btn" data-operator="(">(</button>
<button class="btn btn-sm operator-btn" data-operator=")">)</button>
</div>
</div>
<div class="fields-panel">
<div class="fields-title">Поля:</div>
<div class="field-groups-container"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary apply-formula">Применить</button>
</div>
</div>
</div>
</div>
`;
// Стили с поддержкой перетаскивания
const modalStyles = `
<style>
.formula-workspace {
display: flex;
flex-direction: column;
gap: 15px;
}
.formula-result-container, .trigger-result-container {
border: 1px solid #ddd;
padding: 10px;
border-radius: 4px;
background-color: #f9f9f9;
min-height: 60px;
}
.formula-result-title, .trigger-result-title, .fields-title, .operators-title {
font-weight: bold;
margin-bottom: 5px;
}
.formula-result, .trigger-result {
min-height: 30px;
padding: 5px;
background-color: white;
border: 1px solid #e0e0e0;
border-radius: 3px;
font-family: monospace;
outline: none;
}
.formula-result.drag-hover, .trigger-result.drag-hover {
background-color: #f0f8ff;
border: 1px dashed #4285f4;
}
.operators-panel, .fields-panel {
border: 1px solid #ddd;
padding: 10px;
border-radius: 4px;
background-color: #f9f9f9;
}
.universal-field {
background-color: #e3efff !important;
border: 1px solid #abc9f5 !important;
}
.operators-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.field-groups-container, .trigger-field-groups-container {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 300px;
overflow-y: auto;
}
.field-group, .trigger-field-group {
margin-bottom: 10px;
}
.field-group-title, .trigger-field-group-title {
font-weight: bold;
margin-bottom: 5px;
}
.field-buttons-container, .trigger-field-buttons-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.field-btn, .operator-btn, .trigger-field-btn {
margin: 3px;
background-color: #f0f4f8;
border: 1px solid #d0d8e0;
cursor: pointer;
}
.field-btn:hover, .operator-btn:hover, .trigger-field-btn:hover {
background-color: #e0e8f0;
}
.formula-token, .trigger-token {
display: inline-block;
padding: 2px 5px;
margin: 2px;
border-radius: 3px;
cursor: move;
position: relative;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
.formula-token.field, .trigger-token.field {
background-color: #e3efff;
border: 1px solid #abc9f5;
}
.formula-token.operator {
background-color: #ffe8e3;
border: 1px solid #f5c3b9;
font-weight: bold;
}
.formula-token:hover, .trigger-token:hover {
box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
}
.formula-token.dragging, .trigger-token.dragging {
opacity: 0.7;
box-shadow: 0 0 5px rgba(0, 100, 255, 0.7);
}
.formula-token .token-remove, .trigger-token .token-remove {
display: none;
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: #ff5555;
color: white;
border-radius: 50%;
text-align: center;
line-height: 16px;
font-size: 10px;
cursor: pointer;
}
.formula-token:hover .token-remove, .trigger-token:hover .token-remove {
display: block;
}
.drop-marker, .trigger-drop-marker {
display: inline-block;
width: 2px;
height: 18px;
background-color: #4285f4;
vertical-align: middle;
animation: blink 1s infinite;
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.draggable-item {
cursor: move;
}
/* Стили для универсальных полей */
.universal-field-form {
padding: 10px;
}
.filter-row {
margin-bottom: 10px;
}
.universal-field-token {
display: inline-block;
padding: 2px 5px;
margin: 2px;
border-radius: 3px;
cursor: move;
background-color: #eff8e6;
border: 1px solid #b7d69d;
position: relative;
user-select: none;
}
.universal-field-token .token-remove {
display: none;
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: #ff5555;
color: white;
border-radius: 50%;
text-align: center;
line-height: 16px;
font-size: 10px;
cursor: pointer;
}
.universal-field-token:hover .token-remove {
display: block;
}
.universal-field-btn {
margin: 3px;
background-color: #eff8e6;
border: 1px solid #b7d69d;
cursor: pointer;
}
.universal-field-btn:hover {
background-color: #deedcf;
}
</style>
`;
$('body').append(modalHtml);
$('head').append(modalStyles);
}
function createTriggerFieldsModal() {
if ($("#trigger_fields_modal").length) {
return;
}
const modalHtml = `
<div id="trigger_fields_modal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Редактор полей изменения</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="trigger-workspace">
<div class="trigger-result-container">
<div class="trigger-result-title">Поля изменения:</div>
<div class="trigger-result" contenteditable="true"></div>
</div>
<div class="fields-panel">
<div class="fields-title">Доступные поля:</div>
<div class="trigger-field-groups-container"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary apply-triggers">Применить</button>
</div>
</div>
</div>
</div>
`;
$('body').append(modalHtml);
}
function createUniversalFieldsModal() {
if ($("#universal_fields_modal").length) {
return;
}
const modalHtml = `
<div id="universal_fields_modal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Создание универсального поля</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="universal-field-form"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary save-universal-field" disabled>Сохранить</button>
</div>
</div>
</div>
</div>`;
$('body').append(modalHtml);
}
function createReportFieldModal() {
if ($("#report_fields_modal").length) {
return;
}
const modalHtml = `
<div id="report_fields_modal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Создание поля отчета</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="report-field-form"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary save-report-field" disabled>Сохранить</button>
</div>
</div>
</div>
</div>`;
$('body').append(modalHtml);
}
function createTableRowFormulaModal() {
if ($("#table_row_formula_modal").length) {
return;
}
const modalHtml = `
<div id="table_row_formula_modal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Формула для строки табличной части</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-3">
<label for="table_row_table">Табличная часть:</label>
<select id="table_row_table" class="form-control"></select>
</div>
<div class="col-md-3">
<label for="table_row_field">Поле:</label>
<select id="table_row_field" class="form-control" disabled></select>
</div>
<div class="col-md-3">
<label for="table_row_index">Номер строки:</label>
<input type="number" id="table_row_index" class="form-control" min="1" value="1">
</div>
<div class="col-md-3">
<label for="table_row_order">Порядок выполнения:</label>
<input type="number" id="table_row_order" class="form-control" min="0" value="10">
</div>
</div>
<div class="formula-workspace">
<div class="formula-result-container">
<div class="formula-result-title">Формула:</div>
<div class="formula-result table-row-formula" contenteditable="true"></div>
</div>
<div class="operators-panel">
<div class="operators-title">Операторы:</div>
<div class="operators-container">
<button class="btn btn-sm operator-btn" data-operator="+">+</button>
<button class="btn btn-sm operator-btn" data-operator="-">-</button>
<button class="btn btn-sm operator-btn" data-operator="*">*</button>
<button class="btn btn-sm operator-btn" data-operator="/">/</button>
<button class="btn btn-sm operator-btn" data-operator="%">%</button>
<button class="btn btn-sm operator-btn" data-operator="(">(</button>
<button class="btn btn-sm operator-btn" data-operator=")">)</button>
</div>
</div>
<div class="fields-panel">
<div class="fields-title">Поля:</div>
<div class="field-groups-container"></div>
</div>
</div>
<div class="triggers-container mt-4">
<div class="trigger-result-title">Обновлять при изменении:</div>
<div class="trigger-result table-row-trigger" contenteditable="true"></div>
<div class="trigger-actions mt-2">
<button class="btn btn-sm btn-info add-predefined-event">
<i class="fa fa-calendar"></i> При открытии документа
</button>
<button class="btn btn-primary btn-sm open-trigger-editor-in-row">
<i class="fa fa-list"></i> Выбрать поле
</button>
<button class="btn btn-warning btn-sm select-table-row-trigger-field">
<i class="fa fa-plus"></i> Выбрать поле строки т.ч.
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary save-table-row-formula">Сохранить</button>
</div>
</div>
</div>
</div>
`;
$('body').append(modalHtml);
// Обработчики для селекторов
$("#table_row_table").on('change', function() {
const selectedTable = $(this).val();
if (selectedTable) {
loadTableFieldsForSelector(selectedTable);
$("#table_row_field").prop('disabled', false);
} else {
$("#table_row_field").prop('disabled', true).empty().append('<option value="">Выберите поле</option>');
}
});
// Добавляем обработчик для кнопки "При открытии документа"
$("#table_row_formula_modal").on('click', '.add-predefined-event', function() {
const $triggerContainer = $("#table_row_formula_modal").find(".table-row-trigger");
// Используем специальную функцию для добавления предопределенного события
addTokenToTriggers("При открытии документа", $triggerContainer, 'event');
});
// Добавляем обработчик для кнопки "Выбрать поле"
$("#table_row_formula_modal").on('click', '.open-trigger-editor-in-row', function() {
const $triggerContainer = $("#table_row_formula_modal").find(".table-row-trigger");
const frm = cur_frm;
// Открываем стандартный редактор триггеров, используя контейнер из модального окна строки таблицы
openTriggerFieldsEditorForTableRow(frm, $triggerContainer);
});
}
function createTableRowFieldSelectorModal() {
if ($("#table_row_field_selector_modal").length) {
return;
}
const modalHtml = `
<div id="table_row_field_selector_modal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Выбор поля строки табличной части</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="row_field_table">Табличная часть:</label>
<select id="row_field_table" class="form-control"></select>
</div>
<div class="form-group">
<label for="row_field_name">Поле:</label>
<select id="row_field_name" class="form-control" disabled></select>
</div>
<div class="form-group">
<label for="row_field_index">Номер строки:</label>
<input type="number" id="row_field_index" class="form-control" min="1" value="1">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary add-table-row-field">Добавить поле</button>
</div>
</div>
</div>
</div>
`;
$('body').append(modalHtml);
// Обработчики для селекторов
$("#row_field_table").on('change', function() {
const selectedTable = $(this).val();
if (selectedTable) {
loadTableFieldsForSelector(selectedTable);
$("#row_field_name").prop('disabled', false);
} else {
$("#row_field_name").prop('disabled', true).empty().append('<option value="">Выберите поле</option>');
}
});
}
function createTableFieldSelectorModal() {
if ($("#table_field_selector_modal").length) {
return;
}
const modalHtml = `
<div id="table_field_selector_modal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Выбор поля из строки табличной части</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="table_field_table">Табличная часть:</label>
<select id="table_field_table" class="form-control"></select>
</div>
<div class="form-group">
<label for="table_field_name">Поле:</label>
<select id="table_field_name" class="form-control" disabled></select>
</div>
<div class="form-group">
<label for="table_field_row">Номер строки:</label>
<input type="number" id="table_field_row" class="form-control" min="1" value="1">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary add-table-field">Добавить поле</button>
</div>
</div>
</div>
</div>
`;
$('body').append(modalHtml);
// Обработчики для селекторов
$("#table_field_table").on('change', function() {
const selectedTable = $(this).val();
if (selectedTable) {
loadTableFieldsForTableSelector(selectedTable);
$("#table_field_name").prop('disabled', false);
} else {
$("#table_field_name").prop('disabled', true).empty().append('<option value="">Выберите поле</option>');
}
});
}
function loadTableFieldsForTableSelector(tableName) {
const $fieldSelect = $("#table_field_name");
$fieldSelect.empty().append('<option value="">Выберите поле</option>');
const tableDoctypes = $("#table_field_selector_modal").data("table-doctypes") || {};
const doctype = tableDoctypes[tableName];
if (!doctype) return;
// Получаем поля DocType табличной части
frappe.model.with_doctype(doctype, function() {
const meta = frappe.get_meta(doctype);
if (!meta) return;
// Добавляем все поля, которые могут быть использованы в формулах
const validTypes = ['Int', 'Float', 'Currency', 'Data', 'Text', 'Date', 'Datetime', 'Check', 'Link', 'Select', 'Percent'];
meta.fields.forEach(field => {
if (field.fieldname && validTypes.includes(field.fieldtype)) {
$fieldSelect.append(`<option value="${field.fieldname}" data-fieldtype="${field.fieldtype}">${field.label || field.fieldname}</option>`);
}
});
});
}
function loadTablesListForTableSelector(frm) {
const $tableSelect = $("#table_field_table");
$tableSelect.empty().append('<option value="">Выберите табличную часть</option>');
// Получаем все табличные части DocType
frappe.model.with_doctype(frm.doc.target_doctype, function() {
const meta = frappe.get_meta(frm.doc.target_doctype);
if (!meta) return;
const tableDoctypes = {};
meta.fields.forEach(field => {
if (field.fieldtype === 'Table' && field.options) {
$tableSelect.append(`<option value="${field.fieldname}" data-doctype="${field.options}">${field.label || field.fieldname}</option>`);
tableDoctypes[field.fieldname] = field.options;
}
});
// Сохраняем информацию о DocType для табличных частей
$("#table_field_selector_modal").data("table-doctypes", tableDoctypes);
});
}
function openTableFieldSelector(frm, targetContainer, isForTrigger = false) {
const $modal = $("#table_field_selector_modal");
// Сбрасываем форму
$("#table_field_table").val('');
$("#table_field_name").empty().append('<option value="">Выберите поле</option>').prop('disabled', true);
$("#table_field_row").val(1);
// Удаляем старые обработчики
$modal.find('.add-table-field').off('click');
// Загружаем список табличных частей
loadTablesListForTableSelector(frm);
// Сохраняем информацию о целевом контейнере и типе (формула или триггер)
$modal.data("target-container", targetContainer);
$modal.data("is-trigger", isForTrigger);
$modal.data("frm", frm);
// Обработчик для кнопки добавления
$modal.find('.add-table-field').on('click', function() {
const tableName = $("#table_field_table").val();
const fieldName = $("#table_field_name").val();
const rowNumber = $("#table_field_row").val();
if (!tableName || !fieldName || !rowNumber) {
frappe.msgprint({
title: __('Внимание'),
message: __('Выберите табличную часть, поле и укажите номер строки'),
indicator: 'orange'
});
return;
}
const $container = $($modal.data("target-container"));
const isForTrigger = $modal.data("is-trigger");
if (isForTrigger) {
// Для триггеров добавляем идентификатор поля
const fieldId = `${tableName}_${fieldName}`;
addTokenToTriggers(fieldId, $container, 'table-field');
} else {
// Для формул добавляем вызов функции table()
const formulaText = `table(${tableName}, ${rowNumber}, ${fieldName})`;
const $token = $(`<span class="formula-token field table-field" data-value="${formulaText}" contenteditable="false" draggable="true">${formulaText}<span class="token-remove">×</span></span>`);
$container.append($token).append(' ');
// Добавляем обработчики для токенов
addTokenHandlers($container);
// Переносим курсор в конец контейнера
setEndOfContentEditable($container[0]);
}
$modal.modal('hide');
});
// Показываем модальное окно
$modal.modal('show');
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,418 @@
function parseAndRenderTriggers($container, triggers) {
if (!triggers) return;
// Разбиваем строку с триггерами по запятым
const triggerFields = triggers.split(',').map(field => field.trim()).filter(field => field);
// Создаем токены для каждого поля
triggerFields.forEach(field => {
// Определяем тип триггера
let fieldType = 'field';
if (isPredefinedEvent(field)) {
fieldType = 'event';
} else if (field.startsWith('univ(')) {
fieldType = 'universal';
} else if (field.includes('_') && !field.includes('_row_')) {
// Проверяем, является ли это полем табличной части
const parts = field.split('_');
if (parts.length >= 2) {
fieldType = 'table';
}
} else if (field.includes('_row_')) {
fieldType = 'table-row';
}
addTokenToTriggers(field, $container, fieldType);
});
}
function getTriggerFieldsText($container) {
// Собираем значения всех токенов
const triggerFields = [];
$container.find('.trigger-token').each(function() {
triggerFields.push($(this).data('value'));
});
// Объединяем их через запятую
return triggerFields.join(', ');
}
function addTokenToTriggers(fieldname, $resultContainer, fieldType = 'field') {
// Проверяем, не добавлено ли уже это поле
const existingTokens = $resultContainer.find(`.trigger-token[data-value="${fieldname}"]`);
if (existingTokens.length > 0) {
return; // Если поле уже добавлено, не добавляем его снова
}
// Определяем класс токена в зависимости от типа поля
let tokenClass = "trigger-token field";
switch (fieldType) {
case 'event':
tokenClass = "trigger-token field highlight-event";
break;
case 'universal':
tokenClass = "trigger-token field universal-field";
break;
case 'table':
tokenClass = "trigger-token field table-field";
break;
case 'table-row':
tokenClass = "trigger-token field table-row-field";
break;
default:
tokenClass = "trigger-token field";
}
const $token = $(`<span class="${tokenClass}" data-value="${fieldname}" contenteditable="false">${fieldname}<span class="token-remove">×</span></span>`);
$resultContainer.append($token).append(' ');
// Переносим курсор в конец контейнера
setEndOfContentEditable($resultContainer[0]);
// Добавляем обработчики для токенов
attachTriggerTokenEvents($resultContainer);
}
function attachTriggerTokenEvents($container) {
// Удаление токена
$container.find('.token-remove').off('click').on('click', function(e) {
e.stopPropagation();
$(this).parent().remove();
});
// Перетаскивание токенов
$container.find('.trigger-token').each(function() {
$(this).attr('draggable', 'true');
$(this).off('dragstart').on('dragstart', function(e) {
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
type: 'trigger-token',
value: $(this).data('value'),
html: $(this)[0].outerHTML,
tokenClass: $(this).attr('class')
}));
window.draggedTriggerElement = this;
});
$(this).off('dragend').on('dragend', function() {
window.draggedTriggerElement = null;
});
});
}
function createTriggerToken(fieldname, fieldType) {
// Определяем класс токена в зависимости от типа поля
let tokenClass = "trigger-token field";
switch (fieldType) {
case 'event':
tokenClass = "trigger-token field highlight-event";
break;
case 'universal':
tokenClass = "trigger-token field universal-field";
break;
case 'table':
tokenClass = "trigger-token field table-field";
break;
case 'table-row':
tokenClass = "trigger-token field table-row-field";
break;
default:
tokenClass = "trigger-token field";
}
return $(`<span class="${tokenClass}" data-value="${fieldname}" draggable="true" contenteditable="false">${fieldname}<span class="token-remove">×</span></span>`);
}
function initTriggerTokenEvents($token) {
// Удаление токена
$token.find('.token-remove').on('click', function(e) {
e.stopPropagation();
$(this).parent().remove();
});
// Перетаскивание токена
$token.attr('draggable', 'true');
$token.off('dragstart').on('dragstart', function(e) {
window.draggedTriggerElement = this;
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
type: 'trigger-token',
value: $(this).data('value'),
html: $(this)[0].outerHTML,
tokenClass: $(this).attr('class')
}));
$(this).addClass('dragging');
});
$token.off('dragend').on('dragend', function() {
$(this).removeClass('dragging');
window.draggedTriggerElement = null;
});
}
function isPredefinedEvent(trigger) {
const predefinedEvents = [
"При открытии документа",
"when_document_opens",
"document_opens"
];
return predefinedEvents.includes(trigger);
}
function openTriggerFieldsEditor(frm, targetFieldname, currentTriggers, fields, grouped_fields) {
const $modal = $("#trigger_fields_modal");
const $resultContainer = $modal.find(".trigger-result");
// Очистка предыдущих обработчиков и данных
$modal.off('click', '.trigger-field-btn');
$modal.off('click', '.select-table-row-trigger-field');
$modal.find('.apply-triggers').off('click');
$resultContainer.empty();
$modal.find(".trigger-field-groups-container").empty();
// Заполняем текущие триггеры, если они есть
if (currentTriggers) {
parseAndRenderTriggers($resultContainer, currentTriggers);
}
// Заполняем панель с полями
populateTriggerFieldsPanel($modal, frm, grouped_fields);
// Сохраняем данные о текущем поле и сбрасываем флаг table-row
$modal.data("target-field", targetFieldname);
$modal.data("is-for-table-row", false);
// Обработчик для кнопки применения триггеров
$modal.find('.apply-triggers').off('click').on('click', function() {
const triggers = getTriggerFieldsText($resultContainer);
// Проверяем, для контейнера строки таблицы или обычного поля
if ($modal.data("is-for-table-row")) {
const $tableRowContainer = $modal.data("table-row-container");
// Очищаем контейнер строки таблицы
$tableRowContainer.empty();
// Переносим триггеры из модального окна в контейнер строки таблицы
if (triggers) {
parseAndRenderTriggers($tableRowContainer, triggers);
}
} else {
// Стандартное поведение для обычного поля
const $targetInput = $(frm.wrapper).find(`.trigger-input[data-fieldname="${targetFieldname}_triggers"]`);
$targetInput.val(triggers);
}
$modal.modal('hide');
});
// Обработчики для кнопок полей
$modal.off('click', '.trigger-field-btn').on('click', '.trigger-field-btn', function() {
const fieldname = $(this).data('fieldname');
// Определяем тип поля по классу кнопки
let fieldType = 'field';
if ($(this).hasClass('highlight-event')) {
fieldType = 'event';
} else if ($(this).hasClass('universal-field-btn')) {
fieldType = 'universal';
} else if ($(this).hasClass('table-field-btn')) {
fieldType = 'table';
}
addTokenToTriggers(fieldname, $resultContainer, fieldType);
});
// Обработчик для кнопки выбора полей строк табличных частей
$modal.off('click', '.select-table-row-trigger-field').on('click', '.select-table-row-trigger-field', function() {
openTableRowFieldSelector(frm, $resultContainer, true);
});
// Показываем модальное окно
$modal.modal('show');
// Инициализируем перетаскивание
initTriggerDragAndDrop($modal, $resultContainer);
// Настраиваем редактируемый контейнер
setupTriggerEditableContainer($resultContainer);
// Фокусируемся на редактируемом поле
$resultContainer.focus();
}
function setupTriggerEditableContainer($container) {
// Обработчик нажатия клавиш в контейнере триггеров
$container.off('keydown').on('keydown', function(e) {
// Если нажата клавиша Delete или Backspace
if (e.keyCode === 8 || e.keyCode === 46) {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.commonAncestorContainer.nodeType === Node.TEXT_NODE) {
// Текстовый узел - обычное удаление
return true;
} else {
// Возможно, выделен токен
const $selectedNode = $(range.commonAncestorContainer);
if ($selectedNode.hasClass('trigger-token') || $selectedNode.parents('.trigger-token').length) {
// Удаляем токен
const $token = $selectedNode.hasClass('trigger-token') ?
$selectedNode : $selectedNode.parents('.trigger-token');
$token.remove();
e.preventDefault();
return false;
}
}
}
}
return true;
});
// Обработчик вставки текста
$container.off('paste').on('paste', function(e) {
e.preventDefault();
// Получаем вставляемый текст
let text = '';
if (e.clipboardData || e.originalEvent.clipboardData) {
text = (e.clipboardData || e.originalEvent.clipboardData).getData('text/plain');
} else if (window.clipboardData) {
text = window.clipboardData.getData('Text');
}
// Разбиваем вставляемый текст по запятым и добавляем как токены
if (text) {
const fields = text.split(',').map(field => field.trim()).filter(field => field);
fields.forEach(field => {
// Определяем тип триггера
let fieldType = 'field';
if (isPredefinedEvent(field)) {
fieldType = 'event';
} else if (field.startsWith('univ(')) {
fieldType = 'universal';
} else if (field.includes('_') && !field.includes('_row_')) {
// Проверяем, является ли это полем табличной части
const parts = field.split('_');
if (parts.length >= 2) {
fieldType = 'table';
}
} else if (field.includes('_row_')) {
fieldType = 'table-row';
}
// Проверяем, не добавлено ли уже это поле
const existingTokens = $container.find(`.trigger-token[data-value="${field}"]`);
if (existingTokens.length === 0) {
addTokenToTriggers(field, $container, fieldType);
}
});
}
});
}
function openTriggerFieldsEditorForTableRow(frm, $triggerContainer) {
const $modal = $("#trigger_fields_modal");
// Очистка предыдущих обработчиков и данных
$modal.off('click', '.trigger-field-btn');
$modal.off('click', '.select-table-row-trigger-field');
$modal.find('.apply-triggers').off('click');
$modal.find(".trigger-result").empty();
$modal.find(".trigger-field-groups-container").empty();
// Копируем текущие триггеры из контейнера строки таблицы
const currentTriggers = getTriggerFieldsText($triggerContainer);
// Заполняем текущие триггеры в модальное окно триггеров
if (currentTriggers) {
parseAndRenderTriggers($modal.find(".trigger-result"), currentTriggers);
}
// Заполняем панель с полями
populateTriggerFieldsPanel($modal, frm, frm.grouped_trigger_fields || {});
// Помечаем, что это для контейнера строки таблицы
$modal.data("is-for-table-row", true);
$modal.data("table-row-container", $triggerContainer);
// Обработчик для кнопки применения триггеров
$modal.find('.apply-triggers').off('click').on('click', function() {
const triggers = getTriggerFieldsText($modal.find(".trigger-result"));
// Проверяем, для контейнера строки таблицы или обычного поля
if ($modal.data("is-for-table-row")) {
// Очищаем контейнер строки таблицы
$triggerContainer.empty();
// Переносим триггеры из модального окна в контейнер строки таблицы
if (triggers) {
parseAndRenderTriggers($triggerContainer, triggers);
}
} else {
// Стандартное поведение для обычного поля
const targetFieldname = $modal.data("target-field");
const $targetInput = $(frm.wrapper).find(`.trigger-input[data-fieldname="${targetFieldname}_triggers"]`);
$targetInput.val(triggers);
}
$modal.modal('hide');
});
// Обработчики для кнопок полей
$modal.off('click', '.trigger-field-btn').on('click', '.trigger-field-btn', function() {
const fieldname = $(this).data('fieldname');
// Определяем тип поля по классу кнопки
let fieldType = 'field';
if ($(this).hasClass('highlight-event')) {
fieldType = 'event';
} else if ($(this).hasClass('universal-field-btn')) {
fieldType = 'universal';
} else if ($(this).hasClass('table-field-btn')) {
fieldType = 'table';
}
addTokenToTriggers(fieldname, $modal.find(".trigger-result"), fieldType);
});
// Обработчик для кнопки выбора поля строки табличной части
$modal.off('click', '.select-table-row-trigger-field').on('click', '.select-table-row-trigger-field', function() {
openTableRowFieldSelector(frm, $modal.find(".trigger-result"), true);
});
// Увеличиваем z-index модального окна, чтобы оно было поверх других
$modal.css('z-index', 1100);
// Настраиваем фон
$('.modal-backdrop').not(':first').css({
'z-index': 1050,
'opacity': 0.7
});
// Показываем модальное окно
$modal.modal('show');
// Инициализируем перетаскивание
initTriggerDragAndDrop($modal, $modal.find(".trigger-result"));
// Настраиваем редактируемый контейнер
setupTriggerEditableContainer($modal.find(".trigger-result"));
// Фокусируемся на редактируемом поле
$modal.find(".trigger-result").focus();
}

View File

@ -0,0 +1,612 @@
function addTokenStyles() {
if ($("#token-styles").length) return;
const tokenStyles = `
<style id="token-styles">
/* Стили для токенов */
.formula-token, .trigger-token {
display: inline-block;
padding: 2px 5px;
margin: 2px;
border-radius: 3px;
cursor: move;
position: relative;
user-select: none;
}
.formula-token .token-remove,
.trigger-token .token-remove {
display: none;
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: #ff5555;
color: white;
border-radius: 50%;
text-align: center;
line-height: 16px;
font-size: 10px;
cursor: pointer;
z-index: 100;
}
/* Стили для полей из строк табличных частей */
.table-field-btn,
.trigger-token.table-field,
.formula-token.table-field {
background-color: #e8f4fd !important;
border-color: #bee5eb !important;
}
.table-field-btn:hover {
background-color: #d1ecf1 !important;
}
.btn.select-table-field {
background-color: #e8f4fd;
border-color: #bee5eb;
}
.btn.select-table-field:hover {
background-color: #d1ecf1;
border-color: #a6d9e8;
}
.formula-token:hover .token-remove,
.trigger-token:hover .token-remove {
display: block;
}
/* Стили для обычных полей */
.formula-token.field,
.trigger-token.field {
background-color: #e3efff;
border: 1px solid #abc9f5;
}
/* Стили для операторов */
.formula-token.operator {
background-color: #ffe8e3;
border: 1px solid #f5c3b9;
font-weight: bold;
}
/* Стили для универсальных полей */
.universal-field-btn,
.trigger-token.universal-field,
.formula-token.universal-field {
background-color: #eff8e6 !important;
border-color: #b7d69d !important;
}
/* Стили для полей отчетов */
.report-field-btn,
.trigger-token.report-field,
.formula-token.report-field {
background-color: #f5e6ff !important;
border-color: #d9b3ff !important;
position: relative;
}
.report-field-btn:hover {
background-color: #ebccff !important;
}
/* Стили для полей строк */
.row-field-btn {
background-color: #fff3cd !important;
border-color: #ffeeba !important;
}
/* Стили для контейнеров */
.formula-result,
.trigger-result {
min-height: 30px;
padding: 5px;
background-color: white;
border: 1px solid #e0e0e0;
border-radius: 3px;
font-family: monospace;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* Стили для фильтров отчетов */
.filters-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.filter-row {
margin-bottom: 10px;
}
.filter-label {
font-weight: 500;
margin-bottom: 3px;
}
</style>`;
$('head').append(tokenStyles);
}
function createFormulaFieldHtml(field, dataAttr) {
return $('<div class="formula-row" style="display:flex;justify-content:space-between;gap:20px;align-items:center;width:100%;margin-bottom:10px;">' +
'<div class="frappe-control input-max-width" data-fieldtype="Data" data-fieldname="' + field.fieldname + '" ' + dataAttr + ' style="flex:1;">' +
'<div class="form-group horizontal">' +
'<div class="clearfix"><label class="control-label">' + field.label + ' (' + field.fieldname + ')</label></div>' +
'<div class="control-input-wrapper" style="display:flex;align-items:center;">' +
'<div class="control-input" style="flex:1;">' +
'<input type="text" class="input-with-feedback form-control formula-input" data-fieldtype="Data" data-fieldname="' + field.fieldname + '" ' + dataAttr + ' autocomplete="off" placeholder="Введите формулу">' +
'</div>' +
'<button class="btn btn-sm btn-primary open-formula-builder" data-fieldname="' + field.fieldname + '" data-is-table="' + (dataAttr ? 'true' : 'false') + '" style="margin-left:8px;"><i class="fa fa-plus"></i></button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="frappe-control input-max-width" data-fieldtype="Data" data-fieldname="' + field.fieldname + '_triggers" ' + dataAttr + ' style="flex:1;">' +
'<div class="form-group horizontal">' +
'<div class="clearfix"><label class="control-label">Обновлять при изменении</label></div>' +
'<div class="control-input-wrapper" style="display:flex;align-items:center;">' +
'<div class="control-input" style="flex:1;">' +
'<input type="text" class="input-with-feedback form-control trigger-input" data-fieldtype="Data" data-fieldname="' + field.fieldname + '_triggers" ' + dataAttr + ' autocomplete="off" placeholder="Введите поля через запятую">' +
'</div>' +
'<button class="btn btn-sm btn-primary open-trigger-editor" data-fieldname="' + field.fieldname + '" style="margin-left:8px;"><i class="fa fa-plus"></i></button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>');
}
function attachFormulaBuilderEvents(frm, fields, grouped_fields) {
// Сделаем поля ввода формул редактируемыми
$(frm.wrapper).find('.formula-input').prop('readonly', false);
// Сначала удаляем существующие обработчики событий
$(frm.wrapper).off('click', '.open-formula-builder');
// Обработчик для кнопок открытия визуального редактора
$(frm.wrapper).on('click', '.open-formula-builder', function() {
const fieldname = $(this).data('fieldname');
const isTableField = $(this).data('is-table') === true;
const currentFormula = $(frm.wrapper).find(`.formula-input[data-fieldname="${fieldname}"]`).val();
openFormulaBuilder(frm, fieldname, isTableField, currentFormula, fields, grouped_fields);
return false;
});
}
function attachTriggerEditorEvents(frm, fields, grouped_fields) {
// Сначала удаляем существующие обработчики событий
$(frm.wrapper).off('click', '.open-trigger-editor');
// Обработчик для кнопок открытия редактора полей изменения
$(frm.wrapper).on('click', '.open-trigger-editor', function() {
const fieldname = $(this).data('fieldname');
const currentTriggers = $(frm.wrapper).find(`.trigger-input[data-fieldname="${fieldname}_triggers"]`).val();
openTriggerFieldsEditor(frm, fieldname, currentTriggers, fields, grouped_fields);
return false;
});
}
function addUniversalFieldsSection(wrapper) {
const universalSection = $('<div class="universal-fields-section" style="width:100%;margin-top:20px;padding:10px;border:1px solid #ddd;border-radius:5px;"></div>');
universalSection.append('<h4 style="margin-bottom:10px;display:flex;justify-content:space-between;align-items:center;">Универсальные поля <button class="btn btn-sm btn-primary add-universal-field"><i class="fa fa-plus"></i> Добавить поле</button></h4>');
universalSection.append('<div class="universal-fields-list" style="display:flex;flex-wrap:wrap;gap:10px;"></div>');
wrapper.append(universalSection);
}
function addReportFieldsSection(wrapper) {
const reportSection = $('<div class="report-fields-section" style="width:100%;margin-top:20px;padding:10px;border:1px solid #ddd;border-radius:5px;"></div>');
reportSection.append('<h4 style="margin-bottom:10px;display:flex;justify-content:space-between;align-items:center;">Поля отчетов <button class="btn btn-sm btn-primary add-report-field"><i class="fa fa-plus"></i> Добавить поле отчета</button></h4>');
reportSection.append('<div class="report-fields-list" style="display:flex;flex-wrap:wrap;gap:10px;"></div>');
wrapper.append(reportSection);
}
function addTableRowFormulasSection(wrapper) {
const tableRowSection = $('<div class="table-row-formulas-section" style="width:100%;margin-top:20px;padding:10px;border:1px solid #ddd;border-radius:5px;"></div>');
tableRowSection.append('<h4 style="margin-bottom:10px;display:flex;justify-content:space-between;align-items:center;">Формулы для строк т.ч. <button class="btn btn-sm btn-primary add-table-row-formula"><i class="fa fa-plus"></i> Добавить формулу</button></h4>');
tableRowSection.append('<div class="table-row-formulas-list" style="width:100%;"></div>');
wrapper.append(tableRowSection);
}
function populateFieldsPanel($modal, frm, grouped_fields, isTableField) {
const $container = $modal.find(".field-groups-container");
$container.empty();
// Добавляем секцию предопределенных событий
const $predefinedEventsGroup = $(`
<div class="field-group">
<div class="field-group-title">Предопределенные события</div>
<div class="field-buttons-container" data-group="predefined-events"></div>
</div>
`);
const $predefinedButtonsContainer = $predefinedEventsGroup.find('.field-buttons-container');
// Добавляем кнопки для предопределенных событий
const predefinedEvents = [
{ name: "При открытии документа", id: "При открытии документа" }
];
predefinedEvents.forEach(event => {
const $eventBtn = $(`
<button class="btn btn-sm field-btn highlight-event draggable-item"
data-fieldname="${event.id}"
data-label="${event.name}"
data-group="predefined-events"
data-field-type="event"
draggable="true">
${event.name}
</button>
`);
$predefinedButtonsContainer.append($eventBtn);
});
// Добавляем группу предопределенных событий в начало контейнера
$container.prepend($predefinedEventsGroup);
// Добавляем стандартные поля
Object.keys(grouped_fields).forEach(group => {
const $fieldGroup = $(`
<div class="field-group">
<div class="field-group-title">${group}</div>
<div class="field-buttons-container" data-group="${group}"></div>
</div>
`);
const $buttonsContainer = $fieldGroup.find('.field-buttons-container');
grouped_fields[group].forEach(field => {
// Определяем, является ли это поле таблицы или нет
const isTableGroupField = group !== "Основные поля";
// Создаем кнопку поля с правильными атрибутами
const btnClass = isTableGroupField ? "field-btn table-field-btn" : "field-btn";
const $fieldBtn = $(`
<button class="btn btn-sm ${btnClass} draggable-item"
data-fieldname="${field.fieldname}"
data-label="${field.label}"
data-group="${group}"
data-field-type="${isTableGroupField ? 'table' : 'field'}"
draggable="true">
${field.label}
</button>
`);
$buttonsContainer.append($fieldBtn);
});
$container.append($fieldGroup);
});
// Добавляем секцию универсальных полей
if (frm.doc.universal_fields_storage) {
const universalFields = JSON.parse(frm.doc.universal_fields_storage || "{}");
if (Object.keys(universalFields).length > 0) {
const $universalFieldGroup = $(`
<div class="field-group">
<div class="field-group-title">Универсальные поля</div>
<div class="field-buttons-container" data-group="universal-fields"></div>
</div>
`);
const $universalButtonsContainer = $universalFieldGroup.find('.field-buttons-container');
Object.keys(universalFields).forEach(fieldId => {
const field = universalFields[fieldId];
if (!field || !field.name) return;
const $fieldBtn = $(`
<button class="btn btn-sm universal-field-btn draggable-item"
data-field-id="${fieldId}"
data-fieldname="univ(${field.name})"
data-label="${field.name}"
data-group="universal-fields"
data-field-type="universal"
draggable="true">
${field.name}
</button>
`);
$universalButtonsContainer.append($fieldBtn);
});
$container.append($universalFieldGroup);
}
}
// Добавляем секцию полей отчетов
if (frm.doc.report_fields_storage) {
const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}");
if (Object.keys(reportFields).length > 0) {
const $reportFieldGroup = $(`
<div class="field-group">
<div class="field-group-title">Поля отчетов</div>
<div class="field-buttons-container" data-group="report-fields"></div>
</div>
`);
const $reportButtonsContainer = $reportFieldGroup.find('.field-buttons-container');
Object.keys(reportFields).forEach(fieldId => {
const field = reportFields[fieldId];
if (!field || !field.name) return;
const $fieldBtn = $(`
<button class="btn btn-sm report-field-btn draggable-item"
data-field-id="${fieldId}"
data-fieldname="report(${field.name})"
data-label="${field.name}"
data-group="report-fields"
data-field-type="report"
draggable="true">
report(${field.name})
</button>
`);
$reportButtonsContainer.append($fieldBtn);
});
$container.append($reportFieldGroup);
}
}
// Добавляем группу для полей строк табличных частей
const $tableRowGroup = $(`
<div class="field-group">
<div class="field-group-title">Поля строк табличных частей</div>
<div class="field-buttons-container table-row-fields-container">
<button class="btn btn-warning btn-sm select-table-row-field">
<i class="fa fa-plus"></i> Выбрать поле строки т.ч.
</button>
</div>
</div>
`);
$container.append($tableRowGroup);
// НОВОЕ: Добавляем группу для полей из конкретных строк табличных частей
const $tableFieldGroup = $(`
<div class="field-group">
<div class="field-group-title">Поля из строк табличных частей</div>
<div class="field-buttons-container table-field-container">
<button class="btn btn-info btn-sm select-table-field">
<i class="fa fa-table"></i> Поле из строки т.ч.
</button>
</div>
</div>
`);
$container.append($tableFieldGroup);
// Добавляем обработчик для кнопки выбора поля строки табличной части
$tableRowGroup.find('.select-table-row-field').on('click', function() {
// Определяем целевой контейнер в зависимости от контекста
const targetContainer = $modal.hasClass('trigger-focus') ?
$modal.find(".trigger-result") :
$modal.find(".formula-result");
openTableRowFieldSelector(frm, targetContainer, $modal.hasClass('trigger-focus'));
});
// НОВОЕ: Добавляем обработчик для кнопки выбора поля из строки табличной части
$tableFieldGroup.find('.select-table-field').on('click', function() {
const targetContainer = $modal.find(".formula-result");
openTableFieldSelector(frm, targetContainer, false);
});
}
function populateTriggerFieldsPanel($modal, frm, grouped_fields) {
const $container = $modal.find(".trigger-field-groups-container");
$container.empty();
// Добавляем секцию предопределенных событий
const $predefinedEventsGroup = $(`
<div class="trigger-field-group">
<div class="trigger-field-group-title">Предопределенные события</div>
<div class="trigger-field-buttons-container" data-group="predefined-events"></div>
</div>
`);
const $predefinedButtonsContainer = $predefinedEventsGroup.find('.trigger-field-buttons-container');
// Добавляем кнопки для предопределенных событий
const predefinedEvents = [
{ name: "При открытии документа", id: "При открытии документа" }
];
predefinedEvents.forEach(event => {
const $eventBtn = $(`
<button class="btn btn-sm trigger-field-btn draggable-item highlight-event"
data-fieldname="${event.id}"
data-label="${event.name}"
data-group="predefined-events"
draggable="true">
${event.name}
</button>
`);
$predefinedButtonsContainer.append($eventBtn);
});
// Добавляем группу предопределенных событий в начало контейнера
$container.prepend($predefinedEventsGroup);
// Добавляем секцию универсальных полей, если они есть
if (frm.doc.universal_fields_storage) {
const universalFields = JSON.parse(frm.doc.universal_fields_storage || "{}");
if (Object.keys(universalFields).length > 0) {
const $universalFieldGroup = $(`
<div class="trigger-field-group">
<div class="trigger-field-group-title">Универсальные поля</div>
<div class="trigger-field-buttons-container" data-group="universal-fields"></div>
</div>
`);
const $universalButtonsContainer = $universalFieldGroup.find('.trigger-field-buttons-container');
Object.keys(universalFields).forEach(fieldId => {
const field = universalFields[fieldId];
if (!field || !field.name) return;
const $fieldBtn = $(`
<button class="btn btn-sm trigger-field-btn universal-field-btn draggable-item"
data-fieldname="univ(${field.name})"
data-label="${field.name}"
data-group="universal-fields"
data-field-id="${fieldId}"
draggable="true">
${field.name}
</button>
`);
$universalButtonsContainer.append($fieldBtn);
});
$container.append($universalFieldGroup);
}
}
// Добавляем обычные поля
Object.keys(grouped_fields).forEach(group => {
// Пропускаем пустые группы
if (!grouped_fields[group] || grouped_fields[group].length === 0) return;
const $fieldGroup = $(`
<div class="trigger-field-group">
<div class="trigger-field-group-title">${group}</div>
<div class="trigger-field-buttons-container" data-group="${group}"></div>
</div>
`);
const $buttonsContainer = $fieldGroup.find('.trigger-field-buttons-container');
grouped_fields[group].forEach(field => {
// Создаем кнопку поля с информацией о типе поля
const isTableField = group !== "Основные поля";
const btnClass = isTableField ? "trigger-field-btn table-field-btn" : "trigger-field-btn";
const $fieldBtn = $(`
<button class="btn btn-sm ${btnClass} draggable-item"
data-fieldname="${field.fieldname}"
data-label="${field.label}"
data-group="${group}"
data-fieldtype="${field.fieldtype || ''}"
draggable="true">
${field.label}
</button>
`);
$buttonsContainer.append($fieldBtn);
});
$container.append($fieldGroup);
});
// Добавляем группу для полей строк табличных частей
const $tableRowGroup = $(`
<div class="trigger-field-group">
<div class="trigger-field-group-title">Поля строк табличных частей</div>
<div class="trigger-field-buttons-container table-row-fields-container">
<button class="btn btn-warning btn-sm select-table-row-trigger-field">
<i class="fa fa-plus"></i> Выбрать поле строки т.ч.
</button>
</div>
</div>
`);
$container.append($tableRowGroup);
// Добавляем стили для выделения различных типов триггеров
if (!$("#trigger-field-styles").length) {
$("head").append(`
<style id="trigger-field-styles">
.highlight-event {
background-color: #f8f1c6 !important;
border-color: #e6d292 !important;
}
.highlight-event:hover {
background-color: #f0e6b1 !important;
}
.trigger-token.highlight-event {
background-color: #f8f1c6;
border-color: #e6d292;
}
.universal-field-btn {
background-color: #eff8e6 !important;
border-color: #b7d69d !important;
}
.universal-field-btn:hover {
background-color: #deedcf !important;
}
.trigger-token.universal-field {
background-color: #eff8e6;
border-color: #b7d69d;
}
.table-field-btn {
background-color: #e3efff !important;
border-color: #abc9f5 !important;
}
.table-field-btn:hover {
background-color: #d0e5ff !important;
}
.trigger-token.table-field {
background-color: #e3efff;
border-color: #abc9f5;
}
.table-row-field-btn {
background-color: #fff3cd !important;
border-color: #ffeeba !important;
}
.table-row-field-btn:hover {
background-color: #ffeeba !important;
}
.trigger-token.table-row-field {
background-color: #fff3cd;
border-color: #ffeeba;
}
.btn.select-table-row-field,
.btn.select-table-row-trigger-field {
background-color: #fff3cd;
border-color: #ffeeba;
}
.btn.select-table-row-field:hover,
.btn.select-table-row-trigger-field:hover {
background-color: #ffeeba;
border-color: #ffdf7e;
}
</style>
`);
}
}
function setEndOfContentEditable(contentEditableElement) {
let range, selection;
range = document.createRange();
range.selectNodeContents(contentEditableElement);
range.collapse(false);
selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,171 @@
function getTableDoctypes(target_doctype, table_doctypes) {
// Пытаемся получить информацию о таблицах через API
frappe.call({
method: "frappe.client.get",
args: {
doctype: "DocType",
name: target_doctype
},
async: false,
callback: function(r) {
if (r.message && r.message.fields) {
r.message.fields.forEach(field => {
if (field.fieldtype === 'Table' && field.options) {
table_doctypes[field.fieldname] = field.options;
}
});
}
}
});
// Если данные не получены, используем альтернативный способ через meta
if (Object.keys(table_doctypes).length === 0) {
const meta = frappe.get_meta(target_doctype);
if (meta && meta.fields) {
meta.fields.forEach(field => {
if (field.fieldtype === 'Table' && field.options) {
table_doctypes[field.fieldname] = field.options;
}
});
}
}
// Проверяем результат
if (Object.keys(table_doctypes).length === 0) {
} else {
}
}
function collectChildTables(formula_data, child_tables) {
Object.keys(formula_data).forEach(fieldname => {
let field_info = formula_data[fieldname];
if (field_info && field_info.is_table_field) {
// Извлекаем имя табличной части из fieldname (формат: tablename_fieldname)
let parts = fieldname.split('_');
if (parts.length >= 2) {
let table_name = parts[0];
if (!child_tables[table_name]) {
child_tables[table_name] = [];
}
child_tables[table_name].push(fieldname);
}
}
});
}
function processAzerbayjanDateField(docValues) {
// Словарь для сопоставления названий месяцев с их номерами
const monthNames = {
"Yanvar": 1,
"Fevral": 2,
"Mart": 3,
"Aprel": 4,
"May": 5,
"İyun": 6,
"İyul": 7,
"Avqust": 8,
"Sentyabr": 9,
"Oktyabr": 10,
"Noyabr": 11,
"Dekabr": 12
};
// Проверяем наличие квартала (rüb) и года (il)
if (docValues && docValues.rüb && docValues.il) {
// Вызываем специальную функцию для обработки квартала
return processQuarterDateField(docValues);
}
// Проверяем, есть ли у нас значения ay и il
if (docValues && docValues.ay && monthNames[docValues.ay]) {
// Получаем номер месяца
const monthNum = monthNames[docValues.ay];
// Получаем год из поля il или используем текущий год
let year = docValues.il || new Date().getFullYear();
// Определяем первый день месяца (1-е число)
const firstDay = new Date(year, monthNum - 1, 1);
// Последний день месяца (получаем последнее число месяца)
const lastDay = new Date(year, monthNum, 0);
// Форматируем даты в формате YYYY-MM-DD
const firstDayStr = `${year}-${String(monthNum).padStart(2, '0')}-01`;
const lastDayStr = `${year}-${String(monthNum).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`;
// Возвращаем объект с диапазоном дат
return {
start: firstDayStr,
end: lastDayStr
};
}
// Если это не специальное поле или значение не распознано, возвращаем null
return null;
}
function processQuarterDateField(docValues) {
// Проверяем, есть ли у нас значения rüb и il
if (docValues && docValues.rüb && docValues.il) {
// Получаем номер квартала (преобразуем из строки вида "1 Rüb" в число 1)
const quarterStr = docValues.rüb;
const quarterMatch = quarterStr.match(/(\d+)/);
if (!quarterMatch) {
return null;
}
const quarterNum = parseInt(quarterMatch[1]);
// Получаем год из поля il или используем текущий год
let year = docValues.il || new Date().getFullYear();
// Определяем начальный и конечный месяц квартала
let startMonth, endMonth;
switch(quarterNum) {
case 1:
startMonth = 1; // Январь
endMonth = 3; // Март
break;
case 2:
startMonth = 4; // Апрель
endMonth = 6; // Июнь
break;
case 3:
startMonth = 7; // Июль
endMonth = 9; // Сентябрь
break;
case 4:
startMonth = 10; // Октябрь
endMonth = 12; // Декабрь
break;
default:
return null;
}
// Определяем первый день квартала (1-е число начального месяца)
const firstDay = new Date(year, startMonth - 1, 1);
// Последний день квартала (последнее число конечного месяца)
const lastDay = new Date(year, endMonth, 0);
// Форматируем даты в формате YYYY-MM-DD
const firstDayStr = `${year}-${String(startMonth).padStart(2, '0')}-01`;
const lastDayStr = `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`;
// Возвращаем объект с диапазоном дат
return {
start: firstDayStr,
end: lastDayStr
};
}
// Если это не специальное поле или значение не распознано, возвращаем null
return null;
}

View File

@ -0,0 +1 @@
/home/frappe/frappe-bench/apps/formula_editor/node_modules

1009
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"devDependencies": {
"eslint": "^9.29.0"
}
}