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 ` +
| ${__('Number')} | +${__('Account Name')} | +${__('Account Type')} | +${__('Root Type')} | +${__('Report Type')} | +${__('Action')} | +
|---|---|---|---|---|---|
| + ${__('Loading...')} + | |||||