minor fixes and filter added

This commit is contained in:
Ali 2026-02-17 20:54:51 +04:00
parent 410216992e
commit 192a449741
3 changed files with 709 additions and 166 deletions

View File

@ -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):
"""

View File

@ -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 {

View File

@ -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 {
<div class="selection-dialog-content">
<!-- Left column: Filters -->
<div class="selection-filters">
<div class="filter-group">
<label>${__('Search')}</label>
<input type="text" class="form-control" id="selection-search-input" placeholder="${__('Item code or name...')}">
</div>
<div class="filter-group">
<label>${__('Warehouse')}</label>
<select class="form-control" id="selection-warehouse-select">
<option value="" disabled>${__('Select Warehouse...')}</option>
</select>
<small class="text-muted">${__('For stock levels')}</small>
<div id="selection-warehouse-link"></div>
</div>
<div class="filter-group">
<label>${__('Price List')}</label>
<select class="form-control" id="selection-pricelist-select">
<option value="" disabled>${__('Select Price List...')}</option>
</select>
<small class="text-muted">${__('For item prices')}</small>
<div id="selection-pricelist-link"></div>
</div>
<div class="filter-group">
<div class="selection-stock-toggle">
@ -83,6 +78,13 @@ class SelectionDialog {
<!-- Tree will be loaded dynamically -->
</div>
</div>
<div class="filter-group" id="custom-filters-section">
<label>${__('Additional Filters')}</label>
<div id="custom-filters-list"></div>
<button class="btn btn-xs btn-default btn-block btn-add-custom-filter" id="selection-add-custom-filter">
<i class="fa fa-plus"></i> ${__('Add Filter')}
</button>
</div>
<div class="filter-group">
<button class="btn btn-sm btn-default btn-block" id="selection-clear-filters">
${__('Clear Filters')}
@ -90,21 +92,15 @@ class SelectionDialog {
</div>
</div>
<div class="resize-handle" id="resize-handle-left"></div>
<!-- Middle column: Items -->
<div class="selection-items-column">
<div class="selection-sort-toolbar">
<span class="sort-label">${__('Sort by')}:</span>
<div class="sort-buttons">
<button class="btn btn-xs sort-btn active" data-sort="item_name" data-order="asc">
${__('Name')} <i class="fa fa-sort-alpha-asc"></i>
</button>
<button class="btn btn-xs sort-btn" data-sort="available_qty" data-order="asc">
${__('Stock')} <i class="fa fa-sort"></i>
</button>
<button class="btn btn-xs sort-btn" data-sort="rate" data-order="asc">
${__('Price')} <i class="fa fa-sort"></i>
</button>
</div>
<i class="fa fa-search selection-search-icon"></i>
<input type="text" class="form-control selection-search-input-toolbar"
id="selection-search-input"
placeholder="${__('Search by code or name...')}">
</div>
<div class="selection-items-grid" id="selection-items-grid">
<div class="text-center text-muted" style="padding: 40px;">
@ -120,8 +116,33 @@ class SelectionDialog {
${__('Next')} <i class="fa fa-chevron-right"></i>
</button>
</div>
<div class="selection-info-panel" id="selection-info-panel" style="display:none;">
<div class="info-panel-header">
<div class="info-panel-header-text">
<strong id="info-panel-title"></strong>
<small class="text-muted" id="info-panel-subtitle"></small>
</div>
<button class="btn btn-xs btn-default info-panel-close" id="info-panel-close">
<i class="fa fa-times"></i>
</button>
</div>
<div class="info-panel-body">
<div class="info-panel-image" id="info-panel-image-wrap">
<img id="info-panel-img" src="" alt="" style="display:none;">
<div class="info-panel-no-image" id="info-panel-no-img">
<i class="fa fa-cube"></i>
</div>
</div>
<div class="info-panel-content">
<div class="info-panel-description" id="info-panel-description"></div>
<div class="info-panel-stock" id="info-panel-stock"></div>
</div>
</div>
</div>
</div>
<div class="resize-handle" id="resize-handle-right"></div>
<!-- Right column: Cart -->
<div class="selection-cart">
<div class="cart-header">
@ -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($('<option>', {
value: warehouse.name,
text: warehouse.name
}));
});
const default_warehouse = me.frm.doc.set_warehouse || '';
if (default_warehouse) {
$select.val(default_warehouse);
me.filters.warehouse = default_warehouse;
me._update_stock_toggle_state();
}
this.warehouse_field = frappe.ui.form.make_control({
df: {
fieldtype: 'Link',
options: 'Warehouse',
placeholder: __('Select Warehouse...'),
filters: { is_group: 0, disabled: 0 },
onchange: function() {
me.filters.warehouse = this.value || '';
me.current_page = 0;
me._update_stock_toggle_state();
me.load_items();
}
}
},
parent: me.dialog.$wrapper.find('#selection-warehouse-link')[0],
render_input: true,
});
const default_warehouse = me.frm.doc.set_warehouse || '';
if (default_warehouse) {
me.warehouse_field.set_value(default_warehouse);
me.filters.warehouse = default_warehouse;
me._update_stock_toggle_state();
}
}
load_price_lists() {
setup_pricelist_field() {
const me = this;
frappe.call({
method: 'frappe.client.get_list',
args: {
doctype: 'Price List',
fields: ['name'],
filters: {
'selling': 1,
'enabled': 1
},
order_by: 'name asc',
limit_page_length: 0
},
callback: function(r) {
if (r.message) {
const $select = me.dialog.$wrapper.find('#selection-pricelist-select');
r.message.forEach(function(price_list) {
$select.append($('<option>', {
value: price_list.name,
text: price_list.name
}));
});
const default_price_list = me.frm.doc.selling_price_list || '';
if (default_price_list) {
$select.val(default_price_list);
me.filters.price_list = default_price_list;
}
this.pricelist_field = frappe.ui.form.make_control({
df: {
fieldtype: 'Link',
options: 'Price List',
placeholder: __('Select Price List...'),
filters: { selling: 1, enabled: 1 },
onchange: function() {
me.filters.price_list = this.value || '';
me.current_page = 0;
me.load_items();
}
}
},
parent: me.dialog.$wrapper.find('#selection-pricelist-link')[0],
render_input: true,
});
const default_price_list = me.frm.doc.selling_price_list || '';
if (default_price_list) {
me.pricelist_field.set_value(default_price_list);
me.filters.price_list = default_price_list;
}
}
load_item_groups() {
@ -538,7 +541,10 @@ class SelectionDialog {
args: {
filters: filters,
start: start,
page_length: this.page_length
page_length: this.page_length,
custom_filters: this.custom_filters.filter(cf =>
cf.field && (cf.operator === 'is set' || cf.operator === 'is not set' || cf.value !== '')
)
},
callback: function(r) {
if (r.message) {
@ -673,11 +679,12 @@ class SelectionDialog {
}, 600);
});
// Клик по строке для навигации
// Клик по строке для навигации и инфо-панели
$grid.find('tbody tr').on('click', function(e) {
if (!$(e.target).closest('.btn-add-to-cart, .row-qty-input, .stock-indicator').length) {
me.focused_row_index = parseInt($(this).data('item-index'));
me._update_focused_row($grid.find('tbody tr'));
me.show_item_info(me.focused_row_index);
}
});
@ -702,6 +709,296 @@ class SelectionDialog {
});
}
// ── Произвольные фильтры ────────────────────────────────────────────────
setup_custom_filters() {
const me = this;
// Загружаем мету Item для списка полей
frappe.model.with_doctype('Item', function() {
const meta = frappe.get_meta('Item');
const excluded_types = ['Section Break', 'Column Break', 'HTML', 'Button', 'Tab Break'];
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));
});
this.dialog.$wrapper.find('#selection-add-custom-filter').on('click', function() {
me.add_custom_filter_row();
});
}
add_custom_filter_row() {
this.custom_filters.push({
id: Date.now(),
field: '',
operator: '=',
value: ''
});
this.render_custom_filter_rows();
}
render_custom_filter_rows() {
const me = this;
const $list = this.dialog.$wrapper.find('#custom-filters-list');
$list.empty();
this.custom_filters.forEach(cf => {
// Опции полей
let field_options = `<option value="">${__('Select field...')}</option>`;
me._item_fields.forEach(f => {
const selected = cf.field === f.fieldname ? 'selected' : '';
const label = f.label || f.fieldname;
field_options += `<option value="${f.fieldname}" ${selected}>${label}</option>`;
});
// Определяем тип поля выбранного поля
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 += `<option value="${op.value}" ${selected}>${op.label}</option>`;
});
// Инпут значения
const no_value_ops = ['is set', 'is not set'];
const value_html = no_value_ops.includes(cf.operator)
? `<input class="form-control cf-value" disabled placeholder="" value="">`
: me.get_value_input_html(selected_meta, cf.value);
const $row = $(`
<div class="custom-filter-row" data-id="${cf.id}">
<select class="cf-field form-control">${field_options}</select>
<select class="cf-operator form-control">${op_options}</select>
${value_html}
<button class="btn btn-xs btn-default cf-remove" title="${__('Remove')}">
<i class="fa fa-times"></i>
</button>
</div>
`);
// Изменение поля → сбрасываем оператор и значение
$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 `<input type="text" class="form-control cf-value" value="${frappe.utils.escape_html(val)}">`;
}
if (field_meta.fieldtype === 'Link') {
// Контейнер — ControlLink создаётся после append в DOM в render_custom_filter_rows
return `<div class="cf-value cf-value-link-wrapper"></div>`;
}
if (field_meta.fieldtype === 'Select') {
const options = (field_meta.options || '').split('\n');
let opts = `<option value=""></option>`;
options.forEach(opt => {
if (opt) {
const selected = val === opt ? 'selected' : '';
opts += `<option value="${frappe.utils.escape_html(opt)}" ${selected}>${frappe.utils.escape_html(opt)}</option>`;
}
});
return `<select class="form-control cf-value">${opts}</select>`;
}
if (field_meta.fieldtype === 'Check') {
return `
<select class="form-control cf-value">
<option value=""></option>
<option value="1" ${val == 1 ? 'selected' : ''}>${__('Yes')}</option>
<option value="0" ${val == 0 && val !== '' ? 'selected' : ''}>${__('No')}</option>
</select>
`;
}
if (field_meta.fieldtype === 'Date') {
return `<input type="date" class="form-control cf-value" value="${frappe.utils.escape_html(val)}">`;
}
return `<input type="text" class="form-control cf-value" value="${frappe.utils.escape_html(val)}">`;
}
// ── Инфо-панель ─────────────────────────────────────────────────────────
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 || `<span class="text-muted">${__('No description')}</span>`
);
// Остатки по складам — загружаем если сменился товар
if (this.info_panel_item_code !== item.item_code) {
this.info_panel_item_code = item.item_code;
$panel.find('#info-panel-stock').html(`<i class="fa fa-spinner fa-spin"></i>`);
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(`<span class="text-muted" style="font-size:11px;">${__('No stock data')}</span>`);
return;
}
let html = `<table class="info-panel-stock-table">
<thead><tr>
<th>${__('Warehouse')}</th>
<th style="text-align:right;">${__('Available')}</th>
</tr></thead><tbody>`;
stock_rows.forEach(row => {
const cls = me.get_stock_indicator_class(row.available_qty);
html += `<tr>
<td>${row.warehouse}</td>
<td style="text-align:right;" class="${cls}">${me.format_qty(row.available_qty)}</td>
</tr>`;
});
html += '</tbody></table>';
$stock.html(html);
}
_sync_sort_buttons() {
const me = this;
this.dialog.$wrapper.find('.sort-btn').each(function() {