first commit

This commit is contained in:
Ali 2026-02-17 19:42:41 +04:00
parent 855c68b7ad
commit 410216992e
6 changed files with 2404 additions and 3 deletions

131
CLAUDE.md Normal file
View File

@ -0,0 +1,131 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
A Frappe/ERPNext app that replaces the standard "Add Multiple" button in sales documents (currently Sales Invoice) with a custom item selection dialog inspired by 1C-style item selection. The dialog provides enhanced filtering, real-time stock visibility, pricing, and a shopping cart interface.
## Development Commands
All `bench` commands run from `/home/frappe/frappe-bench`:
```bash
bench install-app selections # Install/reinstall
bench start # Development server
bench console # Python REPL with Frappe context
bench clear-cache # Required after JS/CSS/hook changes
bench migrate # Apply DB schema changes
bench --site [sitename] tail-logs # View error logs
```
### Code Quality
Pre-commit hooks (ruff, eslint, prettier, pyupgrade) — install once:
```bash
cd apps/selections && pre-commit install
```
Manual linting (from app directory):
```bash
ruff check . && ruff format .
npx eslint selections/public/js/**/*.js
npx prettier --write selections/public/js/**/*.js
```
Ruff: line length 110, tab indentation, import sorting enabled. See `pyproject.toml` for ignored rules.
## Architecture
### Key Components
**Backend API (`selections/api.py`)**:
- `get_items_for_selection(filters, start, page_length)`: Main endpoint. Fetches items with stock via LEFT JOIN on `Bin`, then enriches with prices per-item in Python.
- `get_item_groups_tree()`: Returns hierarchical item group tree for filtering. Cached in `frappe.cache()` for 5 minutes.
- `get_stock_across_warehouses(item_code)`: Returns per-warehouse stock for a single item (used for stock tooltip in dialog).
- `add_items_to_document(doctype, docname, items)`: Saves items server-side. Currently unused — the preferred flow adds items directly via JS.
**Frontend Dialog (`selections/public/js/selection_dialog.js`)**:
- `SelectionDialog` class: three-column layout (filters, items grid, cart). Exposed as `window.SelectionDialog`.
- Loaded globally via `app_include_js` in `hooks.py` — available on all desk pages.
**DocType Integration (`selections/public/js/sales_invoice.js`)**:
- Loaded per-doctype via `doctype_js` in `hooks.py`.
- On `refresh`, removes `.grid-add-multiple-rows` (Frappe's standard button) and injects a replacement button using `insertAfter` + `e.stopImmediatePropagation()` to prevent Frappe's own click handler from firing on the shared CSS class.
### Data Flow
1. User clicks "Add Multiple" in Sales Invoice → `SelectionDialog` instantiated
2. Dialog fetches warehouses, price lists, item groups via API
3. User filters → `get_items_for_selection()` queries DB and returns paginated results
4. Cart state managed client-side
5. Finalize → items added to form via `frappe.model.add_child()`, then `frm.doc.set_missing_values()` fills taxes/prices
### Price Lookup Chain
`get_price_data()` in `api.py` attempts pricing in this order:
1. Customer-specific `Item Price` record
2. General `Item Price` record (no customer)
3. ERPNext's `get_item_details()` for pricing rules/discounts (falls back gracefully on exception)
### Sorting Behavior
- Sort by `item_name` or `available_qty`: done at SQL level (works correctly across pages)
- Sort by `rate`: done in Python on the current page only — price data is fetched per-item after the SQL query
### Frappe Framework Patterns
**Whitelisted Methods**: `@frappe.whitelist()` required for any method callable from the client.
**Query Builder** (not raw SQL):
```python
Item = frappe.qb.DocType('Item')
query = frappe.qb.from_(Item).select(Item.name).where(Item.disabled == 0)
```
**Child Table Operations**:
```javascript
const row = frappe.model.add_child(frm.doc, 'Sales Invoice Item', 'items');
row.item_code = 'ITEM-001';
frm.refresh_field('items');
```
**Form Scripting**:
```javascript
frappe.ui.form.on('DocType Name', {
refresh: function(frm) { /* ... */ }
});
```
## Important Conventions
### Python
- Tabs for indentation (ruff-enforced)
- `from frappe.utils import flt, cstr` for numeric/string helpers
- `frappe.throw(_('Message'))` for user-facing errors; wrap all user strings with `_()`
- API docstrings are in Russian (existing codebase convention)
### JavaScript
- Frappe globals available: `frappe`, `__()`, `flt()`, `format_currency()`, `$` (jQuery)
- `frappe.show_alert()` for transient notifications; `frappe.msgprint()` for blocking messages
## Extending to Other DocTypes
1. Add to `doctype_js` in `hooks.py`:
```python
doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js",
"Sales Order": "public/js/sales_order.js"
}
```
2. Create the JS file following the pattern in `sales_invoice.js`
3. Pass the correct child table name to `SelectionDialog` (e.g., `'Sales Order Item'`)
## After Making Changes
```bash
bench clear-cache # Always required for JS/CSS/hook changes
# Then hard-refresh browser (Ctrl+Shift+R)
```

545
selections/api.py Normal file
View File

@ -0,0 +1,545 @@
"""
API для формы подбора товаров
"""
import frappe
from frappe import _
from frappe.utils import flt, cstr
from frappe.query_builder.functions import Count
from pypika import Order
from erpnext.stock.get_item_details import get_item_details
@frappe.whitelist()
def get_items_for_selection(filters=None, start=0, page_length=50):
"""
Получить список товаров с остатками и ценами для формы подбора
Args:
filters (dict): Фильтры для поиска товаров
start (int): Начальная позиция для пагинации
page_length (int): Количество товаров на странице
Returns:
dict: {
'items': list of dicts with item details,
'total_count': int
}
"""
if isinstance(filters, str):
filters = frappe.parse_json(filters)
if not filters:
filters = {}
start = int(start)
page_length = int(page_length)
# Извлекаем параметры из фильтров
search_term = cstr(filters.get('search_term', '')).strip()
item_group = filters.get('item_group')
warehouse = filters.get('warehouse')
customer = filters.get('customer')
price_list = filters.get('price_list')
company = filters.get('company')
transaction_date = filters.get('transaction_date')
only_in_stock = filters.get('only_in_stock', False)
sort_by = filters.get('sort_by', 'item_name') # item_name, available_qty, rate
sort_order = filters.get('sort_order', 'asc') # asc, desc
# Базовый запрос к Item
Item = frappe.qb.DocType('Item')
UOM = frappe.qb.DocType('UOM')
query = (
frappe.qb.from_(Item)
.left_join(UOM).on(UOM.name == Item.stock_uom)
.select(
Item.name.as_('item_code'),
Item.item_name,
Item.item_group,
Item.stock_uom,
Item.image,
Item.description,
UOM.must_be_whole_number
)
.where(Item.disabled == 0)
.where(Item.is_sales_item == 1)
)
# Фильтр по поиску
if search_term:
search_condition = (
Item.name.like(f'%{search_term}%') |
Item.item_name.like(f'%{search_term}%') |
Item.description.like(f'%{search_term}%')
)
query = query.where(search_condition)
# Фильтр по Item Group (включая дочерние)
if item_group:
item_groups = get_child_item_groups(item_group)
if item_groups:
query = query.where(Item.item_group.isin(item_groups))
else:
query = query.where(Item.item_group == item_group)
# LEFT JOIN с Bin когда указан склад — для SQL-сортировки и фильтрации
Bin = None
if warehouse:
Bin = frappe.qb.DocType('Bin')
avail_expr = Bin.actual_qty - Bin.reserved_qty
query = (
query
.left_join(Bin)
.on((Bin.item_code == Item.name) & (Bin.warehouse == warehouse))
.select(
Bin.actual_qty,
Bin.reserved_qty,
avail_expr.as_('available_qty')
)
)
if only_in_stock:
query = query.where(avail_expr > 0)
# Подсчет общего количества
count_query = query.select(Count('*').as_('count'))
count_result = count_query.run(as_dict=True)
total_count = count_result[0].count if count_result else 0
# Сортировка
if sort_by == 'available_qty' and warehouse and Bin:
avail_expr = Bin.actual_qty - Bin.reserved_qty
if sort_order == 'desc':
query = query.orderby(avail_expr, order=Order.desc)
else:
query = query.orderby(avail_expr, order=Order.asc)
else:
if sort_order == 'desc':
query = query.orderby(Item.item_name, order=Order.desc)
else:
query = query.orderby(Item.item_name, order=Order.asc)
# Пагинация
query = query.limit(page_length).offset(start)
items_data = query.run(as_dict=True)
# Обогащаем данные ценами
result_items = []
for item in items_data:
item_dict = {
'item_code': item.item_code,
'item_name': item.item_name,
'item_group': item.item_group,
'stock_uom': item.stock_uom,
'image': item.image,
'description': item.description or '',
'must_be_whole_number': bool(item.get('must_be_whole_number', 0)),
}
# Остатки из JOIN (не нужен отдельный запрос)
if warehouse:
actual_qty = flt(item.get('actual_qty', 0))
reserved_qty = flt(item.get('reserved_qty', 0))
item_dict.update({
'actual_qty': actual_qty,
'reserved_qty': reserved_qty,
'available_qty': actual_qty - reserved_qty
})
else:
item_dict.update({
'actual_qty': 0.0,
'reserved_qty': 0.0,
'available_qty': 0.0
})
# Цены
if price_list and company:
price_data = get_price_data(
item.item_code,
warehouse=warehouse,
customer=customer,
price_list=price_list,
company=company,
transaction_date=transaction_date
)
item_dict.update(price_data)
else:
item_dict.update({
'price_list_rate': 0.0,
'rate': 0.0,
'currency': frappe.defaults.get_global_default('currency') or 'USD'
})
result_items.append(item_dict)
# Сортировка по цене в Python (текущая страница)
if sort_by == 'rate':
result_items.sort(key=lambda x: x.get('rate', 0), reverse=(sort_order == 'desc'))
return {
'items': result_items,
'total_count': total_count
}
@frappe.whitelist()
def get_stock_across_warehouses(item_code):
"""
Получить остатки товара по всем складам
Args:
item_code (str): Код товара
Returns:
list: Список складов с остатками
"""
Bin = frappe.qb.DocType('Bin')
Warehouse = frappe.qb.DocType('Warehouse')
query = (
frappe.qb.from_(Bin)
.join(Warehouse).on(Warehouse.name == Bin.warehouse)
.select(
Bin.warehouse,
Bin.actual_qty,
Bin.reserved_qty,
(Bin.actual_qty - Bin.reserved_qty).as_('available_qty')
)
.where(Bin.item_code == item_code)
.where(Bin.actual_qty != 0)
.where(Warehouse.disabled == 0)
.where(Warehouse.is_group == 0)
.orderby(Bin.warehouse)
)
return query.run(as_dict=True)
def get_stock_data(item_code, warehouse):
"""
Получить данные об остатках товара на складе
Args:
item_code (str): Код товара
warehouse (str): Склад
Returns:
dict: Данные об остатках
"""
Bin = frappe.qb.DocType('Bin')
stock_query = (
frappe.qb.from_(Bin)
.select(
Bin.actual_qty,
Bin.reserved_qty,
Bin.projected_qty
)
.where(Bin.item_code == item_code)
.where(Bin.warehouse == warehouse)
)
stock_result = stock_query.run(as_dict=True)
if stock_result:
stock = stock_result[0]
actual_qty = flt(stock.actual_qty)
reserved_qty = flt(stock.reserved_qty)
available_qty = actual_qty - reserved_qty
return {
'actual_qty': actual_qty,
'reserved_qty': reserved_qty,
'available_qty': available_qty
}
else:
return {
'actual_qty': 0.0,
'reserved_qty': 0.0,
'available_qty': 0.0
}
def get_price_data(item_code, warehouse=None, customer=None, price_list=None, company=None, transaction_date=None):
"""
Получить данные о ценах товара
Args:
item_code (str): Код товара
warehouse (str): Склад
customer (str): Клиент
price_list (str): Прайс-лист
company (str): Компания
transaction_date (str): Дата транзакции
Returns:
dict: Данные о ценах
"""
# First try to get price from Item Price directly (more reliable)
price_list_rate = 0.0
currency = frappe.defaults.get_global_default('currency') or 'USD'
if price_list:
# Try to get price for specific customer first
price_data = None
if customer:
price_data = frappe.db.get_value(
'Item Price',
{
'item_code': item_code,
'price_list': price_list,
'customer': customer
},
['price_list_rate', 'currency'],
as_dict=True
)
# If no customer-specific price, get general price
if not price_data:
price_data = frappe.db.get_value(
'Item Price',
{
'item_code': item_code,
'price_list': price_list,
'customer': ['in', ['', None]]
},
['price_list_rate', 'currency'],
as_dict=True
)
if price_data:
price_list_rate = flt(price_data.get('price_list_rate', 0.0))
currency = price_data.get('currency') or currency
# Now try get_item_details for additional processing (pricing rules, discounts)
try:
args = {
'item_code': item_code,
'doctype': 'Sales Invoice',
'company': company,
'qty': 1.0,
'conversion_rate': 1.0,
'price_list_rate': price_list_rate # Pass the price we found
}
if warehouse:
args['warehouse'] = warehouse
if customer:
args['customer'] = customer
if price_list:
args['selling_price_list'] = price_list
if transaction_date:
args['transaction_date'] = transaction_date
item_details = get_item_details(args)
# Use get_item_details results if available, otherwise use our direct price
final_price_list_rate = flt(item_details.get('price_list_rate', 0.0)) or price_list_rate
final_rate = flt(item_details.get('rate', 0.0)) or final_price_list_rate
final_currency = item_details.get('currency') or currency
return {
'price_list_rate': final_price_list_rate,
'rate': final_rate,
'currency': final_currency
}
except Exception as e:
frappe.log_error(f"Error getting price details for {item_code}: {str(e)}")
# Return direct price even if get_item_details fails
return {
'price_list_rate': price_list_rate,
'rate': price_list_rate,
'currency': currency
}
def get_child_item_groups(item_group):
"""
Получить список дочерних Item Groups
Args:
item_group (str): Название родительской группы
Returns:
list: Список названий групп (включая саму группу)
"""
from erpnext.setup.doctype.item_group import item_group as ig_module
try:
# Используем встроенную функцию ERPNext
child_groups = ig_module.get_child_item_groups(item_group)
return child_groups
except:
# Fallback - возвращаем только саму группу
return [item_group]
@frappe.whitelist()
def get_item_groups_tree():
"""
Get Item Groups tree for filter
Returns:
list: Tree of item groups
"""
# Check cache
cache_key = 'item_groups_tree'
cached_tree = frappe.cache().get_value(cache_key)
if cached_tree:
return cached_tree
# Query all groups
ItemGroup = frappe.qb.DocType('Item Group')
query = (
frappe.qb.from_(ItemGroup)
.select(
ItemGroup.name,
ItemGroup.item_group_name,
ItemGroup.parent_item_group,
ItemGroup.is_group,
ItemGroup.lft,
ItemGroup.rgt
)
.orderby(ItemGroup.lft)
)
groups = query.run(as_dict=True)
# Строим дерево
tree = build_tree(groups)
# Кэшируем на 5 минут
frappe.cache().set_value(cache_key, tree, expires_in_sec=300)
return tree
def build_tree(groups):
"""
Построить дерево из плоского списка групп (NestedSet)
Args:
groups (list): Список групп с lft/rgt
Returns:
list: Дерево групп
"""
if not groups:
return []
# Создаем словарь для быстрого доступа
groups_dict = {g['name']: g for g in groups}
# Создаем структуру дерева
tree = []
for group in groups:
node = {
'value': group['name'],
'label': group['item_group_name'] or group['name'],
'is_group': group['is_group'],
'children': []
}
# Находим детей (следующий уровень)
if group['is_group']:
for potential_child in groups:
if potential_child['parent_item_group'] == group['name']:
child_node = {
'value': potential_child['name'],
'label': potential_child['item_group_name'] or potential_child['name'],
'is_group': potential_child['is_group']
}
node['children'].append(child_node)
# Добавляем в дерево только корневые элементы
if not group['parent_item_group']:
tree.append(node)
# Рекурсивно строим поддерево
def add_children(node, all_groups):
if not node.get('is_group'):
return
children = []
for g in all_groups:
if g['parent_item_group'] == node['value']:
child = {
'value': g['name'],
'label': g['item_group_name'] or g['name'],
'is_group': g['is_group'],
'children': []
}
add_children(child, all_groups)
children.append(child)
node['children'] = children
for node in tree:
add_children(node, groups)
return tree
@frappe.whitelist()
def add_items_to_document(doctype, docname, items):
"""
Add selected items to document
Args:
doctype (str): Document type (e.g., 'Sales Invoice')
docname (str): Document name
items (list): List of items to add
Returns:
dict: Operation result
"""
if isinstance(items, str):
items = frappe.parse_json(items)
if not items:
frappe.throw(_('No items to add'))
try:
# Load document
doc = frappe.get_doc(doctype, docname)
# Check permissions
doc.check_permission('write')
added_count = 0
for item in items:
item_code = item.get('item_code')
qty = flt(item.get('qty', 1.0))
warehouse = item.get('warehouse')
if not item_code:
continue
# Add row to items table
row = doc.append('items', {})
row.item_code = item_code
row.qty = qty
if warehouse:
row.warehouse = warehouse
added_count += 1
# Update item details (prices, taxes, etc.)
doc.run_method('set_missing_values')
# Save document
doc.save()
return {
'success': True,
'message': _('Items added: {0}').format(added_count),
'added_count': added_count
}
except Exception as e:
frappe.log_error(f"Error adding items to {doctype} {docname}: {str(e)}")
frappe.throw(_('Error adding items: {0}').format(str(e)))

View File

@ -25,8 +25,8 @@ app_license = "unlicense"
# ------------------ # ------------------
# include js, css files in header of desk.html # include js, css files in header of desk.html
# app_include_css = "/assets/selections/css/selections.css" app_include_css = "/assets/selections/css/selection_dialog.css"
# app_include_js = "/assets/selections/js/selections.js" app_include_js = "/assets/selections/js/selection_dialog.js"
# include js, css files in header of web template # include js, css files in header of web template
# web_include_css = "/assets/selections/css/selections.css" # web_include_css = "/assets/selections/css/selections.css"
@ -43,7 +43,9 @@ app_license = "unlicense"
# page_js = {"page" : "public/js/file.js"} # page_js = {"page" : "public/js/file.js"}
# include js in doctype views # include js in doctype views
# doctype_js = {"doctype" : "public/js/doctype.js"} doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js"
}
# doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}

