diff --git a/selections/api.py b/selections/api.py index 45c1244..54083b1 100644 --- a/selections/api.py +++ b/selections/api.py @@ -11,7 +11,7 @@ from erpnext.stock.get_item_details import get_item_details @frappe.whitelist() -def get_items_for_selection(filters=None, start=0, page_length=50): +def get_items_for_selection(filters=None, start=0, page_length=50, custom_filters=None): """ Получить список товаров с остатками и ценами для формы подбора @@ -19,6 +19,7 @@ def get_items_for_selection(filters=None, start=0, page_length=50): filters (dict): Фильтры для поиска товаров start (int): Начальная позиция для пагинации page_length (int): Количество товаров на странице + custom_filters (list): Произвольные фильтры по полям DocType Item Returns: dict: { @@ -29,6 +30,9 @@ def get_items_for_selection(filters=None, start=0, page_length=50): if isinstance(filters, str): filters = frappe.parse_json(filters) + if isinstance(custom_filters, str): + custom_filters = frappe.parse_json(custom_filters) + if not filters: filters = {} @@ -102,6 +106,10 @@ def get_items_for_selection(filters=None, start=0, page_length=50): if only_in_stock: query = query.where(avail_expr > 0) + # Применяем произвольные фильтры + if custom_filters: + query = _apply_custom_filters(query, Item, custom_filters) + # Подсчет общего количества count_query = query.select(Count('*').as_('count')) count_result = count_query.run(as_dict=True) @@ -183,6 +191,55 @@ def get_items_for_selection(filters=None, start=0, page_length=50): } +def _apply_custom_filters(query, Item, custom_filters): + """ + Применить произвольные фильтры по полям DocType Item + + Args: + query: Query Builder запрос + Item: DocType Item + custom_filters (list): Список фильтров [{field, operator, value}] + + Returns: + query с применёнными фильтрами + """ + item_meta = frappe.get_meta('Item') + valid_fields = {f.fieldname for f in item_meta.fields} + + 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 + + item_field = Item[field] + + if operator == '=': + query = query.where(item_field == value) + elif operator == '!=': + query = query.where(item_field != value) + elif operator == 'like': + query = query.where(item_field.like(f'%{value}%')) + elif operator == 'not like': + query = query.where(~item_field.like(f'%{value}%')) + elif operator == '>': + query = query.where(item_field > flt(value)) + elif operator == '<': + query = query.where(item_field < flt(value)) + elif operator == '>=': + query = query.where(item_field >= flt(value)) + elif operator == '<=': + query = query.where(item_field <= flt(value)) + elif operator == 'is set': + query = query.where(item_field.isnotnull() & (item_field != '')) + elif operator == 'is not set': + query = query.where(item_field.isnull() | (item_field == '')) + + return query + + @frappe.whitelist() def get_stock_across_warehouses(item_code): """ diff --git a/selections/public/css/selection_dialog.css b/selections/public/css/selection_dialog.css index 9a0045f..201c617 100644 --- a/selections/public/css/selection_dialog.css +++ b/selections/public/css/selection_dialog.css @@ -43,7 +43,7 @@ /* Левая колонка: Фильтры */ .selection-filters { - flex: 0 0 250px; + flex: 0 0 300px; border-right: 1px solid var(--border-color); padding-right: 15px; display: flex; @@ -139,7 +139,7 @@ min-width: 0; } -/* Панель сортировки */ +/* Тулбар поиска (заменяет панель сортировки) */ .selection-sort-toolbar { display: flex; align-items: center; @@ -152,42 +152,42 @@ flex-shrink: 0; } -.sort-label { - font-size: 12px; +.selection-search-icon { color: var(--text-muted); - white-space: nowrap; + flex-shrink: 0; } -.sort-buttons { - display: flex; - gap: 4px; - flex-wrap: wrap; +.selection-search-input-toolbar { + flex: 1; + font-size: 13px; + height: 30px; + padding: 4px 8px; } -.sort-btn { - font-size: 11px; - padding: 2px 8px; - border: 1px solid var(--border-color); - background-color: var(--bg-color); - color: var(--text-color); - border-radius: var(--border-radius); - cursor: pointer; - transition: all 0.15s; +/* Ручки resize для панелей */ +.resize-handle { + flex: 0 0 6px; + cursor: col-resize; + background: var(--border-color); + border-radius: 3px; + transition: background 0.15s; + align-self: stretch; + margin: 0 2px; } -.sort-btn:hover { - background-color: var(--bg-light-gray); - border-color: var(--primary-color); +.resize-handle:hover, +.selection-resizing .resize-handle { + background: var(--primary-color); } -.sort-btn.active { - background-color: var(--primary-color); - color: white; - border-color: var(--primary-color); +/* Блокировать выделение текста во время drag */ +body.selection-resizing { + user-select: none; + cursor: col-resize !important; } -.sort-btn i { - margin-left: 3px; +body.selection-resizing * { + cursor: col-resize !important; } .selection-items-grid { @@ -545,10 +545,199 @@ font-size: 18px; } +/* ── Произвольные фильтры ─────────────────────────────────────────────── */ + +.custom-filter-row { + display: flex; + gap: 4px; + align-items: center; + margin-bottom: 5px; +} + +.cf-field { + flex: 1.5; + font-size: 12px; + padding: 3px 6px; + height: auto; + min-width: 0; +} + +.cf-operator { + flex: 1; + font-size: 12px; + padding: 3px 6px; + height: auto; + min-width: 0; +} + +.cf-value { + flex: 1.5; + font-size: 12px; + padding: 3px 6px; + height: auto; + min-width: 0; +} + +.cf-remove { + flex-shrink: 0; + padding: 2px 6px; +} + +.btn-add-custom-filter { + font-size: 12px; + margin-top: 4px; +} + +/* Link-контролы в строках кастомных фильтров */ +.cf-value-link-wrapper { + flex: 1.5; + min-width: 0; +} + +.cf-value-link-wrapper .frappe-control { + margin-bottom: 0; +} + +.cf-value-link-wrapper .form-group { + margin-bottom: 0; +} + +.cf-value-link-wrapper input.form-control { + font-size: 12px; + padding: 3px 6px; + height: auto; +} + +/* Link-контролы для склада и прайса в левой панели */ +#selection-warehouse-link .frappe-control, +#selection-pricelist-link .frappe-control { + margin-bottom: 0; +} + +#selection-warehouse-link .form-group, +#selection-pricelist-link .form-group { + margin-bottom: 0; +} + +/* ── Инфо-панель ──────────────────────────────────────────────────────── */ + +.selection-info-panel { + flex: 0 0 auto; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + overflow: hidden; + display: flex; + flex-direction: column; + flex-shrink: 0; + margin-top: 8px; + min-height: 160px; +} + +.info-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 6px 10px; + background-color: var(--bg-light-gray); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.info-panel-header-text { + flex: 1; + min-width: 0; +} + +.info-panel-header-text strong { + display: block; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.info-panel-header-text small { + display: block; + font-size: 11px; +} + +.info-panel-body { + display: flex; + padding: 8px; + gap: 10px; +} + +.info-panel-image { + flex-shrink: 0; + width: 120px; + display: flex; + align-items: flex-start; + justify-content: center; +} + +.info-panel-image img { + width: 120px; + height: 120px; + object-fit: cover; + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.info-panel-no-image { + width: 120px; + height: 120px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-light-gray); + color: var(--text-muted); + font-size: 36px; +} + +.info-panel-content { + flex: 1; + overflow-y: auto; + min-width: 0; +} + +.info-panel-description { + font-size: 13px; + max-height: 100px; + overflow-y: auto; + margin-bottom: 8px; + line-height: 1.4; + color: var(--text-color); +} + +.info-panel-stock-table { + width: 100%; + font-size: 14px; + border-collapse: collapse; +} + +.info-panel-stock-table th { + font-weight: 600; + color: var(--text-muted); + padding: 3px 6px; + border-bottom: 1px solid var(--border-color); + text-align: left; +} + +.info-panel-stock-table td { + padding: 3px 6px; + border-bottom: 1px solid var(--border-color); +} + +.info-panel-stock-table tr:last-child td { + border-bottom: none; +} + /* Адаптация для разных размеров экрана */ @media (max-width: 1400px) { .selection-filters { - flex: 0 0 210px; + flex: 0 0 260px; } .selection-cart { diff --git a/selections/public/js/selection_dialog.js b/selections/public/js/selection_dialog.js index 101f428..c7a585b 100644 --- a/selections/public/js/selection_dialog.js +++ b/selections/public/js/selection_dialog.js @@ -15,6 +15,11 @@ class SelectionDialog { this.sort_by = 'item_name'; this.sort_order = 'asc'; this.focused_row_index = -1; + this.custom_filters = []; // [{id, field, operator, value}] + this._item_fields = []; // кэш полей Item из meta + this.info_panel_item_code = null; // текущий товар в инфо-панели + this.warehouse_field = null; + this.pricelist_field = null; this.make_dialog(); } @@ -48,23 +53,13 @@ class SelectionDialog {
| ${__('Warehouse')} | +${__('Available')} | +
|---|---|
| ${row.warehouse} | +${me.format_qty(row.available_qty)} | +