From 3a3b850733484e5b4f205c16a6d5f2ca1749fc32 Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Fri, 27 Mar 2026 18:19:25 +0400 Subject: [PATCH] feat: add Account selection dialog for Link -> Account fields Replace standard Advanced Search with a custom two-column dialog for Account link fields: tree navigation by account groups on the left, filterable/sortable table with Select buttons on the right. Co-Authored-By: Claude Opus 4.6 (1M context) --- selections/api.py | 254 ++++++++ selections/hooks.py | 4 +- .../public/css/account_selection_dialog.css | 258 ++++++++ .../public/js/account_selection_dialog.js | 591 ++++++++++++++++++ selections/public/js/selection_dialog.js | 12 + 5 files changed, 1117 insertions(+), 2 deletions(-) create mode 100644 selections/public/css/account_selection_dialog.css create mode 100644 selections/public/js/account_selection_dialog.js diff --git a/selections/api.py b/selections/api.py index 4df0ea0..80a807f 100644 --- a/selections/api.py +++ b/selections/api.py @@ -627,3 +627,257 @@ def add_items_to_document(doctype, docname, items): except Exception as e: frappe.log_error(f"Error adding items to {doctype} {docname}: {str(e)}") frappe.throw(_('Error adding items: {0}').format(str(e))) + + +# ──────────────────────────────────────────────────────────────────────────── +# API для формы подбора счетов (Account) +# ──────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def get_accounts_for_selection(filters=None, start=0, page_length=50, custom_filters=None): + """ + Получить список счетов для формы подбора + + Args: + filters (dict): Фильтры (search_term, account_group, sort_by, sort_order) + start (int): Смещение для пагинации + page_length (int): Количество записей на странице + custom_filters (list): Произвольные фильтры [{field, operator, value}] + + Returns: + dict: {items: [...], total_count: int} + """ + from frappe.utils import cint + + filters = frappe.parse_json(filters) if isinstance(filters, str) else (filters or {}) + custom_filters = frappe.parse_json(custom_filters) if isinstance(custom_filters, str) else (custom_filters or []) + start = cint(start) + page_length = cint(page_length) + + Account = frappe.qb.DocType('Account') + + query = ( + frappe.qb.from_(Account) + .select( + Account.name, + Account.account_name, + Account.account_number, + Account.account_type, + Account.root_type, + Account.report_type, + Account.is_group, + Account.parent_account, + Account.company, + ) + .where(Account.disabled == 0) + .where(Account.is_group == 0) + ) + + # Поиск по номеру и названию + search_term = cstr(filters.get('search_term', '')).strip() + if search_term: + term = f"%{search_term}%" + query = query.where( + (Account.account_name.like(term)) + | (Account.account_number.like(term)) + | (Account.name.like(term)) + ) + + # Фильтр по группе счетов (через lft/rgt NestedSet) + account_group = cstr(filters.get('account_group', '')).strip() + if account_group: + lft_rgt = frappe.db.get_value('Account', account_group, ['lft', 'rgt']) + if lft_rgt: + lft, rgt = lft_rgt + query = query.where((Account.lft >= lft) & (Account.rgt <= rgt)) + + # Произвольные фильтры + if custom_filters: + query = _apply_account_custom_filters(query, Account, custom_filters) + + # Подсчёт общего количества (отдельный запрос, что бы не мутировать основной query) + count_query = ( + frappe.qb.from_(Account) + .select(Count('*').as_('cnt')) + .where(Account.disabled == 0) + .where(Account.is_group == 0) + ) + if search_term: + term = f"%{search_term}%" + count_query = count_query.where( + (Account.account_name.like(term)) + | (Account.account_number.like(term)) + | (Account.name.like(term)) + ) + if account_group: + lft_rgt = frappe.db.get_value('Account', account_group, ['lft', 'rgt']) + if lft_rgt: + lft, rgt = lft_rgt + count_query = count_query.where((Account.lft >= lft) & (Account.rgt <= rgt)) + if custom_filters: + count_query = _apply_account_custom_filters(count_query, Account, custom_filters) + + count_result = count_query.run(as_dict=True) + total_count = count_result[0].cnt if count_result else 0 + + # Сортировка + sort_by = cstr(filters.get('sort_by', 'account_number')).strip() + sort_order = cstr(filters.get('sort_order', 'asc')).strip() + + valid_sort_fields = {'account_number', 'account_name', 'account_type', 'root_type', 'report_type', 'name'} + if sort_by not in valid_sort_fields: + sort_by = 'account_number' + + sort_field = Account[sort_by] + if sort_order == 'desc': + query = query.orderby(sort_field, order=Order.desc) + else: + query = query.orderby(sort_field, order=Order.asc) + + # Пагинация + query = query.limit(page_length).offset(start) + accounts = query.run(as_dict=True) + + return { + 'items': accounts, + 'total_count': total_count, + } + + +def _apply_account_custom_filters(query, Account, custom_filters): + """ + Применить произвольные фильтры по полям DocType Account + """ + account_meta = frappe.get_meta('Account') + valid_fields = {f.fieldname: f for f in account_meta.fields} + + text_like_types = {'Small Text', 'Text', 'Long Text', 'Text Editor'} + + for cf in custom_filters: + field = cstr(cf.get('field', '')).strip() + operator = cstr(cf.get('operator', '=')).strip() + value = cf.get('value', '') + + if not field or field not in valid_fields: + continue + + field_meta = valid_fields[field] + account_field = Account[field] + + if field_meta.fieldtype == 'Check': + if value == 'True': + value = 1 + elif value == 'False': + value = 0 + else: + continue + + if field_meta.fieldtype in text_like_types and operator in ('=', '!='): + if operator == '=': + query = query.where(account_field.like(f'%{value}%')) + else: + query = query.where(~account_field.like(f'%{value}%')) + continue + + if operator == '=': + query = query.where(account_field == value) + elif operator == '!=': + query = query.where(account_field != value) + elif operator == 'like': + query = query.where(account_field.like(f'%{value}%')) + elif operator == 'not like': + query = query.where(~account_field.like(f'%{value}%')) + elif operator == '>': + query = query.where(account_field > flt(value)) + elif operator == '<': + query = query.where(account_field < flt(value)) + elif operator == '>=': + query = query.where(account_field >= flt(value)) + elif operator == '<=': + query = query.where(account_field <= flt(value)) + elif operator == 'is set': + query = query.where(account_field.isnotnull() & (account_field != '')) + elif operator == 'is not set': + query = query.where(account_field.isnull() | (account_field == '')) + + return query + + +@frappe.whitelist() +def get_account_groups_tree(): + """ + Получить дерево групп счетов для фильтра + + Returns: + list: Дерево групп счетов + """ + cache_key = 'account_groups_tree' + cached_tree = frappe.cache().get_value(cache_key) + + if cached_tree: + return cached_tree + + Account = frappe.qb.DocType('Account') + query = ( + frappe.qb.from_(Account) + .select( + Account.name, + Account.account_name, + Account.account_number, + Account.parent_account, + Account.is_group, + Account.lft, + Account.rgt, + ) + .where(Account.is_group == 1) + .where(Account.disabled == 0) + .orderby(Account.lft) + ) + + groups = query.run(as_dict=True) + + tree = _build_account_tree(groups) + + frappe.cache().set_value(cache_key, tree, expires_in_sec=300) + + return tree + + +def _build_account_tree(groups): + """ + Построить дерево из плоского списка групп счетов + """ + if not groups: + return [] + + tree = [] + + def make_label(g): + num = g.get('account_number', '') + name = g['account_name'] or g['name'] + return f"{num} - {name}" if num else name + + def add_children(parent_name): + children = [] + for g in groups: + if g['parent_account'] == parent_name: + node = { + 'value': g['name'], + 'label': make_label(g), + 'is_group': g['is_group'], + 'children': add_children(g['name']), + } + children.append(node) + return children + + for g in groups: + if not g['parent_account']: + node = { + 'value': g['name'], + 'label': make_label(g), + 'is_group': g['is_group'], + 'children': add_children(g['name']), + } + tree.append(node) + + return tree diff --git a/selections/hooks.py b/selections/hooks.py index 60fbcd7..1a0cd52 100644 --- a/selections/hooks.py +++ b/selections/hooks.py @@ -25,8 +25,8 @@ app_license = "unlicense" # ------------------ # include js, css files in header of desk.html -app_include_css = "/assets/selections/css/selection_dialog.css" -app_include_js = "/assets/selections/js/selection_dialog.js" +app_include_css = ["/assets/selections/css/selection_dialog.css", "/assets/selections/css/account_selection_dialog.css"] +app_include_js = ["/assets/selections/js/selection_dialog.js", "/assets/selections/js/account_selection_dialog.js"] # include js, css files in header of web template # web_include_css = "/assets/selections/css/selections.css" diff --git a/selections/public/css/account_selection_dialog.css b/selections/public/css/account_selection_dialog.css new file mode 100644 index 0000000..cf3661d --- /dev/null +++ b/selections/public/css/account_selection_dialog.css @@ -0,0 +1,258 @@ +/** + * Стили для формы подбора счетов (Account) + */ + +/* Fullscreen modal */ +.account-selection-dialog-wrapper .modal-dialog { + max-width: 100vw !important; + width: 100vw !important; + height: 100vh !important; + margin: 0 !important; +} + +.account-selection-dialog-wrapper .modal-content { + height: 100vh !important; + border-radius: 0 !important; + border: none !important; +} + +.account-selection-dialog-wrapper .modal-body { + padding: 15px !important; + overflow: clip !important; + flex: 1 !important; +} + +.account-selection-dialog-wrapper .frappe-control { + width: 100% !important; + max-width: none !important; +} + +.account-selection-dialog-wrapper .form-group { + width: 100% !important; + max-width: none !important; +} + +/* 2-колоночный layout */ +.account-selection-content { + display: flex; + gap: 0; + height: calc(100vh - 130px); + min-height: 500px; +} + +/* ── Левая колонка: Дерево ──────────────────────────────────────────── */ +.account-selection-filters { + flex: 0 0 380px; + padding-right: 12px; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +/* Заголовок дерева с кнопками */ +.account-tree-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; +} + +.account-tree-header label { + font-weight: 600; + margin: 0; + color: var(--text-color); +} + +.account-tree-actions { + display: flex; + gap: 4px; +} + +.account-tree-actions .btn { + padding: 3px 7px; + font-size: 14px; + line-height: 1; +} + +/* Контейнер дерева — scroll по Y, hidden по X предотвращает сдвиги */ +.account-group-tree { + flex: 1; + overflow-y: scroll; + overflow-x: hidden; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 5px; + min-height: 0; +} + +/* ── Узлы дерева ────────────────────────────────────────────────────── */ +.acct-tree-node { + display: flex; + align-items: center; + padding: 4px 8px; + cursor: pointer; + border-radius: var(--border-radius); + transition: background-color 0.15s; + gap: 4px; +} + +.acct-tree-node:hover { + background-color: var(--bg-light-gray); +} + +.acct-tree-node.active { + background-color: var(--primary-color); + color: white; +} + +.acct-tree-node.active .acct-tree-link { + color: white !important; +} + +/* Chevron toggle */ +.acct-tree-toggle { + flex-shrink: 0; + width: 16px; + text-align: center; + cursor: pointer; + font-size: 12px; + color: var(--text-muted); +} + +.acct-tree-toggle:hover { + color: var(--primary-color); +} + +.acct-tree-node.active .acct-tree-toggle { + color: white; +} + +/* Placeholder для узлов без детей — сохраняет выравнивание */ +.acct-tree-toggle-placeholder { + flex-shrink: 0; + width: 16px; +} + +/* Ссылка (label) */ +.acct-tree-link { + text-decoration: none; + color: var(--text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 16px; + line-height: 1.4; +} + +.acct-tree-link i { + margin-right: 5px; + width: 14px; + text-align: center; + font-size: 13px; +} + +/* ── Правая колонка ─────────────────────────────────────────────────── */ +.account-selection-items-column { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +/* Поиск сверху */ +.account-search-bar { + flex-shrink: 0; + margin-bottom: 8px; + padding: 0 4px; +} + +.account-search-bar .frappe-control, +.account-search-bar .form-group { + margin-bottom: 0; +} + +/* Иконка поиска — вставляется в .control-input через JS */ +.account-search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + z-index: 1; + pointer-events: none; + font-size: 13px; +} + +/* Таблица счетов — фиксированная ширина колонок предотвращает сдвиги */ +.account-table { + table-layout: fixed; +} + +.account-table tbody tr { + cursor: pointer; +} + +.account-table tbody tr:hover { + background-color: var(--bg-light-gray); +} + +.account-table td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.btn-select-account { + white-space: nowrap; +} + +/* Awesomplete для фильтров */ +.account-filter-bar .awesomplete > ul { + z-index: 1060; + position: absolute; +} + +.account-filter-bar #account-custom-filters-list:empty { + display: none; +} + +/* ── Скроллбары ─────────────────────────────────────────────────────── */ +.account-items-grid::-webkit-scrollbar, +.account-group-tree::-webkit-scrollbar { + width: 8px; +} + +.account-items-grid::-webkit-scrollbar-track, +.account-group-tree::-webkit-scrollbar-track { + background: var(--bg-light-gray); + border-radius: 4px; +} + +.account-items-grid::-webkit-scrollbar-thumb, +.account-group-tree::-webkit-scrollbar-thumb { + background: var(--text-muted); + border-radius: 4px; +} + +.account-items-grid::-webkit-scrollbar-thumb:hover, +.account-group-tree::-webkit-scrollbar-thumb:hover { + background: var(--text-color); +} + +/* ── Responsive ─────────────────────────────────────────────────────── */ +@media (max-width: 1200px) { + .account-selection-content { + flex-direction: column; + } + + .account-selection-filters { + flex: 0 0 auto; + border: none; + padding: 0; + border-bottom: 1px solid var(--border-color); + padding-bottom: 15px; + margin-bottom: 15px; + max-height: 250px; + } +} diff --git a/selections/public/js/account_selection_dialog.js b/selections/public/js/account_selection_dialog.js new file mode 100644 index 0000000..ddf99ae --- /dev/null +++ b/selections/public/js/account_selection_dialog.js @@ -0,0 +1,591 @@ +/** + * Диалог подбора счетов (Account) — замена стандартного Advanced Search + */ + +class AccountSelectionDialog { + constructor(opts) { + this.link_selector_opts = opts; + this.current_page = 0; + this.page_length = 50; + this.total_count = 0; + this.filters = {}; + this.sort_by = 'account_number'; + this.sort_order = 'asc'; + this.focused_row_index = -1; + this.custom_filters = []; + this._account_fields = []; + this.accounts_data = []; + + this.make_dialog(); + } + + make_dialog() { + this.dialog = new frappe.ui.Dialog({ + title: __('Select Account'), + size: 'extra-large', + minimizable: true, + static: false, + primary_action_label: null, + }); + + this.dialog.$wrapper.addClass('account-selection-dialog-wrapper'); + this.dialog.$body.html(this._get_html()); + this.dialog.show(); + + setTimeout(() => { + this.setup_resizable_panels(); + this.setup_handlers(); + this.setup_search_field(); + this.setup_custom_filters(); + this.load_account_groups(); + this.load_accounts(); + }, 50); + + this.dialog.onhide = () => { + document.removeEventListener('keydown', this._keydown_handler); + }; + } + + _get_html() { + return ` +
+ + + + +
+ + + +
`; + } + + // ── Поиск ────────────────────────────────────────────────────────────── + + setup_search_field() { + const wrapper = this.dialog.$body.find('#account-search-field'); + this.search_field = frappe.ui.form.make_control({ + df: { fieldtype: 'Data', fieldname: 'account_search', placeholder: __('Search by number or name...') }, + parent: wrapper, + render_input: true, + }); + + // Иконку поиска вставляем в .control-input — прямой родитель + const $ci = this.search_field.$input.closest('.control-input'); + $ci.css('position', 'relative'); + $ci.prepend(''); + this.search_field.$input.css('padding-left', '30px'); + + this.search_field.$input.on('input', frappe.utils.debounce(() => { + this.filters.search_term = this.search_field.get_value(); + this.current_page = 0; + this.load_accounts(); + }, 500)); + this.search_field.$input.on('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.filters.search_term = this.search_field.get_value(); + this.current_page = 0; + this.load_accounts(); + } + }); + } + + // ── Дерево групп ─────────────────────────────────────────────────────── + + load_account_groups() { + frappe.call({ + method: 'selections.api.get_account_groups_tree', + args: {}, + async: true, + callback: (r) => { + if (r.message) { + this.render_account_groups_tree(r.message); + } + } + }); + } + + render_account_groups_tree(tree) { + const container = this.dialog.$body.find('#account-group-tree'); + let html = `
+ + ${__('All Accounts')} +
`; + html += this._render_tree_nodes(tree, 0); + container.html(html); + + // Клик по узлу (link) — выбор + toggle + container.on('click', '.acct-tree-link', (e) => { + e.preventDefault(); + e.stopPropagation(); + const $node = $(e.currentTarget).closest('.acct-tree-node'); + + // Выбор + container.find('.acct-tree-node').removeClass('active'); + container.find('.acct-tree-link').removeClass('active'); + $node.addClass('active'); + $node.find('> .acct-tree-link').addClass('active'); + + const value = $node.data('value'); + this.filters.account_group = value || ''; + this.current_page = 0; + this.load_accounts(); + + // Toggle дочерних узлов (как в COA) + const $children = $node.next('.acct-tree-children'); + if ($children.length) { + const $icon = $node.find('> .acct-tree-toggle .fa'); + if ($children.is(':visible')) { + $children.slideUp(150); + $icon.removeClass('fa-chevron-down').addClass('fa-chevron-right'); + } else { + $children.slideDown(150); + $icon.removeClass('fa-chevron-right').addClass('fa-chevron-down'); + } + } + }); + + // Клик по chevron — только toggle + container.on('click', '.acct-tree-toggle', (e) => { + e.preventDefault(); + e.stopPropagation(); + const $node = $(e.currentTarget).closest('.acct-tree-node'); + const $children = $node.next('.acct-tree-children'); + const $icon = $(e.currentTarget).find('.fa'); + + if ($children.is(':visible')) { + $children.slideUp(150); + $icon.removeClass('fa-chevron-down').addClass('fa-chevron-right'); + } else { + $children.slideDown(150); + $icon.removeClass('fa-chevron-right').addClass('fa-chevron-down'); + } + }); + + // Expand All / Collapse All + this.dialog.$body.find('#btn-expand-all').on('click', (e) => { + e.preventDefault(); + container.find('.acct-tree-children').slideDown(150); + container.find('.acct-tree-toggle .fa') + .removeClass('fa-chevron-right').addClass('fa-chevron-down'); + }); + this.dialog.$body.find('#btn-collapse-all').on('click', (e) => { + e.preventDefault(); + container.find('.acct-tree-children').slideUp(150); + container.find('.acct-tree-toggle .fa') + .removeClass('fa-chevron-down').addClass('fa-chevron-right'); + }); + } + + _render_tree_nodes(nodes, level) { + let html = ''; + for (const node of nodes) { + const pad = (level + 1) * 15; + const has_children = node.children && node.children.length; + const toggle_html = has_children + ? `` + : ``; + + html += `
+ ${toggle_html} ${frappe.utils.escape_html(node.label)} +
`; + if (has_children) { + html += `
`; + html += this._render_tree_nodes(node.children, level + 1); + html += `
`; + } + } + return html; + } + + // ── Загрузка и рендер таблицы ────────────────────────────────────────── + + load_accounts() { + const filters = Object.assign({}, this.filters, { + sort_by: this.sort_by, + sort_order: this.sort_order, + }); + + frappe.call({ + method: 'selections.api.get_accounts_for_selection', + args: { + filters: filters, + start: this.current_page * this.page_length, + page_length: this.page_length, + custom_filters: this.custom_filters.map(f => ({ + field: f.field, operator: f.operator, value: f.value, + })), + }, + async: true, + callback: (r) => { + if (r.message) { + // Дополнительный фильтр на клиенте — страховка от is_group + this.accounts_data = (r.message.items || []).filter(a => !a.is_group); + this.total_count = r.message.total_count || 0; + this.render_accounts(); + this.update_pagination(); + } + } + }); + } + + render_accounts() { + const tbody = this.dialog.$body.find('#account-items-body'); + + if (!this.accounts_data.length) { + tbody.html(`${__('No accounts found')}`); + return; + } + + let html = ''; + this.accounts_data.forEach((acc, idx) => { + const account_number = frappe.utils.escape_html(acc.account_number || ''); + const account_name = frappe.utils.escape_html(acc.account_name || ''); + const account_type = frappe.utils.escape_html(acc.account_type || ''); + const root_type = frappe.utils.escape_html(acc.root_type || ''); + const report_type = frappe.utils.escape_html(acc.report_type || ''); + const name = frappe.utils.escape_html(acc.name || ''); + + html += ` + ${account_number} + + ${account_name} + + + + + ${account_type} + ${root_type} + ${report_type} + + + + `; + }); + tbody.html(html); + } + + // ── Выбор счёта ──────────────────────────────────────────────────────── + + select_account(account_name) { + const opts = this.link_selector_opts; + if (opts.target) { + opts.target.set_value(account_name); + } + this.dialog.hide(); + } + + // ── Пагинация ────────────────────────────────────────────────────────── + + update_pagination() { + const total_pages = Math.max(1, Math.ceil(this.total_count / this.page_length)); + const current = this.current_page + 1; + + this.dialog.$body.find('#account-page-info').text( + __('Page {0} of {1} (Total: {2})', [current, total_pages, this.total_count]) + ); + this.dialog.$body.find('#account-prev-page').prop('disabled', this.current_page <= 0); + this.dialog.$body.find('#account-next-page').prop('disabled', current >= total_pages); + } + + // ── Обработчики событий ──────────────────────────────────────────────── + + setup_handlers() { + // Сортировка + this.dialog.$body.find('.sortable-header').on('click', (e) => { + const $th = $(e.currentTarget); + const field = $th.data('sort'); + if (this.sort_by === field) { + this.sort_order = this.sort_order === 'asc' ? 'desc' : 'asc'; + } else { + this.sort_by = field; + this.sort_order = 'asc'; + } + this.dialog.$body.find('.sortable-header').removeClass('sort-active'); + $th.addClass('sort-active'); + $th.find('i').attr('class', 'fa fa-sort-' + (this.sort_order === 'asc' ? 'asc' : 'desc')); + this.current_page = 0; + this.load_accounts(); + }); + + // Кнопка выбора + this.dialog.$body.on('click', '.btn-select-account', (e) => { + e.stopPropagation(); + const name = $(e.currentTarget).data('name'); + this.select_account(name); + }); + + // Клик по строке — тоже выбирает + this.dialog.$body.on('click', '.account-row', (e) => { + if ($(e.target).closest('.btn-select-account, .item-open-link').length) return; + const name = $(e.currentTarget).data('name'); + this.select_account(name); + }); + + // Пагинация + this.dialog.$body.find('#account-prev-page').on('click', () => { + if (this.current_page > 0) { + this.current_page--; + this.load_accounts(); + } + }); + this.dialog.$body.find('#account-next-page').on('click', () => { + const total_pages = Math.ceil(this.total_count / this.page_length); + if (this.current_page + 1 < total_pages) { + this.current_page++; + this.load_accounts(); + } + }); + + // Клавиатура + this._keydown_handler = (e) => this._handle_keydown(e); + document.addEventListener('keydown', this._keydown_handler); + } + + _handle_keydown(e) { + if (!this.dialog.display) return; + + const rows = this.dialog.$body.find('.account-row'); + if (!rows.length) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.focused_row_index = Math.min(this.focused_row_index + 1, rows.length - 1); + this._focus_row(rows); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this.focused_row_index = Math.max(this.focused_row_index - 1, 0); + this._focus_row(rows); + } else if (e.key === 'Enter' && this.focused_row_index >= 0) { + const $target = $(e.target); + if ($target.closest('.account-search-bar, .account-filter-bar').length) return; + e.preventDefault(); + const name = $(rows[this.focused_row_index]).data('name'); + this.select_account(name); + } else if (e.key === 'Escape') { + this.dialog.hide(); + } + } + + _focus_row(rows) { + rows.removeClass('selection-row-focused'); + if (this.focused_row_index >= 0 && this.focused_row_index < rows.length) { + const $row = $(rows[this.focused_row_index]); + $row.addClass('selection-row-focused'); + $row[0].scrollIntoView({ block: 'nearest' }); + } + } + + // ── Resizable панели ─────────────────────────────────────────────────── + + setup_resizable_panels() { + const handle = this.dialog.$body.find('#account-resize-left')[0]; + const left = this.dialog.$body.find('.account-selection-filters')[0]; + if (!handle || !left) return; + + handle.addEventListener('pointerdown', (e) => { + e.preventDefault(); + document.body.classList.add('selection-resizing'); + const startX = e.clientX; + const startW = left.getBoundingClientRect().width; + + const onMove = (ev) => { + const newW = Math.min(Math.max(startW + ev.clientX - startX, 180), 500); + left.style.flex = `0 0 ${newW}px`; + }; + const onUp = () => { + document.body.classList.remove('selection-resizing'); + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + }; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + }); + } + + // ── Кастомные фильтры (произвольные по полям Account) ────────────────── + + setup_custom_filters() { + frappe.model.with_doctype('Account', () => { + const meta = frappe.get_meta('Account'); + const skip = new Set(['Section Break', 'Column Break', 'HTML', 'Button', 'Tab Break', 'Table']); + this._account_fields = meta.fields.filter(f => !skip.has(f.fieldtype)); + }); + + this.dialog.$body.find('#btn-add-account-filter').on('click', () => this._add_custom_filter()); + } + + _add_custom_filter() { + const id = frappe.utils.get_random(8); + this.custom_filters.push({ id, field: '', operator: '=', value: '' }); + + const $row = $(`
+
+
+
+ +
`); + + this.dialog.$body.find('#account-custom-filters-list').append($row); + + // Поле + const field_options = this._account_fields.map(f => f.label || f.fieldname); + const field_ctrl = frappe.ui.form.make_control({ + df: { fieldtype: 'Select', fieldname: `cf_field_${id}`, options: ['', ...field_options] }, + parent: $row.find('.cf-field-wrap'), + render_input: true, + }); + field_ctrl.$input.on('change', () => { + const label = field_ctrl.get_value(); + const meta_field = this._account_fields.find(f => (f.label || f.fieldname) === label); + const cf = this.custom_filters.find(f => f.id === id); + if (cf && meta_field) { + cf.field = meta_field.fieldname; + this._render_operator(id, meta_field); + this._render_value(id, meta_field); + } + }); + + // Удалить + $row.find('.cf-remove').on('click', () => { + this.custom_filters = this.custom_filters.filter(f => f.id !== id); + $row.remove(); + this.current_page = 0; + this.load_accounts(); + }); + } + + _render_operator(id, meta_field) { + const $row = this.dialog.$body.find(`.custom-filter-row[data-id="${id}"]`); + const $wrap = $row.find('.cf-operator-wrap').empty(); + + const text_types = new Set(['Data', 'Small Text', 'Text', 'Long Text', 'Text Editor']); + const num_types = new Set(['Int', 'Float', 'Currency', 'Percent', 'Date', 'Datetime', 'Duration']); + + let ops; + if (text_types.has(meta_field.fieldtype)) { + ops = ['=', '!=', 'like', 'not like', 'is set', 'is not set']; + } else if (num_types.has(meta_field.fieldtype)) { + ops = ['=', '!=', '>', '<', '>=', '<=', 'is set', 'is not set']; + } else { + ops = ['=', '!=']; + } + + const ctrl = frappe.ui.form.make_control({ + df: { fieldtype: 'Select', fieldname: `cf_op_${id}`, options: ops, default: '=' }, + parent: $wrap, + render_input: true, + }); + ctrl.set_value('='); + ctrl.$input.on('change', () => { + const cf = this.custom_filters.find(f => f.id === id); + if (cf) { + cf.operator = ctrl.get_value(); + this.current_page = 0; + this.load_accounts(); + } + }); + } + + _render_value(id, meta_field) { + const $row = this.dialog.$body.find(`.custom-filter-row[data-id="${id}"]`); + const $wrap = $row.find('.cf-value-wrap').empty(); + + let df; + if (meta_field.fieldtype === 'Check') { + df = { fieldtype: 'Select', fieldname: `cf_val_${id}`, options: ['', 'True', 'False'] }; + } else if (meta_field.fieldtype === 'Select') { + df = { fieldtype: 'Select', fieldname: `cf_val_${id}`, options: meta_field.options || '' }; + } else if (meta_field.fieldtype === 'Link') { + df = { fieldtype: 'Link', fieldname: `cf_val_${id}`, options: meta_field.options || '' }; + } else { + df = { fieldtype: 'Data', fieldname: `cf_val_${id}` }; + } + + const ctrl = frappe.ui.form.make_control({ + df: df, + parent: $wrap, + render_input: true, + }); + + const debounced_apply = frappe.utils.debounce(() => { + const cf = this.custom_filters.find(f => f.id === id); + if (cf) { + cf.value = ctrl.get_value(); + this.current_page = 0; + this.load_accounts(); + } + }, 300); + + ctrl.$input.on('change', debounced_apply); + if (!['Link', 'Select'].includes(df.fieldtype)) { + ctrl.$input.on('input', debounced_apply); + } + } +} + +// Экспорт +window.AccountSelectionDialog = AccountSelectionDialog; diff --git a/selections/public/js/selection_dialog.js b/selections/public/js/selection_dialog.js index 9a7a9ac..5d9535a 100644 --- a/selections/public/js/selection_dialog.js +++ b/selections/public/js/selection_dialog.js @@ -1457,3 +1457,15 @@ class SelectionDialog { // Экспортируем класс в глобальную область window.SelectionDialog = SelectionDialog; + +// Перехват Advanced Search для Link -> Account +$(document).ready(function () { + const OriginalLinkSelector = frappe.ui.form.LinkSelector; + + frappe.ui.form.LinkSelector = function (opts) { + if (opts.doctype === 'Account') { + return new AccountSelectionDialog(opts); + } + return new OriginalLinkSelector(opts); + }; +});