View File

@ -0,0 +1,623 @@
/**
* Стили для формы подбора товаров
*/
/* Fullscreen modal for selection dialog */
.selection-dialog-wrapper .modal-dialog {
max-width: 100vw !important;
width: 100vw !important;
height: 100vh !important;
margin: 0 !important;
}
.selection-dialog-wrapper .modal-content {
height: 100vh !important;
border-radius: 0 !important;
border: none !important;
}
.selection-dialog-wrapper .modal-body {
padding: 15px !important;
overflow: hidden !important;
flex: 1 !important;
}
/* CRITICAL: Force frappe-control to full width */
.selection-dialog-wrapper .frappe-control {
width: 100% !important;
max-width: none !important;
}
.selection-dialog-wrapper .form-group {
width: 100% !important;
max-width: none !important;
}
/* 3-колоночный layout */
.selection-dialog-content {
display: flex;
gap: 20px;
height: calc(100vh - 130px);
min-height: 500px;
}
/* Левая колонка: Фильтры */
.selection-filters {
flex: 0 0 250px;
border-right: 1px solid var(--border-color);
padding-right: 15px;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.filter-group {
margin-bottom: 15px;
}
.filter-group label {
font-weight: 600;
margin-bottom: 5px;
display: block;
color: var(--text-color);
}
/* Переключатель "Только в наличии" */
.selection-stock-toggle {
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--bg-light-gray);
}
.stock-toggle-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
margin: 0;
cursor: pointer;
}
.stock-toggle-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary-color);
}
.stock-toggle-label input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.item-group-tree {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 5px;
}
.tree-node {
padding: 4px 8px;
cursor: pointer;
border-radius: var(--border-radius);
transition: background-color 0.2s;
}
.tree-node:hover {
background-color: var(--bg-light-gray);
}
.tree-node.active {
background-color: var(--primary-color);
color: white;
}
.tree-node.active .tree-link {
color: white !important;
}
.tree-link {
text-decoration: none;
color: var(--text-color);
display: block;
}
.tree-link i {
margin-right: 5px;
width: 16px;
text-align: center;
}
/* Средняя колонка: Товары */
.selection-items-column {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
/* Панель сортировки */
.selection-sort-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background-color: var(--bg-light-gray);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
margin-bottom: 8px;
flex-shrink: 0;
}
.sort-label {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.sort-buttons {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.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;
}
.sort-btn:hover {
background-color: var(--bg-light-gray);
border-color: var(--primary-color);
}
.sort-btn.active {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.sort-btn i {
margin-left: 3px;
}
.selection-items-grid {
flex: 1;
overflow-y: auto;
overflow-x: auto;
margin-bottom: 10px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
}
.selection-table {
margin-bottom: 0;
width: 100%;
}
.selection-table thead th {
position: sticky;
top: 0;
background-color: var(--bg-color);
z-index: 10;
border-bottom: 2px solid var(--border-color);
white-space: nowrap;
padding: 8px;
}
/* Кликабельные заголовки для сортировки */
.sortable-header {
cursor: pointer;
user-select: none;
transition: background-color 0.15s;
}
.sortable-header:hover {
background-color: var(--bg-light-gray) !important;
}
.sortable-header.sort-active {
background-color: #e8f0fe !important;
color: var(--primary-color);
}
.sortable-header i {
margin-left: 4px;
font-size: 11px;
opacity: 0.7;
}
.selection-table tbody tr {
cursor: pointer;
transition: background-color 0.1s;
}
.selection-table tbody tr:hover {
background-color: var(--bg-light-gray);
}
/* Выделение строки при клавиатурной навигации */
.selection-row-focused {
background-color: #dbeafe !important;
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
/* Изображения товаров */
.item-image-cell {
width: 50px;
padding: 4px !important;
text-align: center;
vertical-align: middle;
}
.item-thumbnail {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border-color);
}
.item-thumbnail-placeholder {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-light-gray);
color: var(--text-muted);
font-size: 18px;
margin: 0 auto;
}
/* Поле qty и кнопка в строке товара */
.action-cell {
white-space: nowrap;
width: 140px;
}
.row-action-group {
display: flex;
align-items: center;
gap: 4px;
}
.row-qty-input {
width: 65px !important;
text-align: center;
padding: 4px 6px !important;
}
.btn-add-to-cart {
padding: 4px 8px;
transition: all 0.2s;
}
/* Stock indicators */
.stock-indicator {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.15s;
}
.stock-indicator:hover {
opacity: 0.8;
}
.stock-indicator.in-stock {
background-color: #d4edda;
color: #155724;
}
.stock-indicator.low-stock {
background-color: #fff3cd;
color: #856404;
}
.stock-indicator.out-of-stock {
background-color: #f8d7da;
color: #721c24;
}
/* Tooltip остатков по складам */
.stock-warehouses-tooltip {
position: fixed;
z-index: 9999;
background: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 10px 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
font-size: 13px;
min-width: 220px;
max-width: 320px;
}
.stock-warehouses-tooltip strong {
display: block;
margin-bottom: 8px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 6px;
}
.stock-tooltip-table {
width: 100%;
border-collapse: collapse;
}
.stock-tooltip-table td {
padding: 3px 6px;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
}
.stock-tooltip-table tr:last-child td {
border-bottom: none;
}
.stock-tooltip-qty {
text-align: right;
font-weight: 600;
padding: 2px 6px !important;
border-radius: 3px;
}
/* Пагинация */
.selection-pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
flex-shrink: 0;
}
.pagination-info {
font-size: 13px;
color: var(--text-muted);
}
/* Правая колонка: Корзина */
.selection-cart {
flex: 0 0 290px;
border-left: 1px solid var(--border-color);
padding-left: 15px;
display: flex;
flex-direction: column;
}
.cart-header {
padding: 10px;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
margin-bottom: 10px;
text-align: center;
}
.cart-count {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.cart-items-list {
flex: 1;
overflow-y: auto;
margin-bottom: 10px;
}
/* Cart item */
.cart-item {
padding: 10px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
margin-bottom: 10px;
background-color: var(--bg-color);
transition: box-shadow 0.2s;
}
.cart-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.cart-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.cart-item-header strong {
flex: 1;
font-size: 13px;
line-height: 1.3;
}
.cart-item-details {
font-size: 12px;
}
.cart-item-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 5px;
}
.cart-field-label {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
min-width: 40px;
margin: 0;
}
.cart-item-qty {
text-align: center;
}
.cart-item-discount {
width: 65px !important;
text-align: center;
padding: 3px 6px !important;
}
.cart-item-price {
margin-top: 6px;
color: var(--text-muted);
}
.cart-item-price strong {
color: var(--text-color);
}
/* Зачёркнутая цена при скидке */
.line-through {
text-decoration: line-through;
opacity: 0.6;
}
/* Предупреждение превышения остатка */
.stock-warning {
margin-top: 5px;
padding: 3px 8px;
background-color: #fff3cd;
color: #856404;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
}
.stock-warning i {
margin-right: 4px;
}
.btn-remove-from-cart {
padding: 2px 6px;
line-height: 1;
}
.cart-footer {
padding: 10px;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
flex-shrink: 0;
}
.cart-total {
font-size: 16px;
margin-bottom: 10px;
text-align: center;
}
.cart-total strong {
display: block;
margin-bottom: 5px;
}
.cart-total span {
color: var(--primary-color);
font-weight: 600;
font-size: 18px;
}
/* Адаптация для разных размеров экрана */
@media (max-width: 1400px) {
.selection-filters {
flex: 0 0 210px;
}
.selection-cart {
flex: 0 0 255px;
}
}
@media (max-width: 1200px) {
.selection-dialog-content {
flex-direction: column;
}
.selection-filters,
.selection-cart {
flex: 0 0 auto;
border: none;
padding: 0;
}
.selection-filters {
border-bottom: 1px solid var(--border-color);
padding-bottom: 15px;
margin-bottom: 15px;
}
.selection-cart {
border-top: 1px solid var(--border-color);
padding-top: 15px;
margin-top: 15px;
}
}
/* Общие улучшения */
.selection-dialog-content .btn-primary {
white-space: nowrap;
}
.selection-dialog-content .form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(var(--primary-color-rgb), 0.25);
}
/* Scrollbar styling */
.selection-items-grid::-webkit-scrollbar,
.cart-items-list::-webkit-scrollbar,
.item-group-tree::-webkit-scrollbar,
.selection-filters::-webkit-scrollbar {
width: 8px;
}
.selection-items-grid::-webkit-scrollbar-track,
.cart-items-list::-webkit-scrollbar-track,
.item-group-tree::-webkit-scrollbar-track,
.selection-filters::-webkit-scrollbar-track {
background: var(--bg-light-gray);
border-radius: 4px;
}
.selection-items-grid::-webkit-scrollbar-thumb,
.cart-items-list::-webkit-scrollbar-thumb,
.item-group-tree::-webkit-scrollbar-thumb,
.selection-filters::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
.selection-items-grid::-webkit-scrollbar-thumb:hover,
.cart-items-list::-webkit-scrollbar-thumb:hover,
.item-group-tree::-webkit-scrollbar-thumb:hover,
.selection-filters::-webkit-scrollbar-thumb:hover {
background: var(--text-color);
}

