diff --git a/Screenshot_20260218_155218.png b/Screenshot_20260218_155218.png new file mode 100644 index 0000000..8dd5343 Binary files /dev/null and b/Screenshot_20260218_155218.png differ diff --git a/Screenshot_20260218_160142.png b/Screenshot_20260218_160142.png new file mode 100644 index 0000000..9fcbe58 Binary files /dev/null and b/Screenshot_20260218_160142.png differ diff --git a/Screenshot_20260218_161151.png b/Screenshot_20260218_161151.png new file mode 100644 index 0000000..e422ab0 Binary files /dev/null and b/Screenshot_20260218_161151.png differ diff --git a/Screenshot_20260218_161531.png b/Screenshot_20260218_161531.png new file mode 100644 index 0000000..092ae51 Binary files /dev/null and b/Screenshot_20260218_161531.png differ diff --git a/Screenshot_20260218_162613.png b/Screenshot_20260218_162613.png new file mode 100644 index 0000000..586de14 Binary files /dev/null and b/Screenshot_20260218_162613.png differ diff --git a/Screenshot_20260218_171933.png b/Screenshot_20260218_171933.png new file mode 100644 index 0000000..40cab77 Binary files /dev/null and b/Screenshot_20260218_171933.png differ diff --git a/selections/api.py b/selections/api.py index 54083b1..b7a31df 100644 --- a/selections/api.py +++ b/selections/api.py @@ -204,7 +204,10 @@ def _apply_custom_filters(query, Item, custom_filters): query с применёнными фильтрами """ item_meta = frappe.get_meta('Item') - valid_fields = {f.fieldname for f in item_meta.fields} + valid_fields = {f.fieldname: f for f in item_meta.fields} + + # Для этих типов значение в БД может содержать HTML-обёртку — точное совпадение бессмысленно + text_like_types = {'Small Text', 'Text', 'Long Text', 'Text Editor'} for cf in custom_filters: field = cstr(cf.get('field', '')).strip() @@ -214,8 +217,26 @@ def _apply_custom_filters(query, Item, custom_filters): if not field or field not in valid_fields: continue + field_meta = valid_fields[field] item_field = Item[field] + # Check: значение приходит как 'True'/'False', преобразуем в 1/0 + if field_meta.fieldtype == 'Check': + if value == 'True': + value = 1 + elif value == 'False': + value = 0 + else: + continue # пустой выбор — пропускаем фильтр + + # Для текстовых HTML-полей = и != заменяем на LIKE / NOT LIKE + if field_meta.fieldtype in text_like_types and operator in ('=', '!='): + if operator == '=': + query = query.where(item_field.like(f'%{value}%')) + else: + query = query.where(~item_field.like(f'%{value}%')) + continue + if operator == '=': query = query.where(item_field == value) elif operator == '!=': diff --git a/selections/public/css/selection_dialog.css b/selections/public/css/selection_dialog.css index 201c617..2d105ed 100644 --- a/selections/public/css/selection_dialog.css +++ b/selections/public/css/selection_dialog.css @@ -18,7 +18,7 @@ .selection-dialog-wrapper .modal-body { padding: 15px !important; - overflow: hidden !important; + overflow: clip !important; flex: 1 !important; } @@ -36,7 +36,7 @@ /* 3-колоночный layout */ .selection-dialog-content { display: flex; - gap: 20px; + gap: 0; height: calc(100vh - 130px); min-height: 500px; } @@ -44,8 +44,7 @@ /* Левая колонка: Фильтры */ .selection-filters { flex: 0 0 300px; - border-right: 1px solid var(--border-color); - padding-right: 15px; + padding-right: 12px; display: flex; flex-direction: column; overflow-y: auto; @@ -139,40 +138,85 @@ min-width: 0; } -/* Тулбар поиска (заменяет панель сортировки) */ -.selection-sort-toolbar { +/* ── Поиск в левой панели ─────────────────────────────────────────────── */ +.selection-search-group { + flex-shrink: 0; +} + +/* Убираем margin-bottom у frappe-control внутри поиска */ +.selection-search-group .frappe-control, +.selection-search-group .form-group { + margin-bottom: 0; +} + +/* Иконка поиска для левой панели */ +.selection-search-group .form-group { + position: relative; +} + +.selection-search-group .form-group::before { + content: '\f002'; + font-family: FontAwesome; + position: absolute; + left: 9px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + z-index: 1; + pointer-events: none; + font-size: 13px; +} + +.selection-search-group .form-control { + padding-left: 30px; +} + +/* ── Панель кастомных фильтров над таблицей ───────────────────────────── */ +.selection-filter-bar { display: flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - background-color: var(--bg-light-gray); + flex-direction: column; + gap: 4px; + padding: 8px 10px; + background-color: var(--card-bg); border: 1px solid var(--border-color); border-radius: var(--border-radius); margin-bottom: 8px; flex-shrink: 0; + position: relative; + z-index: 200; + overflow: visible; } -.selection-search-icon { - color: var(--text-muted); - flex-shrink: 0; +/* Awesomplete dropdown должен отображаться поверх таблицы товаров */ +.selection-filter-bar .awesomplete > ul { + z-index: 1060; + position: absolute; } -.selection-search-input-toolbar { - flex: 1; - font-size: 13px; - height: 30px; - padding: 4px 8px; +.selection-filter-bar #custom-filters-list:empty { + display: none; +} + +.selection-filter-bar .btn-add-custom-filter { + align-self: flex-start; + margin-top: 2px; +} + +/* Убираем лишние отступы у frappe-control внутри панели фильтров */ +.selection-filter-bar .frappe-control, +.selection-filter-bar .form-group { + margin-bottom: 0; } /* Ручки resize для панелей */ .resize-handle { - flex: 0 0 6px; + flex: 0 0 5px; cursor: col-resize; background: var(--border-color); - border-radius: 3px; transition: background 0.15s; align-self: stretch; - margin: 0 2px; + touch-action: none; + user-select: none; } .resize-handle:hover, @@ -398,8 +442,7 @@ body.selection-resizing * { /* Правая колонка: Корзина */ .selection-cart { flex: 0 0 290px; - border-left: 1px solid var(--border-color); - padding-left: 15px; + padding-left: 12px; display: flex; flex-direction: column; } @@ -549,66 +592,47 @@ body.selection-resizing * { .custom-filter-row { display: flex; - gap: 4px; + gap: 6px; align-items: center; - margin-bottom: 5px; + margin-bottom: 4px; } -.cf-field { - flex: 1.5; - font-size: 12px; - padding: 3px 6px; - height: auto; +/* Только flex-размеры — внешний вид полностью от Frappe */ +.cf-field-wrap { + flex: 2; min-width: 0; } -.cf-operator { - flex: 1; - font-size: 12px; - padding: 3px 6px; - height: auto; +.cf-operator-wrap { + flex: 0 0 100px; min-width: 0; } -.cf-value { - flex: 1.5; - font-size: 12px; - padding: 3px 6px; - height: auto; +.cf-value-wrap { + flex: 2; min-width: 0; } +.cf-field-wrap .frappe-control, +.cf-operator-wrap .frappe-control, +.cf-value-wrap .frappe-control, +.cf-field-wrap .form-group, +.cf-operator-wrap .form-group, +.cf-value-wrap .form-group { + margin-bottom: 0; +} + .cf-remove { flex-shrink: 0; padding: 2px 6px; } .btn-add-custom-filter { - font-size: 12px; - margin-top: 4px; + font-size: 13px; + margin-top: 2px; } -/* 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-контролы для склада и прайса в левой панели */ +/* Link-контролы для склада и прайса */ #selection-warehouse-link .frappe-control, #selection-pricelist-link .frappe-control { margin-bottom: 0; diff --git a/selections/public/js/selection_dialog.js b/selections/public/js/selection_dialog.js index c7a585b..6dc5c72 100644 --- a/selections/public/js/selection_dialog.js +++ b/selections/public/js/selection_dialog.js @@ -51,8 +51,11 @@ class SelectionDialog { get_dialog_html() { return `
- +
+
+
+
@@ -78,13 +81,6 @@ class SelectionDialog {
-
- -
- -
@@ -204,10 +201,11 @@ class SelectionDialog { frappeControl.style.cssText = 'width: 100% !important; max-width: none !important;'; } - me.setup_resizable_panels(); + this.setup_resizable_panels(); }, 50); this.setup_handlers(); + this.setup_search_field(); this.setup_custom_filters(); this.setup_warehouse_field(); this.setup_pricelist_field(); @@ -218,17 +216,7 @@ class SelectionDialog { setup_handlers() { const me = this; - // Поиск с debounce - let search_timeout; - this.dialog.$wrapper.find('#selection-search-input').on('input', function() { - clearTimeout(search_timeout); - const search_term = $(this).val(); - search_timeout = setTimeout(function() { - me.filters.search_term = search_term; - me.current_page = 0; - me.load_items(); - }, 500); - }); + // Поиск — обработчик вешается в setup_search_field() // Фильтр "Только в наличии" this.dialog.$wrapper.find('#selection-only-in-stock').on('change', function() { @@ -247,7 +235,7 @@ class SelectionDialog { me.sort_by = 'item_name'; me.sort_order = 'asc'; me.current_page = 0; - me.dialog.$wrapper.find('#selection-search-input').val(''); + if (me.search_field) me.search_field.set_value(''); 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); @@ -307,34 +295,43 @@ class SelectionDialog { setup_resizable_panels() { const me = this; - const $filters = me.dialog.$wrapper.find('.selection-filters'); - const $cart = me.dialog.$wrapper.find('.selection-cart'); + const filters_el = me.dialog.$wrapper.find('.selection-filters')[0]; + const cart_el = me.dialog.$wrapper.find('.selection-cart')[0]; - function make_resizable(handle_id, $panel, direction) { - me.dialog.$wrapper.find(handle_id).on('mousedown', function(e) { + function make_resizable(handle_selector, panel_el, direction) { + const handle_el = me.dialog.$wrapper.find(handle_selector)[0]; + if (!handle_el || !panel_el) return; + + // pointerdown + setPointerCapture: все события указателя идут на handle_el + // даже когда мышь ушла за его пределы — стандартный drag API + handle_el.addEventListener('pointerdown', function(e) { e.preventDefault(); - const startX = e.clientX; - const startWidth = $panel[0].getBoundingClientRect().width; + handle_el.setPointerCapture(e.pointerId); + document.body.classList.add('selection-resizing'); - $('body').addClass('selection-resizing'); + let currentWidth = panel_el.getBoundingClientRect().width; - $(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`); - }); + function on_move(ev) { + // movementX — готовая дельта от предыдущего события, без ручного расчёта + const delta = direction === 'right' ? ev.movementX : -ev.movementX; + currentWidth = Math.max(180, Math.min(500, currentWidth + delta)); + panel_el.style.flex = `0 0 ${currentWidth}px`; + } - $(document).on('mouseup.sel-resize', function() { - $(document).off('mousemove.sel-resize mouseup.sel-resize'); - $('body').removeClass('selection-resizing'); - }); + function on_up() { + handle_el.removeEventListener('pointermove', on_move); + handle_el.removeEventListener('pointerup', on_up); + document.body.classList.remove('selection-resizing'); + } + + // Слушаем на самом handle_el (после capture все события придут сюда) + handle_el.addEventListener('pointermove', on_move); + handle_el.addEventListener('pointerup', on_up); }); } - make_resizable('#resize-handle-left', $filters, 'right'); - make_resizable('#resize-handle-right', $cart, 'left'); + make_resizable('#resize-handle-left', filters_el, 'right'); + make_resizable('#resize-handle-right', cart_el, 'left'); } _update_stock_toggle_state() { @@ -406,6 +403,41 @@ class SelectionDialog { } } + setup_search_field() { + const me = this; + let search_timeout; + this.search_field = frappe.ui.form.make_control({ + df: { + fieldtype: 'Data', + fieldname: 'selection_search', + placeholder: __('Search by code or name...'), + }, + parent: me.dialog.$wrapper.find('#selection-search-control')[0], + render_input: true, + }); + this.search_field.$input.on('input', function() { + clearTimeout(search_timeout); + const val = $(this).val(); + search_timeout = setTimeout(() => { + me.filters.search_term = val; + me.current_page = 0; + me.load_items(); + }, 500); + }); + + this.search_field.$input.on('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + clearTimeout(search_timeout); + const val = $(this).val(); + me.filters.search_term = val; + me.current_page = 0; + me.load_items(); + } + }); + } + setup_warehouse_field() { const me = this; this.warehouse_field = frappe.ui.form.make_control({ @@ -716,7 +748,11 @@ class SelectionDialog { // Загружаем мету Item для списка полей frappe.model.with_doctype('Item', function() { const meta = frappe.get_meta('Item'); - const excluded_types = ['Section Break', 'Column Break', 'HTML', 'Button', 'Tab Break']; + const excluded_types = [ + 'Section Break', 'Column Break', 'HTML', 'Button', 'Tab Break', + 'Table', 'Table MultiSelect', + 'Image', 'Attach', 'Attach Image' + ]; me._item_fields = meta.fields .filter(f => !excluded_types.includes(f.fieldtype) && f.fieldname) .sort((a, b) => (a.label || a.fieldname).localeCompare(b.label || b.fieldname)); @@ -743,48 +779,56 @@ class SelectionDialog { $list.empty(); this.custom_filters.forEach(cf => { - // Опции полей - let field_options = ``; - 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} +
+
+
`); + $list.append($row); - // Изменение поля → сбрасываем оператор и значение - $row.find('.cf-field').on('change', function() { + // ── 1. Контрол выбора поля (Select через make_control) ────────── + const field_options_str = [''].concat( + me._item_fields.map(f => f.label || f.fieldname) + ).join('\n'); + + // Маппинг label → fieldname для обратного поиска + const label_to_fieldname = {}; + me._item_fields.forEach(f => { + label_to_fieldname[f.label || f.fieldname] = f.fieldname; + }); + const fieldname_to_label = {}; + me._item_fields.forEach(f => { + fieldname_to_label[f.fieldname] = f.label || f.fieldname; + }); + + const field_ctrl = frappe.ui.form.make_control({ + df: { + fieldtype: 'Select', + fieldname: `cf_field_${cf.id}`, + options: field_options_str, + placeholder: __('Select field...'), + }, + parent: $row.find('.cf-field-wrap')[0], + render_input: true, + }); + field_ctrl.set_value(fieldname_to_label[cf.field] || ''); + + field_ctrl.$input.on('change', function() { + const label = $(this).val(); + const fieldname = label_to_fieldname[label] || ''; 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].field = fieldname; me.custom_filters[idx].operator = '='; me.custom_filters[idx].value = ''; } @@ -793,70 +837,125 @@ class SelectionDialog { me.load_items(); }); - // Изменение оператора - $row.find('.cf-operator').on('change', function() { + // ── 2. Контрол оператора (Select) ─────────────────────────────── + const operators = me.get_operators_for_fieldtype(fieldtype); + const op_options_str = operators.map(o => o.label).join('\n'); + const op_label_to_value = {}; + const op_value_to_label = {}; + operators.forEach(o => { + op_label_to_value[o.label] = o.value; + op_value_to_label[o.value] = o.label; + }); + + const op_ctrl = frappe.ui.form.make_control({ + df: { + fieldtype: 'Select', + fieldname: `cf_op_${cf.id}`, + options: op_options_str, + }, + parent: $row.find('.cf-operator-wrap')[0], + render_input: true, + }); + op_ctrl.set_value(op_value_to_label[cf.operator] || operators[0].label); + + op_ctrl.$input.on('change', function() { + const op_value = op_label_to_value[$(this).val()] || '='; const idx = me.custom_filters.findIndex(x => x.id === cf.id); - if (idx !== -1) { - me.custom_filters[idx].operator = $(this).val(); - } + if (idx !== -1) me.custom_filters[idx].operator = op_value; 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); - }); + // ── 3. Контрол значения (make_control по типу поля) ───────────── + const $val_wrap = $row.find('.cf-value-wrap'); - // Удаление строки + if (no_value_ops.includes(cf.operator)) { + // Заглушка — поле отключено + const stub = frappe.ui.form.make_control({ + df: { fieldtype: 'Data', fieldname: `cf_val_${cf.id}`, read_only: 1 }, + parent: $val_wrap[0], + render_input: true, + }); + stub.set_value(''); + } else if (selected_meta) { + // Текстовые поля рендерим как Data — textarea/rich-редактор не нужны для фильтра + const text_like = ['Small Text', 'Text', 'Long Text', 'Text Editor']; + let filter_fieldtype = text_like.includes(selected_meta.fieldtype) + ? 'Data' + : selected_meta.fieldtype; + let filter_options = selected_meta.options || ''; + + // Check рендерим как Select True/False — чтобы make_control создал + // идентичную DOM-структуру с другими контролами и не было смещения + let display_value = cf.value || ''; + if (selected_meta.fieldtype === 'Check') { + filter_fieldtype = 'Select'; + filter_options = '\nTrue\nFalse'; + if (cf.value === '1') display_value = 'True'; + else if (cf.value === '0') display_value = 'False'; + else display_value = ''; + } + + const val_df = { + fieldtype: filter_fieldtype, + fieldname: `cf_val_${cf.id}`, + options: filter_options, + onchange: function() { + let val = this.get_value(); + // Обратное маппирование True/False → 1/0 для хранения + if (selected_meta.fieldtype === 'Check') { + if (val === 'True') val = '1'; + else if (val === 'False') val = '0'; + else val = ''; + } + const idx = me.custom_filters.findIndex(x => x.id === cf.id); + if (idx !== -1) { + me.custom_filters[idx].value = (val != null) ? String(val) : ''; + } + clearTimeout(me._cf_value_timeout); + me._cf_value_timeout = setTimeout(() => { + me.current_page = 0; + me.load_items(); + }, 300); + } + }; + + const val_ctrl = frappe.ui.form.make_control({ + df: val_df, + parent: $val_wrap[0], + render_input: true, + }); + val_ctrl.set_value(display_value); + + // Для Data/Text/Int/Float — дополнительный real-time слушатель + if (!['Link', 'Select', 'Check'].includes(selected_meta.fieldtype)) { + val_ctrl.$input.on('input', function() { + const idx = me.custom_filters.findIndex(x => x.id === cf.id); + if (idx !== -1) me.custom_filters[idx].value = $(this).val() || ''; + clearTimeout(me._cf_value_timeout); + me._cf_value_timeout = setTimeout(() => { + me.current_page = 0; + me.load_items(); + }, 300); + }); + } + } else { + // Поле ещё не выбрано — пустой Data-контрол + frappe.ui.form.make_control({ + df: { fieldtype: 'Data', fieldname: `cf_val_${cf.id}` }, + parent: $val_wrap[0], + render_input: true, + }); + } + + // ── Удаление строки ────────────────────────────────────────────── $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); - }); - } }); } @@ -893,41 +992,6 @@ class SelectionDialog { 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) {