food-market/app/ui/widgets.py

437 lines
15 KiB
Python
Raw Normal View History

"""Общие элементы интерфейса.
Здесь всё, что иначе копировалось бы по экранам: поля денег и количеств,
настроенные таблицы и редактор строк документа.
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from PySide6.QtCore import QDate, Qt, Signal
from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import (
QComboBox,
QCompleter,
QDateEdit,
QDoubleSpinBox,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from .. import money as m
from . import theme
MAX_MONEY = 99_999_999.0
# --- поля ввода -----------------------------------------------------------
class MoneySpin(QDoubleSpinBox):
def __init__(self, currency: str = "", parent=None):
super().__init__(parent)
self.setDecimals(2)
self.setRange(0, MAX_MONEY)
self.setGroupSeparatorShown(True)
self.setSuffix(f" {currency}" if currency else "")
self.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
def value_decimal(self) -> Decimal:
return m.money(self.value())
def set_decimal(self, value) -> None:
self.setValue(float(m.money(value)))
class QtySpin(QDoubleSpinBox):
def __init__(self, parent=None):
super().__init__(parent)
self.setDecimals(3)
self.setRange(0, 1_000_000)
self.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
# Дробные количества нужны для веса, но булки штучные — прячем нули.
self.setStepType(QDoubleSpinBox.DefaultStepType)
def textFromValue(self, value: float) -> str:
return m.fmt_qty(value)
def valueFromText(self, text: str) -> float:
return float(m.qty(text))
def value_decimal(self) -> Decimal:
return m.qty(self.value())
def set_decimal(self, value) -> None:
self.setValue(float(m.qty(value)))
class DateInput(QDateEdit):
def __init__(self, value: date | None = None, parent=None):
super().__init__(parent)
self.setCalendarPopup(True)
self.setDisplayFormat("dd.MM.yyyy")
self.set_date(value or date.today())
def set_date(self, value: date) -> None:
self.setDate(QDate(value.year, value.month, value.day))
def get_date(self) -> date:
qd = self.date()
return date(qd.year(), qd.month(), qd.day())
class ProductCombo(QComboBox):
"""Выпадающий список товаров с поиском по набранному тексту."""
def __init__(self, products, parent=None):
super().__init__(parent)
self.setEditable(True)
self.setInsertPolicy(QComboBox.NoInsert)
# Поиск по подстроке, а не только по началу: «мак» должен находить
# «Булка с маком».
completer = self.completer()
if completer is not None:
completer.setFilterMode(Qt.MatchContains)
completer.setCompletionMode(QCompleter.PopupCompletion)
for product in products:
self.addItem(product.name, product.id)
def current_product_id(self) -> str | None:
return self.currentData()
def select_product(self, product_id: str) -> None:
index = self.findData(product_id)
if index >= 0:
self.setCurrentIndex(index)
# --- мелкие сборки --------------------------------------------------------
def label(text: str, role: str = "", color: str = "") -> QLabel:
widget = QLabel(text)
if role:
widget.setProperty("role", role)
if color:
widget.setStyleSheet(f"color: {color};")
return widget
def heading(text: str) -> QLabel:
return label(text, "h1")
def card() -> QFrame:
frame = QFrame()
frame.setProperty("role", "card")
return frame
def button(text: str, role: str = "", on_click=None) -> QPushButton:
widget = QPushButton(text)
if role:
widget.setProperty("role", role)
if on_click is not None:
widget.clicked.connect(on_click)
return widget
def row(*widgets, stretch_at: int | None = None) -> QWidget:
holder = QWidget()
layout = QHBoxLayout(holder)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
for index, widget in enumerate(widgets):
if widget is None:
layout.addStretch(1)
continue
layout.addWidget(widget)
if index == stretch_at:
layout.setStretch(index, 1)
return holder
def metric_card(title: str, value: str, hint: str = "", color: str = "") -> QFrame:
"""Крупная цифра для сводки."""
frame = card()
layout = QVBoxLayout(frame)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(2)
layout.addWidget(label(title, "dim"))
layout.addWidget(label(value, "metric", color))
if hint:
layout.addWidget(label(hint, "dim"))
frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
return frame
def warning_banner(messages: list[str]) -> QFrame:
frame = QFrame()
frame.setProperty("role", "banner")
layout = QVBoxLayout(frame)
layout.setContentsMargins(12, 10, 12, 10)
layout.setSpacing(3)
for text in messages:
item = QLabel(f"{text}")
item.setWordWrap(True)
layout.addWidget(item)
return frame
# --- таблицы --------------------------------------------------------------
def table(headers: list[str], stretch_column: int = 0) -> QTableWidget:
widget = QTableWidget(0, len(headers))
widget.setHorizontalHeaderLabels(headers)
widget.verticalHeader().setVisible(False)
widget.setAlternatingRowColors(True)
widget.setSelectionBehavior(QTableWidget.SelectRows)
widget.setSelectionMode(QTableWidget.SingleSelection)
widget.setEditTriggers(QTableWidget.NoEditTriggers)
widget.setSortingEnabled(True)
widget.setWordWrap(False)
header = widget.horizontalHeader()
header.setSectionResizeMode(QHeaderView.ResizeToContents)
header.setSectionResizeMode(stretch_column, QHeaderView.Stretch)
header.setHighlightSections(False)
return widget
def text_item(text: str, color: str = "", bold: bool = False) -> QTableWidgetItem:
item = QTableWidgetItem(text)
if color:
item.setForeground(QColor(color))
if bold:
font = item.font()
font.setBold(True)
item.setFont(font)
return item
def num_item(text: str, value, color: str = "", bold: bool = False) -> QTableWidgetItem:
"""Ячейка с числом.
Отображаемый текст отформатирован, а сортировка идёт по настоящему
значению иначе «1 200 » сортировалось бы как строка и оказалось бы
меньше «900 ».
"""
item = text_item(text, color, bold)
item.setData(Qt.UserRole + 1, float(value))
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
return item
class SortableItem(QTableWidgetItem):
def __lt__(self, other):
mine = self.data(Qt.UserRole + 1)
theirs = other.data(Qt.UserRole + 1)
if mine is not None and theirs is not None:
return mine < theirs
return super().__lt__(other)
def sortable_num_item(text: str, value, color: str = "", bold: bool = False) -> SortableItem:
item = SortableItem(text)
if color:
item.setForeground(QColor(color))
if bold:
font = item.font()
font.setBold(True)
item.setFont(font)
item.setData(Qt.UserRole + 1, float(value))
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
return item
def fill(widget: QTableWidget, rows: list[list[QTableWidgetItem]], keys: list = None) -> None:
"""Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки."""
was_sorting = widget.isSortingEnabled()
widget.setSortingEnabled(False)
widget.setRowCount(len(rows))
for r, cells in enumerate(rows):
for c, cell in enumerate(cells):
widget.setItem(r, c, cell)
if keys is not None and cells:
cells[0].setData(Qt.UserRole, keys[r])
widget.setSortingEnabled(was_sorting)
def selected_key(widget: QTableWidget):
row_index = widget.currentRow()
if row_index < 0:
return None
item = widget.item(row_index, 0)
return item.data(Qt.UserRole) if item else None
# --- редактор строк документа ---------------------------------------------
class LinesEditor(QWidget):
"""Таблица строк закупки или продажи.
Один виджет на оба случая: отличается только подпись колонки цены и то,
откуда берётся подстановка себестоимость или цена продажи.
"""
changed = Signal()
COL_PRODUCT, COL_QTY, COL_PRICE, COL_TOTAL = range(4)
def __init__(self, products, price_title: str, price_source, currency: str = "", parent=None):
super().__init__(parent)
self.products = list(products)
self.price_source = price_source
self.currency = currency
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(["Товар", "Кол-во", price_title, "Сумма"])
self.table.verticalHeader().setVisible(False)
header = self.table.horizontalHeader()
header.setSectionResizeMode(self.COL_PRODUCT, QHeaderView.Stretch)
for column in (self.COL_QTY, self.COL_PRICE, self.COL_TOTAL):
header.setSectionResizeMode(column, QHeaderView.ResizeToContents)
self.table.setMinimumHeight(180)
layout.addWidget(self.table)
self.total_label = label("", "h2")
layout.addWidget(
row(
button("+ Строка", on_click=self.add_line),
button("Удалить строку", role="danger", on_click=self.remove_current),
None,
self.total_label,
)
)
# --- содержимое ---
def add_line(self, product_id: str | None = None, quantity=1, price=None) -> None:
if not self.products:
return
r = self.table.rowCount()
self.table.insertRow(r)
combo = ProductCombo(self.products)
if product_id:
combo.select_product(product_id)
combo.currentIndexChanged.connect(lambda _, rr=r: self._on_product_changed(rr))
self.table.setCellWidget(r, self.COL_PRODUCT, combo)
qty_spin = QtySpin()
qty_spin.set_decimal(quantity)
qty_spin.valueChanged.connect(self._recalc)
self.table.setCellWidget(r, self.COL_QTY, qty_spin)
price_spin = MoneySpin(self.currency)
price_spin.set_decimal(
price if price is not None else self._suggested_price(combo.current_product_id())
)
price_spin.valueChanged.connect(self._recalc)
self.table.setCellWidget(r, self.COL_PRICE, price_spin)
self.table.setItem(r, self.COL_TOTAL, text_item(""))
self._recalc()
def remove_current(self) -> None:
r = self.table.currentRow()
if r >= 0:
self.table.removeRow(r)
self._recalc()
def set_lines(self, lines: list, value_attr: str) -> None:
self.table.setRowCount(0)
for line in lines:
self.add_line(line.product_id, line.qty, getattr(line, value_attr))
def lines(self, value_key: str) -> list[dict]:
result = []
for r in range(self.table.rowCount()):
combo = self.table.cellWidget(r, self.COL_PRODUCT)
product_id = combo.current_product_id() if combo else None
if not product_id:
continue
result.append(
{
"product_id": product_id,
"qty": self.table.cellWidget(r, self.COL_QTY).value_decimal(),
value_key: self.table.cellWidget(r, self.COL_PRICE).value_decimal(),
}
)
return result
def total(self) -> Decimal:
total = m.ZERO
for r in range(self.table.rowCount()):
qty_widget = self.table.cellWidget(r, self.COL_QTY)
price_widget = self.table.cellWidget(r, self.COL_PRICE)
if qty_widget and price_widget:
total += qty_widget.value_decimal() * price_widget.value_decimal()
return m.money(total)
def refresh_prices(self) -> None:
"""Пересобрать подстановку цен — например, после смены типа продажи."""
for r in range(self.table.rowCount()):
combo = self.table.cellWidget(r, self.COL_PRODUCT)
price_widget = self.table.cellWidget(r, self.COL_PRICE)
if combo and price_widget:
price_widget.set_decimal(self._suggested_price(combo.current_product_id()))
self._recalc()
# --- внутреннее ---
def _suggested_price(self, product_id: str | None) -> Decimal:
product = next((p for p in self.products if p.id == product_id), None)
return self.price_source(product) if product else m.ZERO
def _on_product_changed(self, r: int) -> None:
combo = self.table.cellWidget(r, self.COL_PRODUCT)
price_widget = self.table.cellWidget(r, self.COL_PRICE)
if combo and price_widget:
price_widget.set_decimal(self._suggested_price(combo.current_product_id()))
self._recalc()
def _recalc(self) -> None:
for r in range(self.table.rowCount()):
qty_widget = self.table.cellWidget(r, self.COL_QTY)
price_widget = self.table.cellWidget(r, self.COL_PRICE)
if not qty_widget or not price_widget:
continue
total = m.money(qty_widget.value_decimal() * price_widget.value_decimal())
item = self.table.item(r, self.COL_TOTAL)
if item is None:
item = text_item("")
self.table.setItem(r, self.COL_TOTAL, item)
item.setText(m.fmt_money(total, self.currency))
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.total_label.setText(f"Итого: {m.fmt_money(self.total(), self.currency)}")
self.changed.emit()
def progress_color(status: str) -> str:
return theme.STATUS_COLORS.get(status, theme.ACCENT)
def monospace(widget) -> None:
font = QFont("Consolas")
font.setStyleHint(QFont.Monospace)
widget.setFont(font)