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 {
-
- - -
- - ${__('For stock levels')} +
- - ${__('For item prices')} +
@@ -83,6 +78,13 @@ class SelectionDialog {
+
+ +
+ +
+
+
- ${__('Sort by')}: -
- - - -
+ +
@@ -120,8 +116,33 @@ class SelectionDialog { ${__('Next')}
+
+
+
@@ -182,11 +203,14 @@ class SelectionDialog { if (frappeControl) { frappeControl.style.cssText = 'width: 100% !important; max-width: none !important;'; } + + me.setup_resizable_panels(); }, 50); this.setup_handlers(); - this.load_warehouses(); - this.load_price_lists(); + this.setup_custom_filters(); + this.setup_warehouse_field(); + this.setup_pricelist_field(); this.load_item_groups(); this.load_items(); } @@ -206,21 +230,6 @@ class SelectionDialog { }, 500); }); - // Выбор склада - this.dialog.$wrapper.find('#selection-warehouse-select').on('change', function() { - me.filters.warehouse = $(this).val(); - me.current_page = 0; - me._update_stock_toggle_state(); - me.load_items(); - }); - - // Выбор прайс-листа - this.dialog.$wrapper.find('#selection-pricelist-select').on('change', function() { - me.filters.price_list = $(this).val(); - me.current_page = 0; - me.load_items(); - }); - // Фильтр "Только в наличии" this.dialog.$wrapper.find('#selection-only-in-stock').on('change', function() { if (!me.filters.warehouse) { @@ -232,34 +241,6 @@ class SelectionDialog { me.load_items(); }); - // Кнопки сортировки - this.dialog.$wrapper.find('.sort-btn').on('click', function() { - const $btn = $(this); - const sort_by = $btn.data('sort'); - const current_order = $btn.data('order'); - - if (me.sort_by === sort_by) { - // Переключаем направление - me.sort_order = current_order === 'asc' ? 'desc' : 'asc'; - $btn.data('order', me.sort_order); - } else { - me.sort_by = sort_by; - me.sort_order = 'asc'; - $btn.data('order', 'asc'); - } - - // Обновляем иконки - me.dialog.$wrapper.find('.sort-btn').removeClass('active').each(function() { - $(this).find('i').attr('class', 'fa fa-sort'); - }); - $btn.addClass('active'); - const icon_class = me.sort_order === 'asc' ? `fa-sort-${sort_by === 'item_name' ? 'alpha' : 'amount'}-asc` : `fa-sort-${sort_by === 'item_name' ? 'alpha' : 'amount'}-desc`; - $btn.find('i').attr('class', `fa ${icon_class}`); - - me.current_page = 0; - me.load_items(); - }); - // Очистить фильтры this.dialog.$wrapper.find('#selection-clear-filters').on('click', function() { me.filters = {}; @@ -267,12 +248,17 @@ class SelectionDialog { me.sort_order = 'asc'; me.current_page = 0; me.dialog.$wrapper.find('#selection-search-input').val(''); - me.dialog.$wrapper.find('#selection-warehouse-select').val(''); - me.dialog.$wrapper.find('#selection-pricelist-select').val(''); + if (me.warehouse_field) me.warehouse_field.set_value(''); + if (me.pricelist_field) me.pricelist_field.set_value(''); me.dialog.$wrapper.find('#selection-only-in-stock').prop('checked', false); me.dialog.$wrapper.find('.item-group-tree .active').removeClass('active'); - me.dialog.$wrapper.find('.sort-btn').removeClass('active').eq(0).addClass('active'); me._update_stock_toggle_state(); + // Очищаем произвольные фильтры + me.custom_filters = []; + me.dialog.$wrapper.find('#custom-filters-list').empty(); + // Скрываем инфо-панель + me.dialog.$wrapper.find('#selection-info-panel').hide(); + me.info_panel_item_code = null; me.load_items(); }); @@ -311,6 +297,44 @@ class SelectionDialog { me.dialog.$wrapper.find('.stock-warehouses-tooltip').hide(); } }); + + // Закрыть инфо-панель + this.dialog.$wrapper.on('click', '#info-panel-close', function() { + me.dialog.$wrapper.find('#selection-info-panel').hide(); + me.info_panel_item_code = null; + }); + } + + setup_resizable_panels() { + const me = this; + const $filters = me.dialog.$wrapper.find('.selection-filters'); + const $cart = me.dialog.$wrapper.find('.selection-cart'); + + function make_resizable(handle_id, $panel, direction) { + me.dialog.$wrapper.find(handle_id).on('mousedown', function(e) { + e.preventDefault(); + const startX = e.clientX; + const startWidth = $panel[0].getBoundingClientRect().width; + + $('body').addClass('selection-resizing'); + + $(document).on('mousemove.sel-resize', function(e) { + const delta = direction === 'right' + ? e.clientX - startX + : startX - e.clientX; + const newWidth = Math.max(180, Math.min(500, startWidth + delta)); + $panel.css('flex', `0 0 ${newWidth}px`); + }); + + $(document).on('mouseup.sel-resize', function() { + $(document).off('mousemove.sel-resize mouseup.sel-resize'); + $('body').removeClass('selection-resizing'); + }); + }); + } + + make_resizable('#resize-handle-left', $filters, 'right'); + make_resizable('#resize-handle-right', $cart, 'left'); } _update_stock_toggle_state() { @@ -382,77 +406,56 @@ class SelectionDialog { } } - load_warehouses() { + setup_warehouse_field() { const me = this; - - frappe.call({ - method: 'frappe.client.get_list', - args: { - doctype: 'Warehouse', - fields: ['name'], - filters: { - 'is_group': 0, - 'disabled': 0 - }, - order_by: 'name asc', - limit_page_length: 0 - }, - callback: function(r) { - if (r.message) { - const $select = me.dialog.$wrapper.find('#selection-warehouse-select'); - - r.message.forEach(function(warehouse) { - $select.append($('`; + me._item_fields.forEach(f => { + const selected = cf.field === f.fieldname ? 'selected' : ''; + const label = f.label || f.fieldname; + field_options += ``; + }); + + // Определяем тип поля выбранного поля + const selected_meta = me._item_fields.find(f => f.fieldname === cf.field); + const fieldtype = selected_meta ? selected_meta.fieldtype : 'Data'; + + // Опции операторов + const operators = me.get_operators_for_fieldtype(fieldtype); + let op_options = ''; + operators.forEach(op => { + const selected = cf.operator === op.value ? 'selected' : ''; + op_options += ``; + }); + + // Инпут значения + const no_value_ops = ['is set', 'is not set']; + const value_html = no_value_ops.includes(cf.operator) + ? `` + : me.get_value_input_html(selected_meta, cf.value); + + const $row = $(` +
+ + + ${value_html} + +
+ `); + + // Изменение поля → сбрасываем оператор и значение + $row.find('.cf-field').on('change', function() { + const idx = me.custom_filters.findIndex(x => x.id === cf.id); + if (idx !== -1) { + me.custom_filters[idx].field = $(this).val(); + me.custom_filters[idx].operator = '='; + me.custom_filters[idx].value = ''; + } + me.render_custom_filter_rows(); + me.current_page = 0; + me.load_items(); + }); + + // Изменение оператора + $row.find('.cf-operator').on('change', function() { + const idx = me.custom_filters.findIndex(x => x.id === cf.id); + if (idx !== -1) { + me.custom_filters[idx].operator = $(this).val(); + } + me.render_custom_filter_rows(); + me.current_page = 0; + me.load_items(); + }); + + // Изменение значения (только для обычных инпутов — не Link-обёрток) + $row.find('.cf-value').on('change input', function() { + if ($(this).hasClass('cf-value-link-wrapper')) return; + const idx = me.custom_filters.findIndex(x => x.id === cf.id); + if (idx !== -1) { + me.custom_filters[idx].value = $(this).val(); + } + // debounce через timeout + clearTimeout(me._cf_value_timeout); + me._cf_value_timeout = setTimeout(() => { + me.current_page = 0; + me.load_items(); + }, 500); + }); + + // Удаление строки + $row.find('.cf-remove').on('click', function() { + me.custom_filters = me.custom_filters.filter(x => x.id !== cf.id); + me.render_custom_filter_rows(); + me.current_page = 0; + me.load_items(); + }); + + $list.append($row); + + // Если поле Link — создаём ControlLink в контейнере (после append в DOM) + if (selected_meta && selected_meta.fieldtype === 'Link' && !no_value_ops.includes(cf.operator)) { + const link_container = $row.find('.cf-value-link-wrapper')[0]; + const link_ctrl = frappe.ui.form.make_control({ + df: { + fieldtype: 'Link', + options: selected_meta.options, + fieldname: `cf_link_${cf.id}`, + only_select: false, + }, + parent: link_container, + render_input: true, + }); + link_ctrl.set_value(cf.value || ''); + $row.data('link-ctrl', link_ctrl); + + link_ctrl.$input.on('change', function() { + const idx = me.custom_filters.findIndex(x => x.id === cf.id); + if (idx !== -1) { + me.custom_filters[idx].value = link_ctrl.get_value() || ''; + } + clearTimeout(me._cf_value_timeout); + me._cf_value_timeout = setTimeout(() => { + me.current_page = 0; + me.load_items(); + }, 500); + }); + } + }); + } + + get_operators_for_fieldtype(fieldtype) { + const text_ops = [ + { value: '=', label: '=' }, + { value: '!=', label: '≠' }, + { value: 'like', label: __('contains') }, + { value: 'not like', label: __('not contains') }, + { value: 'is set', label: __('is set') }, + { value: 'is not set', label: __('not set') } + ]; + const num_ops = [ + { value: '=', label: '=' }, + { value: '!=', label: '≠' }, + { value: '>', label: '>' }, + { value: '<', label: '<' }, + { value: '>=', label: '≥' }, + { value: '<=', label: '≤' }, + { value: 'is set', label: __('is set') }, + { value: 'is not set', label: __('not set') } + ]; + const eq_ops = [ + { value: '=', label: '=' }, + { value: '!=', label: '≠' } + ]; + + const num_types = ['Int', 'Float', 'Currency', 'Percent']; + const eq_types = ['Select', 'Check']; + const date_types = ['Date', 'Datetime']; + + if (num_types.includes(fieldtype) || date_types.includes(fieldtype)) return num_ops; + if (eq_types.includes(fieldtype)) return eq_ops; + return text_ops; + } + + get_value_input_html(field_meta, current_value) { + const val = current_value || ''; + if (!field_meta) { + return ``; + } + if (field_meta.fieldtype === 'Link') { + // Контейнер — ControlLink создаётся после append в DOM в render_custom_filter_rows + return ``; + } + if (field_meta.fieldtype === 'Select') { + const options = (field_meta.options || '').split('\n'); + let opts = ``; + options.forEach(opt => { + if (opt) { + const selected = val === opt ? 'selected' : ''; + opts += ``; + } + }); + return ``; + } + if (field_meta.fieldtype === 'Check') { + return ` + + `; + } + if (field_meta.fieldtype === 'Date') { + return ``; + } + return ``; + } + + // ── Инфо-панель ───────────────────────────────────────────────────────── + + show_item_info(index) { + const item = this.items_data[index]; + if (!item) return; + + const $panel = this.dialog.$wrapper.find('#selection-info-panel'); + + // Заголовок + $panel.find('#info-panel-title').text(item.item_name); + $panel.find('#info-panel-subtitle').text( + item.item_code + (item.item_group ? ' · ' + item.item_group : '') + ); + + // Изображение + if (item.image) { + $panel.find('#info-panel-img').attr('src', item.image).show(); + $panel.find('#info-panel-no-img').hide(); + } else { + $panel.find('#info-panel-img').hide(); + $panel.find('#info-panel-no-img').show(); + } + + // Описание + $panel.find('#info-panel-description').html( + item.description || `${__('No description')}` + ); + + // Остатки по складам — загружаем если сменился товар + if (this.info_panel_item_code !== item.item_code) { + this.info_panel_item_code = item.item_code; + $panel.find('#info-panel-stock').html(``); + + const me = this; + frappe.call({ + method: 'selections.api.get_stock_across_warehouses', + args: { item_code: item.item_code }, + callback: function(r) { + me._render_info_panel_stock(r.message || []); + } + }); + } + + $panel.show(); + } + + _render_info_panel_stock(stock_rows) { + const me = this; + const $stock = this.dialog.$wrapper.find('#info-panel-stock'); + + if (!stock_rows || stock_rows.length === 0) { + $stock.html(`${__('No stock data')}`); + return; + } + + let html = ` + + + + `; + stock_rows.forEach(row => { + const cls = me.get_stock_indicator_class(row.available_qty); + html += ` + + + `; + }); + html += '
${__('Warehouse')}${__('Available')}
${row.warehouse}${me.format_qty(row.available_qty)}
'; + $stock.html(html); + } + _sync_sort_buttons() { const me = this; this.dialog.$wrapper.find('.sort-btn').each(function() {