View File

@ -0,0 +1,45 @@
/**
* Интеграция формы подбора с Sales Invoice
*/
frappe.ui.form.on('Sales Invoice', {
refresh: function(frm) {
if (frm.doc.docstatus === 0) { // Только для черновиков
setup_selection_button(frm);
}
}
});
function setup_selection_button(frm) {
const grid = frm.fields_dict.items.grid;
// Удаляем стандартную кнопку "Add Multiple" и нашу предыдущую (если есть)
grid.wrapper.find('.grid-add-multiple-rows').remove();
// Вставляем нашу кнопку сразу после "Add Row" — в то же место, где была оригинальная.
// Это гарантирует одинаковое поведение: кнопка сдвигается вправо вместе с "Add Row"
// когда появляются Delete/Duplicate кнопки при выборе строк.
$('<button type="button" class="grid-add-multiple-rows btn btn-xs btn-secondary">')
.html(__('Add Multiple'))
.insertAfter(grid.wrapper.find('.grid-add-row'))
.on('click', function(e) {
// Предотвращаем срабатывание чужих обработчиков на этой же кнопке
// (Frappe привязывает свой хендлер через set_multiple_add к классу grid-add-multiple-rows)
e.stopImmediatePropagation();
if (!frm.doc.customer) {
frappe.msgprint({
title: __('Warning'),
message: __('Please select Customer first'),
indicator: 'orange'
});
return;
}
const selection_dialog = new SelectionDialog({
frm: frm,
doctype: 'Sales Invoice'
});
selection_dialog.show();
});
}

File diff suppressed because it is too large Load Diff