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) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-03-27 18:19:25 +04:00
parent ed8c31e4ad
commit 3a3b850733
5 changed files with 1117 additions and 2 deletions

View File

@ -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

View File

@ -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"

View File

@ -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;
}
}

View File

@ -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 `
<div class="account-selection-content">
<!-- Левая колонка: Дерево групп -->
<div class="account-selection-filters">
<div class="filter-group" style="flex-shrink:0;">
<div class="account-tree-header">
<label>${__('Account Groups')}</label>
<span class="account-tree-actions">
<button class="btn btn-xs btn-default" id="btn-expand-all" title="${__('Expand All')}"><i class="fa fa-plus-square-o"></i></button>
<button class="btn btn-xs btn-default" id="btn-collapse-all" title="${__('Collapse All')}"><i class="fa fa-minus-square-o"></i></button>
</span>
</div>
</div>
<div class="account-group-tree" id="account-group-tree">
<div class="text-muted text-center p-3">
<i class="fa fa-spinner fa-spin"></i> ${__('Loading...')}
</div>
</div>
</div>
<!-- Ручка resize -->
<div class="resize-handle" id="account-resize-left"></div>
<!-- Правая колонка: Поиск + фильтры + таблица -->
<div class="account-selection-items-column">
<!-- Поиск сверху -->
<div class="account-search-bar">
<div id="account-search-field"></div>
</div>
<!-- Панель кастомных фильтров -->
<div class="account-filter-bar selection-filter-bar">
<div id="account-custom-filters-list"></div>
<button class="btn btn-xs btn-default btn-add-custom-filter" id="btn-add-account-filter">
<i class="fa fa-plus"></i> ${__('Add Filter')}
</button>
</div>
<!-- Таблица -->
<div class="account-items-grid selection-items-grid">
<table class="table table-bordered selection-table account-table">
<thead>
<tr>
<th class="sortable-header" data-sort="account_number" style="width:100px;">${__('Number')} <i class="fa fa-sort"></i></th>
<th class="sortable-header" data-sort="account_name">${__('Account Name')} <i class="fa fa-sort"></i></th>
<th class="sortable-header" data-sort="account_type" style="width:140px;">${__('Account Type')} <i class="fa fa-sort"></i></th>
<th class="sortable-header" data-sort="root_type" style="width:110px;">${__('Root Type')} <i class="fa fa-sort"></i></th>
<th class="sortable-header" data-sort="report_type" style="width:130px;">${__('Report Type')} <i class="fa fa-sort"></i></th>
<th style="width:80px;">${__('Action')}</th>
</tr>
</thead>
<tbody id="account-items-body">
<tr><td colspan="6" class="text-center text-muted p-4">
<i class="fa fa-spinner fa-spin"></i> ${__('Loading...')}
</td></tr>
</tbody>
</table>
</div>
<!-- Пагинация -->
<div class="selection-pagination" id="account-pagination">
<button class="btn btn-sm btn-default" id="account-prev-page">
<i class="fa fa-chevron-left"></i> ${__('Previous')}
</button>
<span class="pagination-info" id="account-page-info"></span>
<button class="btn btn-sm btn-default" id="account-next-page">
${__('Next')} <i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
</div>`;
}
// ── Поиск ──────────────────────────────────────────────────────────────
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 — прямой родитель <input>
const $ci = this.search_field.$input.closest('.control-input');
$ci.css('position', 'relative');
$ci.prepend('<i class="fa fa-search account-search-icon"></i>');
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 = `<div class="acct-tree-node" data-value="">
<span class="acct-tree-toggle-placeholder"></span>
<a href="#" class="acct-tree-link active"><i class="fa fa-list"></i> ${__('All Accounts')}</a>
</div>`;
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
? `<span class="acct-tree-toggle"><i class="fa fa-chevron-down"></i></span>`
: `<span class="acct-tree-toggle-placeholder"></span>`;
html += `<div class="acct-tree-node" data-value="${frappe.utils.escape_html(node.value)}" style="padding-left: ${pad}px;">
${toggle_html}<a href="#" class="acct-tree-link"><i class="fa fa-folder"></i> ${frappe.utils.escape_html(node.label)}</a>
</div>`;
if (has_children) {
html += `<div class="acct-tree-children">`;
html += this._render_tree_nodes(node.children, level + 1);
html += `</div>`;
}
}
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(`<tr><td colspan="6" class="text-center text-muted p-4">${__('No accounts found')}</td></tr>`);
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 += `<tr class="account-row" data-idx="${idx}" data-name="${name}">
<td>${account_number}</td>
<td>
${account_name}
<a href="/app/account/${encodeURIComponent(acc.name)}" target="_blank" class="item-open-link" title="${__('Open')}">
<i class="fa fa-external-link"></i>
</a>
</td>
<td>${account_type}</td>
<td>${root_type}</td>
<td>${report_type}</td>
<td class="text-center">
<button class="btn btn-xs btn-primary btn-select-account" data-name="${name}">
${__('Select')}
</button>
</td>
</tr>`;
});
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 = $(`<div class="custom-filter-row" data-id="${id}">
<div class="cf-field-wrap"></div>
<div class="cf-operator-wrap"></div>
<div class="cf-value-wrap"></div>
<button class="btn btn-xs btn-danger cf-remove"><i class="fa fa-times"></i></button>
</div>`);
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;

View File

@ -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);
};
});