From 5672dae50bc85554686741f52f7fb472d4b8003b Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Mon, 18 May 2026 16:14:20 +0000 Subject: [PATCH] feat: document selection dialog for Landed Cost Voucher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dialog mirroring the Account selection UX for the receipt_document (purchase_receipts) and vendor_invoice (vendor_invoices) fields. Intercepts the standard Advanced Search (LinkSelector) for these fields with a fullscreen dialog featuring a Supplier Group tree on the left, resizable panels, search, custom filters bar, sortable table and pagination. Backend adds get_documents_for_selection (PI/PR/SE/SR with the same filters ERPNext applies in set_query), get_vendor_invoices_for_selection (replicates erpnext.stock...get_vendor_invoice_query — unclaimed_amount > 0, no stock items, etc.), and get_supplier_groups_tree. --- selections/api.py | 401 +++++++++++ selections/hooks.py | 12 +- .../public/css/document_selection_dialog.css | 386 ++++++++++ .../public/js/document_selection_dialog.js | 662 ++++++++++++++++++ selections/public/js/selection_dialog.js | 12 +- 5 files changed, 1470 insertions(+), 3 deletions(-) create mode 100644 selections/public/css/document_selection_dialog.css create mode 100644 selections/public/js/document_selection_dialog.js diff --git a/selections/api.py b/selections/api.py index d972b49..84b7e34 100644 --- a/selections/api.py +++ b/selections/api.py @@ -935,3 +935,404 @@ def _build_account_tree(groups): tree.append(node) return tree + + +# ──────────────────────────────────────────────────────────────────────────── +# API для формы подбора документов (Landed Cost Voucher) +# ──────────────────────────────────────────────────────────────────────────── + +# Метаданные по поддерживаемым типам документов receipt_document +RECEIPT_DOC_META = { + 'Purchase Invoice': { + 'amount_field': 'base_grand_total', + 'has_supplier': True, + 'extra_where': lambda dt: [(dt.update_stock == 1)], + }, + 'Purchase Receipt': { + 'amount_field': 'base_grand_total', + 'has_supplier': True, + 'extra_where': lambda dt: [], + }, + 'Stock Entry': { + 'amount_field': 'total_incoming_value', + 'has_supplier': True, + 'extra_where': lambda dt: [(dt.purpose.isin(['Manufacture', 'Repack']))], + }, + 'Subcontracting Receipt': { + 'amount_field': 'total', + 'has_supplier': True, + 'extra_where': lambda dt: [], + }, +} + + +@frappe.whitelist() +def get_documents_for_selection( + doctype, + filters=None, + start=0, + page_length=50, + link_filters=None, + custom_filters=None, +): + """ + Получить список документов (Receipt Document) для формы подбора в Landed Cost Voucher. + + Args: + doctype: Один из 'Purchase Invoice', 'Purchase Receipt', 'Stock Entry', 'Subcontracting Receipt' + filters: dict (search_term, sort_by, sort_order, supplier_group) + start, page_length: пагинация + link_filters: dict/list из set_query — поддерживается формат Frappe + custom_filters: list [{field, operator, value}] + + Returns: + dict: {items: [...], total_count: int} + """ + from frappe.utils import cint + + if doctype not in RECEIPT_DOC_META: + frappe.throw(_('Unsupported document type: {0}').format(doctype)) + + if not frappe.has_permission(doctype, 'read'): + frappe.throw(_('Insufficient permissions for {0}').format(doctype)) + + filters = frappe.parse_json(filters) if isinstance(filters, str) else (filters or {}) + link_filters = frappe.parse_json(link_filters) if isinstance(link_filters, str) else (link_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) + + meta = RECEIPT_DOC_META[doctype] + + def build_base(): + DT = frappe.qb.DocType(doctype) + q = frappe.qb.from_(DT).where(DT.docstatus == 1) + for cond in meta['extra_where'](DT): + q = q.where(cond) + q = _apply_doc_link_filters(q, DT, link_filters) + + # Фильтр по группе поставщиков (NestedSet на Supplier Group + связь через supplier.supplier_group) + sg = cstr(filters.get('supplier_group', '')).strip() + if sg: + group_names = _get_supplier_group_descendants(sg) + if group_names: + Supplier = frappe.qb.DocType('Supplier') + q = q.left_join(Supplier).on(Supplier.name == DT.supplier) + q = q.where(Supplier.supplier_group.isin(group_names)) + + # Поиск + search_term = cstr(filters.get('search_term', '')).strip() + if search_term: + term = f"%{search_term}%" + cond = DT.name.like(term) + if meta['has_supplier']: + cond = cond | DT.supplier.like(term) + q = q.where(cond) + + # Произвольные фильтры + q = _apply_doc_custom_filters(q, DT, doctype, custom_filters) + + return q, DT + + # Основной запрос с выборкой полей + query, DT = build_base() + query = query.select(DT.name, DT.posting_date, DT.supplier, DT[meta['amount_field']].as_('grand_total')) + + # Сортировка + sort_by = cstr(filters.get('sort_by', 'posting_date')).strip() + sort_order = cstr(filters.get('sort_order', 'desc')).strip() + valid_sort = {'name', 'posting_date', 'supplier', 'grand_total'} + if sort_by not in valid_sort: + sort_by = 'posting_date' + + if sort_by == 'grand_total': + sort_col = DT[meta['amount_field']] + elif sort_by == 'supplier' and not meta['has_supplier']: + sort_col = DT.posting_date + else: + sort_col = DT[sort_by] + + query = query.orderby(sort_col, order=Order.desc if sort_order == 'desc' else Order.asc) + query = query.limit(page_length).offset(start) + rows = query.run(as_dict=True) + + # Подсчёт total — отдельный запрос с теми же условиями + count_query, _ = build_base() + count_query = count_query.select(Count(DT.name).distinct().as_('cnt')) + total_count = (count_query.run(as_dict=True) or [{}])[0].get('cnt', 0) + + return {'items': rows, 'total_count': total_count} + + +@frappe.whitelist() +def get_vendor_invoices_for_selection( + filters=None, + start=0, + page_length=50, + link_filters=None, + custom_filters=None, +): + """ + Получить список Vendor Invoices (Purchase Invoice) для формы подбора в Landed Cost Voucher. + Логика повторяет erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher.get_vendor_invoice_query: + docstatus=1, is_subcontracted=0, is_return=0, update_stock=0, есть несклад. позиции, + unclaimed_amount > 0. + """ + from frappe.utils import cint + + if not frappe.has_permission('Purchase Invoice', 'read'): + frappe.throw(_('Insufficient permissions for Purchase Invoice')) + + filters = frappe.parse_json(filters) if isinstance(filters, str) else (filters or {}) + link_filters = frappe.parse_json(link_filters) if isinstance(link_filters, str) else (link_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) + + def build_base(): + PI = frappe.qb.DocType('Purchase Invoice') + PII = frappe.qb.DocType('Purchase Invoice Item') + Item = frappe.qb.DocType('Item') + + q = ( + frappe.qb.from_(PI) + .inner_join(PII).on(PII.parent == PI.name) + .inner_join(Item).on(Item.name == PII.item_code) + .where( + (PI.docstatus == 1) + & (PI.is_subcontracted == 0) + & (PI.is_return == 0) + & (PI.update_stock == 0) + & (Item.is_stock_item == 0) + & ((PI.base_total - PI.claimed_landed_cost_amount) > 0) + ) + ) + + company = link_filters.get('company') if isinstance(link_filters, dict) else None + if company: + q = q.where(PI.company == company) + + sg = cstr(filters.get('supplier_group', '')).strip() + if sg: + group_names = _get_supplier_group_descendants(sg) + if group_names: + Supplier = frappe.qb.DocType('Supplier') + q = q.left_join(Supplier).on(Supplier.name == PI.supplier) + q = q.where(Supplier.supplier_group.isin(group_names)) + + search_term = cstr(filters.get('search_term', '')).strip() + if search_term: + term = f"%{search_term}%" + q = q.where(PI.name.like(term) | PI.supplier.like(term)) + + q = _apply_doc_custom_filters(q, PI, 'Purchase Invoice', custom_filters) + + return q, PI + + # Основной запрос + query, PI = build_base() + unclaimed = (PI.base_total - PI.claimed_landed_cost_amount).as_('grand_total') + query = query.select(PI.name, PI.posting_date, PI.supplier, unclaimed).distinct() + + sort_by = cstr(filters.get('sort_by', 'posting_date')).strip() + sort_order = cstr(filters.get('sort_order', 'desc')).strip() + valid_sort = {'name', 'posting_date', 'supplier', 'grand_total'} + if sort_by not in valid_sort: + sort_by = 'posting_date' + + if sort_by == 'grand_total': + sort_col = (PI.base_total - PI.claimed_landed_cost_amount) + else: + sort_col = PI[sort_by] + + query = query.orderby(sort_col, order=Order.desc if sort_order == 'desc' else Order.asc) + query = query.limit(page_length).offset(start) + rows = query.run(as_dict=True) + + # Подсчёт total + count_query, _ = build_base() + count_query = count_query.select(PI.name).distinct() + total_count = len(count_query.run(as_dict=True)) + + return {'items': rows, 'total_count': total_count} + + +def _apply_doc_custom_filters(query, DT, doctype, custom_filters): + """ + Применить произвольные фильтры по полям doctype (как у Account). + """ + if not custom_filters: + return query + + doc_meta = frappe.get_meta(doctype) + valid_fields = {f.fieldname: f for f in doc_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] + col = DT[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(col.like(f'%{value}%')) + else: + query = query.where(~col.like(f'%{value}%')) + continue + + if operator == '=': + query = query.where(col == value) + elif operator == '!=': + query = query.where(col != value) + elif operator == 'like': + query = query.where(col.like(f'%{value}%')) + elif operator == 'not like': + query = query.where(~col.like(f'%{value}%')) + elif operator == '>': + query = query.where(col > flt(value)) + elif operator == '<': + query = query.where(col < flt(value)) + elif operator == '>=': + query = query.where(col >= flt(value)) + elif operator == '<=': + query = query.where(col <= flt(value)) + elif operator == 'is set': + query = query.where(col.isnotnull() & (col != '')) + elif operator == 'is not set': + query = query.where(col.isnull() | (col == '')) + + return query + + +def _get_supplier_group_descendants(group_name): + """Возвращает имена всех потомков (включая саму группу) для Supplier Group по lft/rgt.""" + lft_rgt = frappe.db.get_value('Supplier Group', group_name, ['lft', 'rgt']) + if not lft_rgt: + return [] + lft, rgt = lft_rgt + SG = frappe.qb.DocType('Supplier Group') + rows = ( + frappe.qb.from_(SG) + .select(SG.name) + .where((SG.lft >= lft) & (SG.rgt <= rgt)) + .run(as_dict=True) + ) + return [r['name'] for r in rows] + + +@frappe.whitelist() +def get_supplier_groups_tree(): + """Дерево групп поставщиков для левого фильтра в форме подбора документов.""" + cache_key = 'supplier_groups_tree' + cached = frappe.cache().get_value(cache_key) + if cached: + return cached + + SG = frappe.qb.DocType('Supplier Group') + rows = ( + frappe.qb.from_(SG) + .select(SG.name, SG.supplier_group_name, SG.parent_supplier_group, SG.is_group, SG.lft, SG.rgt) + .orderby(SG.lft) + .run(as_dict=True) + ) + + def add_children(parent_name): + children = [] + for g in rows: + if g['parent_supplier_group'] == parent_name: + children.append({ + 'value': g['name'], + 'label': g['supplier_group_name'] or g['name'], + 'is_group': g['is_group'], + 'children': add_children(g['name']), + }) + return children + + tree = [] + for g in rows: + if not g['parent_supplier_group']: + tree.append({ + 'value': g['name'], + 'label': g['supplier_group_name'] or g['name'], + 'is_group': g['is_group'], + 'children': add_children(g['name']), + }) + + frappe.cache().set_value(cache_key, tree, expires_in_sec=300) + return tree + + +def _apply_doc_link_filters(query, DT, link_filters): + """ + Применить link_filters из set_query. + Поддерживает два формата: + - dict: {field: value | [op, val]} + - list of lists: [[doctype, field, op, val], ...] — формат set_query + """ + if not link_filters: + return query + + # Список фильтров (формат set_query) + if isinstance(link_filters, list): + for f in link_filters: + if not isinstance(f, (list, tuple)) or len(f) < 3: + continue + # Может быть [field, op, val] или [doctype, field, op, val] + if len(f) == 4: + _dt, field, op, val = f + else: + field, op, val = f[0], f[1], f[2] + query = _apply_doc_filter(query, DT, field, op, val) + return query + + # dict-формат + for field, value in link_filters.items(): + if isinstance(value, (list, tuple)) and len(value) == 2: + op, val = value[0], value[1] + query = _apply_doc_filter(query, DT, field, op, val) + else: + query = _apply_doc_filter(query, DT, field, '=', value) + return query + + +def _apply_doc_filter(query, DT, field, op, val): + try: + col = DT[field] + except Exception: + return query + + op = (op or '=').lower() + if op == '=': + query = query.where(col == val) + elif op == '!=': + query = query.where(col != val) + elif op == 'in' and isinstance(val, (list, tuple)): + query = query.where(col.isin(val)) + elif op == 'not in' and isinstance(val, (list, tuple)): + query = query.where(col.notin(val)) + elif op == 'like': + query = query.where(col.like(val)) + elif op == '>': + query = query.where(col > val) + elif op == '<': + query = query.where(col < val) + elif op == '>=': + query = query.where(col >= val) + elif op == '<=': + query = query.where(col <= val) + return query diff --git a/selections/hooks.py b/selections/hooks.py index 1a0cd52..a6122e6 100644 --- a/selections/hooks.py +++ b/selections/hooks.py @@ -25,8 +25,16 @@ app_license = "unlicense" # ------------------ # include js, css files in header of desk.html -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"] +app_include_css = [ + "/assets/selections/css/selection_dialog.css", + "/assets/selections/css/account_selection_dialog.css", + "/assets/selections/css/document_selection_dialog.css", +] +app_include_js = [ + "/assets/selections/js/document_selection_dialog.js", + "/assets/selections/js/account_selection_dialog.js", + "/assets/selections/js/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/document_selection_dialog.css b/selections/public/css/document_selection_dialog.css new file mode 100644 index 0000000..2c1ae57 --- /dev/null +++ b/selections/public/css/document_selection_dialog.css @@ -0,0 +1,386 @@ +/** + * Стили для формы подбора документов (Landed Cost Voucher). + * Повторяют структуру account_selection_dialog.css. + */ + +/* Fullscreen modal */ +.document-selection-dialog-wrapper .modal-dialog { + max-width: 100vw !important; + width: 100vw !important; + height: 100vh !important; + margin: 0 !important; +} + +.document-selection-dialog-wrapper .modal-content { + height: 100vh !important; + border-radius: 0 !important; + border: none !important; +} + +.document-selection-dialog-wrapper .modal-body { + padding: 15px !important; + overflow: clip !important; + flex: 1 !important; +} + +.document-selection-dialog-wrapper .frappe-control { + width: 100% !important; + max-width: none !important; +} + +.document-selection-dialog-wrapper .form-group { + width: 100% !important; + max-width: none !important; +} + +/* 2-колоночный layout */ +.document-selection-content { + display: flex; + gap: 0; + height: calc(100vh - 130px); + min-height: 500px; +} + +/* ── Левая колонка: Дерево ──────────────────────────────────────────── */ +.document-selection-filters { + flex: 0 0 320px; + padding-right: 12px; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.document-tree-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; +} + +.document-tree-header label { + font-weight: 600; + margin: 0; + color: var(--text-color); +} + +.document-tree-actions { + display: flex; + gap: 4px; +} + +.document-tree-actions .btn { + padding: 3px 7px; + font-size: 14px; + line-height: 1; +} + +.document-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; +} + +/* ── Узлы дерева ────────────────────────────────────────────────────── */ +.doc-tree-node { + display: flex; + align-items: center; + padding: 4px 8px; + cursor: pointer; + border-radius: var(--border-radius); + transition: background-color 0.15s; + gap: 4px; +} + +.doc-tree-node:hover { + background-color: var(--bg-light-gray); +} + +.doc-tree-node.active { + background-color: var(--primary-color); + color: white; +} + +.doc-tree-node.active .doc-tree-link { + color: white !important; +} + +.doc-tree-toggle { + flex-shrink: 0; + width: 16px; + text-align: center; + cursor: pointer; + font-size: 12px; + color: var(--text-muted); +} + +.doc-tree-toggle:hover { + color: var(--primary-color); +} + +.doc-tree-node.active .doc-tree-toggle { + color: white; +} + +.doc-tree-toggle-placeholder { + flex-shrink: 0; + width: 16px; +} + +.doc-tree-link { + text-decoration: none; + color: var(--text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 15px; + line-height: 1.4; +} + +.doc-tree-link i { + margin-right: 5px; + width: 14px; + text-align: center; + font-size: 13px; +} + +/* ── Правая колонка ─────────────────────────────────────────────────── */ +.document-selection-items-column { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +/* Поиск сверху */ +.document-search-bar { + flex-shrink: 0; + margin-bottom: 8px; + padding: 0 4px; +} + +.document-search-bar .frappe-control, +.document-search-bar .form-group { + margin-bottom: 0; +} + +.document-search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + z-index: 1; + pointer-events: none; + font-size: 13px; +} + +/* Панель фильтров */ +.document-filter-bar { + flex-shrink: 0; + margin-bottom: 8px; + padding: 0 4px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.document-filter-bar #document-custom-filters-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.document-filter-bar #document-custom-filters-list:empty { + display: none; +} + +.document-filter-bar .custom-filter-row { + display: flex; + align-items: center; + gap: 8px; +} + +.document-filter-bar .cf-field-wrap { + flex: 0 0 220px; +} + +.document-filter-bar .cf-operator-wrap { + flex: 0 0 130px; +} + +.document-filter-bar .cf-value-wrap { + flex: 1 1 auto; + min-width: 0; +} + +.document-filter-bar .cf-remove { + flex: 0 0 auto; + line-height: 1; + padding: 3px 7px; +} + +.document-filter-bar .frappe-control, +.document-filter-bar .form-group { + margin: 0 !important; +} + +.document-filter-bar .awesomplete > ul { + z-index: 1060; + position: absolute; +} + +.document-filter-bar .btn-add-custom-filter { + align-self: flex-start; +} + +/* Контейнер таблицы */ +.document-items-grid { + flex: 1 1 auto; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + min-height: 0; +} + +.document-table { + margin-bottom: 0; + table-layout: fixed; +} + +.document-table thead th { + position: sticky; + top: 0; + background-color: var(--fg-color); + z-index: 2; + white-space: nowrap; + user-select: none; +} + +.document-table tbody tr { + cursor: pointer; +} + +.document-table tbody tr:hover, +.document-table tbody tr.selection-row-focused { + background-color: var(--bg-light-gray); +} + +.document-table td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.document-table .sortable-header { + cursor: pointer; +} + +.document-table .sortable-header.sort-active { + color: var(--primary); +} + +.document-table .item-open-link { + margin-left: 6px; + opacity: 0.6; +} + +.document-table .item-open-link:hover { + opacity: 1; +} + +.btn-select-document { + white-space: nowrap; +} + +/* Пагинация */ +.document-selection-items-column .selection-pagination { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 6px 0 0; +} + +.document-selection-items-column .pagination-info { + color: var(--text-muted); + font-size: 12px; +} + +/* Ручка resize */ +.document-selection-dialog-wrapper .resize-handle { + flex: 0 0 6px; + cursor: col-resize; + background: transparent; + position: relative; +} + +.document-selection-dialog-wrapper .resize-handle::before { + content: ""; + position: absolute; + left: 2px; + top: 0; + bottom: 0; + width: 2px; + background: var(--border-color); + border-radius: 1px; + transition: background-color 0.15s; +} + +.document-selection-dialog-wrapper .resize-handle:hover::before { + background: var(--primary-color); +} + +body.selection-resizing { + cursor: col-resize !important; + user-select: none !important; +} + +/* Скроллбары */ +.document-items-grid::-webkit-scrollbar, +.document-group-tree::-webkit-scrollbar { + width: 8px; +} + +.document-items-grid::-webkit-scrollbar-track, +.document-group-tree::-webkit-scrollbar-track { + background: var(--bg-light-gray); + border-radius: 4px; +} + +.document-items-grid::-webkit-scrollbar-thumb, +.document-group-tree::-webkit-scrollbar-thumb { + background: var(--text-muted); + border-radius: 4px; +} + +.document-items-grid::-webkit-scrollbar-thumb:hover, +.document-group-tree::-webkit-scrollbar-thumb:hover { + background: var(--text-color); +} + +/* Responsive */ +@media (max-width: 1200px) { + .document-selection-content { + flex-direction: column; + } + + .document-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; + } + + .document-selection-dialog-wrapper .resize-handle { + display: none; + } +} diff --git a/selections/public/js/document_selection_dialog.js b/selections/public/js/document_selection_dialog.js new file mode 100644 index 0000000..1014e46 --- /dev/null +++ b/selections/public/js/document_selection_dialog.js @@ -0,0 +1,662 @@ +/** + * Диалог подбора документов — замена стандартного Advanced Search (LinkSelector). + * Используется в Landed Cost Voucher для полей: + * - receipt_document (purchase_receipts) — Purchase Invoice / Purchase Receipt / Stock Entry / Subcontracting Receipt + * - vendor_invoice (vendor_invoices) — Purchase Invoice (с unclaimed_amount > 0) + * + * Структурно повторяет AccountSelectionDialog: левая колонка — дерево групп поставщиков, + * правая — поиск, произвольные фильтры, сортируемая таблица, пагинация. + */ + +class DocumentSelectionDialog { + /** + * @param {object} opts + * - target: LinkControl + * - doctype: doctype документов в списке + * - mode: 'receipt_document' | 'vendor_invoice' + * - title: заголовок диалога + * - txt: начальный поисковый запрос + */ + constructor(opts) { + this.link_selector_opts = opts; + this.target = opts.target; + this.doctype = opts.doctype; + this.mode = opts.mode || 'receipt_document'; + this.title = opts.title || __('Select Document'); + + this.current_page = 0; + this.page_length = 50; + this.total_count = 0; + this.filters = {}; + this.sort_by = 'posting_date'; + this.sort_order = 'desc'; + this.focused_row_index = -1; + this.custom_filters = []; + this._doc_fields = []; + this.rows_data = []; + + this.link_filters = this._extract_link_filters(); + + this.make_dialog(); + } + + _extract_link_filters() { + const target = this.target; + if (!target) return {}; + const args = {}; + if (target.set_custom_query) { + try { target.set_custom_query(args); } catch (e) { /* ignore */ } + } + return args.filters || {}; + } + + make_dialog() { + this.dialog = new frappe.ui.Dialog({ + title: this.title, + size: 'extra-large', + minimizable: true, + static: false, + primary_action_label: null, + }); + + this.dialog.$wrapper.addClass('document-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_supplier_groups(); + this.load_documents(); + }, 50); + + this.dialog.onhide = () => { + document.removeEventListener('keydown', this._keydown_handler); + }; + } + + _get_html() { + return ` +
+ +
+
+
+ + + + + +
+
+
+
+ ${__('Loading...')} +
+
+
+ + +
+ + +
+ + + + +
+
+ +
+ + +
+ + + + + + + + + + + + + +
${__('Document')} ${__('Posting Date')} ${__('Supplier')} ${__('Amount')} ${__('Action')}
+ ${__('Loading...')} +
+
+ + +
+ + + +
+
+
`; + } + + // ── Поиск ────────────────────────────────────────────────────────────── + + setup_search_field() { + const wrapper = this.dialog.$body.find('#document-search-field'); + this.search_field = frappe.ui.form.make_control({ + df: { fieldtype: 'Data', fieldname: 'document_search', placeholder: __('Search by document or supplier...') }, + parent: wrapper, + render_input: true, + }); + + const initial_txt = this.link_selector_opts.txt; + if (initial_txt) { + this.search_field.set_value(initial_txt); + this.filters.search_term = initial_txt; + } + + 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.focus(); + + this.search_field.$input.on('input', frappe.utils.debounce(() => { + this.filters.search_term = this.search_field.get_value(); + this.current_page = 0; + this.load_documents(); + }, 400)); + 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_documents(); + } + }); + } + + // ── Дерево групп поставщиков ────────────────────────────────────────── + + load_supplier_groups() { + frappe.call({ + method: 'selections.api.get_supplier_groups_tree', + args: {}, + async: true, + callback: (r) => { + if (r.message) { + this.render_supplier_groups_tree(r.message); + } + } + }); + } + + render_supplier_groups_tree(tree) { + const container = this.dialog.$body.find('#document-group-tree'); + let html = `
+ + ${__('All Suppliers')} +
`; + html += this._render_tree_nodes(tree, 0); + container.html(html); + + container.on('click', '.doc-tree-link', (e) => { + e.preventDefault(); + e.stopPropagation(); + const $node = $(e.currentTarget).closest('.doc-tree-node'); + + container.find('.doc-tree-node').removeClass('active'); + container.find('.doc-tree-link').removeClass('active'); + $node.addClass('active'); + $node.find('> .doc-tree-link').addClass('active'); + + const value = $node.data('value'); + this.filters.supplier_group = value || ''; + this.current_page = 0; + this.load_documents(); + + // Toggle дочерних узлов + const $children = $node.next('.doc-tree-children'); + if ($children.length) { + const $icon = $node.find('> .doc-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'); + } + } + }); + + container.on('click', '.doc-tree-toggle', (e) => { + e.preventDefault(); + e.stopPropagation(); + const $node = $(e.currentTarget).closest('.doc-tree-node'); + const $children = $node.next('.doc-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'); + } + }); + + this.dialog.$body.find('#btn-doc-expand-all').on('click', (e) => { + e.preventDefault(); + container.find('.doc-tree-children').slideDown(150); + container.find('.doc-tree-toggle .fa') + .removeClass('fa-chevron-right').addClass('fa-chevron-down'); + }); + this.dialog.$body.find('#btn-doc-collapse-all').on('click', (e) => { + e.preventDefault(); + container.find('.doc-tree-children').slideUp(150); + container.find('.doc-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_documents() { + const filters = Object.assign({}, this.filters, { + sort_by: this.sort_by, + sort_order: this.sort_order, + }); + + const method = this.mode === 'vendor_invoice' + ? 'selections.api.get_vendor_invoices_for_selection' + : 'selections.api.get_documents_for_selection'; + + const args = { + filters: filters, + start: this.current_page * this.page_length, + page_length: this.page_length, + link_filters: this.link_filters, + custom_filters: this.custom_filters.map(f => ({ + field: f.field, operator: f.operator, value: f.value, + })), + }; + if (this.mode !== 'vendor_invoice') { + args.doctype = this.doctype; + } + + frappe.call({ + method: method, + args: args, + async: true, + callback: (r) => { + if (r.message) { + this.rows_data = r.message.items || []; + this.total_count = r.message.total_count || 0; + this.render_documents(); + this.update_pagination(); + } + } + }); + } + + render_documents() { + const tbody = this.dialog.$body.find('#document-items-body'); + + if (!this.rows_data.length) { + tbody.html(`${__('No documents found')}`); + return; + } + + const route_dt = encodeURIComponent(this.doctype.toLowerCase().replace(/ /g, '-')); + + let html = ''; + this.rows_data.forEach((doc, idx) => { + const name = frappe.utils.escape_html(doc.name || ''); + const posting_date = doc.posting_date + ? frappe.datetime.str_to_user(doc.posting_date) + : ''; + const supplier = frappe.utils.escape_html(doc.supplier || ''); + const amount = format_currency(flt(doc.grand_total || 0)); + + html += ` + + ${name} + + + + + ${posting_date} + ${supplier} + ${amount} + + + + `; + }); + tbody.html(html); + } + + // ── Выбор документа ─────────────────────────────────────────────────── + + select_document(name) { + if (this.target) { + this.target.set_value(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('#document-page-info').text( + __('Page {0} of {1} (Total: {2})', [current, total_pages, this.total_count]) + ); + this.dialog.$body.find('#document-prev-page').prop('disabled', this.current_page <= 0); + this.dialog.$body.find('#document-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_documents(); + }); + + this.dialog.$body.on('click', '.btn-select-document', (e) => { + e.stopPropagation(); + const name = $(e.currentTarget).data('name'); + this.select_document(name); + }); + + this.dialog.$body.on('click', '.document-row', (e) => { + if ($(e.target).closest('.btn-select-document, .item-open-link').length) return; + const name = $(e.currentTarget).data('name'); + this.select_document(name); + }); + + this.dialog.$body.find('#document-prev-page').on('click', () => { + if (this.current_page > 0) { + this.current_page--; + this.load_documents(); + } + }); + this.dialog.$body.find('#document-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_documents(); + } + }); + + 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('.document-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('.document-search-bar, .document-filter-bar').length) return; + e.preventDefault(); + const name = $(rows[this.focused_row_index]).data('name'); + this.select_document(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('#document-resize-left')[0]; + const left = this.dialog.$body.find('.document-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); + }); + } + + // ── Произвольные фильтры по полям doctype ────────────────────────────── + + setup_custom_filters() { + const target_doctype = this.doctype; + frappe.model.with_doctype(target_doctype, () => { + const meta = frappe.get_meta(target_doctype); + const skip = new Set(['Section Break', 'Column Break', 'HTML', 'Button', 'Tab Break', 'Table', 'Table MultiSelect']); + this._doc_fields = (meta.fields || []).filter(f => !skip.has(f.fieldtype) && f.fieldname); + }); + + this.dialog.$body.find('#btn-add-document-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('#document-custom-filters-list').append($row); + + const field_options = this._doc_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._doc_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_documents(); + }); + } + + _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_documents(); + } + }); + } + + _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 if (meta_field.fieldtype === 'Date') { + df = { fieldtype: 'Date', fieldname: `cf_val_${id}` }; + } 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_documents(); + } + }, 300); + + ctrl.$input.on('change', debounced_apply); + if (!['Link', 'Select', 'Date'].includes(df.fieldtype)) { + ctrl.$input.on('input', debounced_apply); + } + } +} + +window.DocumentSelectionDialog = DocumentSelectionDialog; + +/** + * Определяет, должен ли LinkSelector быть перехвачен DocumentSelectionDialog, + * исходя из контекста (parent doctype + child field). + * Возвращает opts для DocumentSelectionDialog или null. + */ +window.get_document_selection_opts = function (opts) { + const target = opts.target; + if (!target) return null; + const df = target.df; + const frm = target.frm; + if (!df || !frm) return null; + + if (frm.doctype === 'Landed Cost Voucher' && df.fieldname === 'receipt_document') { + return { + target: target, + doctype: opts.doctype, + mode: 'receipt_document', + title: __('Select {0}', [__(opts.doctype)]), + txt: opts.txt, + }; + } + + if (frm.doctype === 'Landed Cost Voucher' && df.fieldname === 'vendor_invoice') { + return { + target: target, + doctype: 'Purchase Invoice', + mode: 'vendor_invoice', + title: __('Select Vendor Invoice'), + txt: opts.txt, + }; + } + + return null; +}; diff --git a/selections/public/js/selection_dialog.js b/selections/public/js/selection_dialog.js index d5b88bc..b7b2b3e 100644 --- a/selections/public/js/selection_dialog.js +++ b/selections/public/js/selection_dialog.js @@ -1475,14 +1475,24 @@ class SelectionDialog { // Экспортируем класс в глобальную область window.SelectionDialog = SelectionDialog; -// Перехват Advanced Search для Link -> Account +// Перехват Advanced Search для Link/Dynamic Link контролов $(document).ready(function () { const OriginalLinkSelector = frappe.ui.form.LinkSelector; frappe.ui.form.LinkSelector = function (opts) { + // Подбор счетов if (opts.doctype === 'Account') { return new AccountSelectionDialog(opts); } + + // Подбор документов (Landed Cost Voucher: receipt_document, vendor_invoice) + if (typeof window.get_document_selection_opts === 'function') { + const doc_opts = window.get_document_selection_opts(opts); + if (doc_opts) { + return new DocumentSelectionDialog(doc_opts); + } + } + return new OriginalLinkSelector(opts); }; });