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 ` +
| ${__('Document')} | +${__('Posting Date')} | +${__('Supplier')} | +${__('Amount')} | +${__('Action')} | +
|---|---|---|---|---|
| + ${__('Loading...')} + | ||||