From 410216992e31c16d0dae82a307eb37297cb1ba0a Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Tue, 17 Feb 2026 19:42:41 +0400 Subject: [PATCH] first commit --- CLAUDE.md | 131 +++ selections/api.py | 545 ++++++++++ selections/hooks.py | 8 +- selections/public/css/selection_dialog.css | 623 ++++++++++++ selections/public/js/sales_invoice.js | 45 + selections/public/js/selection_dialog.js | 1055 ++++++++++++++++++++ 6 files changed, 2404 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md create mode 100644 selections/api.py create mode 100644 selections/public/css/selection_dialog.css create mode 100644 selections/public/js/sales_invoice.js create mode 100644 selections/public/js/selection_dialog.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8b1d803 --- /dev/null +++ b/CLAUDE.md @@ -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) +``` diff --git a/selections/api.py b/selections/api.py new file mode 100644 index 0000000..45c1244 --- /dev/null +++ b/selections/api.py @@ -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))) diff --git a/selections/hooks.py b/selections/hooks.py index 2b567d0..f16e1d4 100644 --- a/selections/hooks.py +++ b/selections/hooks.py @@ -25,8 +25,8 @@ app_license = "unlicense" # ------------------ # include js, css files in header of desk.html -# app_include_css = "/assets/selections/css/selections.css" -# app_include_js = "/assets/selections/js/selections.js" +app_include_css = "/assets/selections/css/selection_dialog.css" +app_include_js = "/assets/selections/js/selection_dialog.js" # include js, css files in header of web template # web_include_css = "/assets/selections/css/selections.css" @@ -43,7 +43,9 @@ app_license = "unlicense" # page_js = {"page" : "public/js/file.js"} # 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_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} diff --git a/selections/public/css/selection_dialog.css b/selections/public/css/selection_dialog.css new file mode 100644 index 0000000..9a0045f --- /dev/null +++ b/selections/public/css/selection_dialog.css @@ -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); +} diff --git a/selections/public/js/sales_invoice.js b/selections/public/js/sales_invoice.js new file mode 100644 index 0000000..9cef374 --- /dev/null +++ b/selections/public/js/sales_invoice.js @@ -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 кнопки при выборе строк. + $(' + + + + +
+
+ ${__('Sort by')}: +
+ + + +
+
+
+
+ ${__('Loading items...')} +
+
+ +
+ + +
+
+ ${__('Cart')} (0) +
+
+
+ ${__('Cart is empty')} +
+
+ +
+ + + + + `; + } + + show() { + this.dialog.show(); + + // Add unique class to modal wrapper for CSS targeting + this.dialog.$wrapper.addClass('selection-dialog-wrapper'); + + // Force fullscreen with setTimeout to override Frappe's styles + setTimeout(() => { + const wrapper = this.dialog.$wrapper; + + // Make modal fullscreen + wrapper.find('.modal-dialog').removeClass('modal-xl').css({ + 'max-width': '100vw', + 'width': '100vw', + 'height': '100vh', + 'margin': '0' + }); + + // CRITICAL: Force ALL Frappe containers to full width with !important + const formColumn = wrapper.find('.form-column')[0]; + const sectionBody = wrapper.find('.section-body')[0]; + const frappeControl = wrapper.find('.frappe-control')[0]; + + if (formColumn) { + formColumn.style.cssText = 'width: 100% !important; max-width: none !important;'; + } + + if (sectionBody) { + sectionBody.style.cssText = 'width: 100% !important; max-width: none !important;'; + } + + if (frappeControl) { + frappeControl.style.cssText = 'width: 100% !important; max-width: none !important;'; + } + }, 50); + + this.setup_handlers(); + this.load_warehouses(); + this.load_price_lists(); + this.load_item_groups(); + this.load_items(); + } + + 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); + }); + + // Выбор склада + 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) { + $(this).prop('checked', false); + return; + } + me.filters.only_in_stock = $(this).prop('checked'); + me.current_page = 0; + 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 = {}; + me.sort_by = 'item_name'; + 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(''); + 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.load_items(); + }); + + // Пагинация + this.dialog.$wrapper.find('#selection-prev-page').on('click', function() { + if (me.current_page > 0) { + me.current_page--; + me.focused_row_index = -1; + me.load_items(); + } + }); + + this.dialog.$wrapper.find('#selection-next-page').on('click', function() { + const max_pages = Math.ceil(me.total_count / me.page_length); + if (me.current_page < max_pages - 1) { + me.current_page++; + me.focused_row_index = -1; + me.load_items(); + } + }); + + // Очистить корзину + this.dialog.$wrapper.find('#selection-clear-cart').on('click', function() { + me.cart = {}; + me.update_cart_display(); + }); + + // Клавиатурная навигация + this.dialog.$wrapper.on('keydown', function(e) { + me._handle_keydown(e); + }); + + // Скрывать tooltip при клике вне + this.dialog.$wrapper.on('click.tooltip-close', function(e) { + if (!$(e.target).closest('.stock-indicator, .stock-warehouses-tooltip').length) { + me.dialog.$wrapper.find('.stock-warehouses-tooltip').hide(); + } + }); + } + + _update_stock_toggle_state() { + const has_warehouse = !!this.filters.warehouse; + const $toggle = this.dialog.$wrapper.find('#selection-only-in-stock'); + const $hint = this.dialog.$wrapper.find('#selection-stock-toggle-hint'); + + $toggle.prop('disabled', !has_warehouse); + if (!has_warehouse) { + $toggle.prop('checked', false); + this.filters.only_in_stock = false; + $hint.show(); + } else { + $hint.hide(); + } + } + + _handle_keydown(e) { + const $rows = this.dialog.$wrapper.find('#selection-items-grid tbody tr'); + const row_count = $rows.length; + + if (row_count === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.focused_row_index = Math.min(this.focused_row_index + 1, row_count - 1); + this._update_focused_row($rows); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this.focused_row_index = Math.max(this.focused_row_index - 1, 0); + this._update_focused_row($rows); + } else if (e.key === 'Enter' && !e.ctrlKey) { + // Добавить выделенный товар в корзину + if (this.focused_row_index >= 0 && this.items_data[this.focused_row_index]) { + e.preventDefault(); + const $row = $rows.eq(this.focused_row_index); + const qty_val = parseFloat(($row.find('.row-qty-input').val() || '').replace(',', '.')) || 1; + const item = this.items_data[this.focused_row_index]; + this.add_to_cart(item, qty_val); + } + } else if (e.key === 'Enter' && e.ctrlKey) { + // Добавить всё в документ + e.preventDefault(); + this.finalize_selection(); + } else if (e.key === 'Escape') { + this.dialog.hide(); + } + } + + _update_focused_row($rows) { + $rows.removeClass('selection-row-focused'); + if (this.focused_row_index >= 0) { + const $focused = $rows.eq(this.focused_row_index); + $focused.addClass('selection-row-focused'); + // Прокручиваем к выделенной строке + const grid = this.dialog.$wrapper.find('#selection-items-grid')[0]; + const row_el = $focused[0]; + if (grid && row_el) { + const grid_top = grid.scrollTop; + const grid_bottom = grid_top + grid.clientHeight; + const row_top = row_el.offsetTop; + const row_bottom = row_top + row_el.offsetHeight; + if (row_top < grid_top) { + grid.scrollTop = row_top; + } else if (row_bottom > grid_bottom) { + grid.scrollTop = row_bottom - grid.clientHeight; + } + } + } + } + + load_warehouses() { + 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($('