import calendar from datetime import datetime, date from kivy.clock import Clock from kivy.metrics import dp from kivy.uix.boxlayout import BoxLayout from kivy.uix.button import Button from kivy.uix.checkbox import CheckBox from kivy.uix.gridlayout import GridLayout from kivy.uix.label import Label from kivy.uix.popup import Popup from kivy.uix.screenmanager import Screen from kivy.uix.scrollview import ScrollView from kivy.uix.textinput import TextInput from kivy.logger import Logger from kivy.core.window import Window import data from numberpad import NumberPad class ClosedReceiptsScreen(Screen): def __init__(self, *, controller, back_screen="account_select", **kwargs): super().__init__(**kwargs) self.controller = controller self.back_screen = back_screen self.receipts: list[data.UcetSelect] = [] self.filtered: list[data.UcetSelect] = [] self.selected_summary: data.UcetSelect | None = None self.selected_ucet: data.Ucet | None = None self.selected_quantities: dict[str, float] = {} self.current_only = True self._keyboard_bound = False root = BoxLayout(orientation="vertical", spacing=dp(6), padding=dp(8)) top = BoxLayout(size_hint_y=None, height=dp(48), spacing=dp(6)) btn_back = Button(text="Zavriet", size_hint_x=None, width=dp(120)) btn_back.bind(on_press=lambda *_: self._go_back()) self.search = TextInput( hint_text="Hladat ucet, stol, platbu, casnika...", multiline=False, font_size=dp(16), padding=(dp(10), dp(10), dp(10), dp(8)), ) self.search.bind(text=lambda *_: self._apply_filters()) self.date_filter = TextInput( hint_text="Den", multiline=False, size_hint_x=None, width=dp(120), readonly=True, font_size=dp(16), padding=(dp(10), dp(10), dp(10), dp(8)), ) self.date_filter.bind(text=lambda *_: self._apply_filters()) btn_date = Button(text="...", size_hint_x=None, width=dp(48)) btn_date.bind(on_press=lambda *_: self._open_calendar()) btn_clear_date = Button(text="X", size_hint_x=None, width=dp(42)) btn_clear_date.bind(on_press=lambda *_: setattr(self.date_filter, "text", "")) current_wrap = BoxLayout(size_hint_x=None, width=dp(170), spacing=dp(4)) self.chk_current = CheckBox(active=True, size_hint_x=None, width=dp(36)) self.chk_current.bind(active=lambda _w, active: self._set_current_only(active)) current_wrap.add_widget(self.chk_current) current_wrap.add_widget(Label(text="Aktualne ucty", halign="left")) btn_refresh = Button(text="Obnovit", size_hint_x=None, width=dp(120)) btn_refresh.bind(on_press=lambda *_: self.refresh()) top.add_widget(btn_back) top.add_widget(self.search) top.add_widget(self.date_filter) top.add_widget(btn_date) top.add_widget(btn_clear_date) top.add_widget(current_wrap) top.add_widget(btn_refresh) root.add_widget(top) body = BoxLayout(spacing=dp(8)) left = BoxLayout(orientation="vertical", size_hint_x=0.62, spacing=dp(4)) header = GridLayout(cols=7, size_hint_y=None, height=dp(34), spacing=dp(2)) for title in ("Stav", "Ucet", "Datum", "Stol", "Platby", "Suma", "Casnik"): header.add_widget(Label(text=f"[b]{title}[/b]", markup=True, halign="left")) left.add_widget(header) self.list_grid = GridLayout(cols=1, spacing=dp(2), size_hint_y=None) self.list_grid.bind(minimum_height=self.list_grid.setter("height")) list_scroll = ScrollView(do_scroll_x=False, do_scroll_y=True, bar_width=dp(14)) list_scroll.add_widget(self.list_grid) left.add_widget(list_scroll) body.add_widget(left) right = BoxLayout(orientation="vertical", size_hint_x=0.38, spacing=dp(6)) self.detail_title = Label( text="Vyber ucet", size_hint_y=None, height=dp(36), font_size=dp(20), bold=True, ) self.detail_subtitle = Label(text="", size_hint_y=None, height=dp(28)) right.add_widget(self.detail_title) right.add_widget(self.detail_subtitle) self.detail_grid = GridLayout(cols=1, spacing=dp(2), size_hint_y=None) self.detail_grid.bind(minimum_height=self.detail_grid.setter("height")) detail_scroll = ScrollView(do_scroll_x=False, do_scroll_y=True, bar_width=dp(14)) detail_scroll.add_widget(self.detail_grid) right.add_widget(detail_scroll) actions = GridLayout(cols=2, spacing=dp(6), size_hint_y=None, height=dp(148)) self.btn_storno = self._action_button("Stornovat ucet", self._storno) self.btn_return_table = self._action_button("Storno a presun na stol", self._return_to_table) self.btn_copy = self._action_button("Tlac kopie", self._print_copy) self.btn_change_pay = self._action_button("Zmenit platby", self._change_payment) self.btn_tip = self._action_button("Zadanie TIPu", self._edit_tip) self.btn_select_all = self._action_button("Vybrat vsetko", self._select_all_items) for btn in ( self.btn_storno, self.btn_return_table, self.btn_copy, self.btn_change_pay, self.btn_tip, self.btn_select_all, ): actions.add_widget(btn) right.add_widget(actions) body.add_widget(right) root.add_widget(body) self.add_widget(root) self._refresh_actions() def on_enter(self): self._bind_keyboard() self.refresh() def on_leave(self): self._unbind_keyboard() def _bind_keyboard(self): if self._keyboard_bound: return Window.unbind(on_key_down=self._on_key_down) Window.bind(on_key_down=self._on_key_down) self._keyboard_bound = True def _unbind_keyboard(self): Window.unbind(on_key_down=self._on_key_down) self._keyboard_bound = False def _normalize_key(self, keycode, text): if text: return text if keycode == 27: return "ESC" key = keycode[1] if isinstance(keycode, tuple) else keycode return { "escape": "ESC", "esc": "ESC", }.get(key, key) def _on_key_down(self, window, keycode, scancode, codepoint, modifiers): key = self._normalize_key(keycode, codepoint) if key == "ESC": self._go_back() return True return False def _action_button(self, text, callback): btn = Button(text=text) btn.bind(on_press=lambda *_: callback()) return btn def _set_current_only(self, active: bool): self.current_only = bool(active) self.refresh() def _go_back(self): if self.manager: self.manager.current = self.back_screen def refresh(self): try: self.receipts = self.controller.load_closed_ucty( limit=2000, onlynonclsrep=self.current_only, ) except Exception as exc: Logger.exception("ClosedReceipts: load failed") self.controller._popup_info("Ucty", f"Ucty sa nepodarilo nacitat:\n{exc}") self.receipts = [] self.selected_summary = None self.selected_ucet = None self.selected_quantities = {} self._apply_filters() self._render_detail() def _apply_filters(self): text = (self.search.text or "").strip().lower() date_text = (self.date_filter.text or "").strip() result = [] for row in self.receipts: if not self.current_only and not getattr(row, "c_uzaverka", None): continue if self.current_only and getattr(row, "c_uzaverka", None): continue if date_text and self._date_key(getattr(row, "closed_at", "")) != self._normalize_date(date_text): continue if text and text not in self._summary_search_text(row): continue result.append(row) self.filtered = result self._render_list() def _summary_search_text(self, row) -> str: fields = [ getattr(row, "ucislo", ""), getattr(row, "closed_at", ""), getattr(row, "stul", ""), getattr(row, "payments_text", ""), getattr(row, "status_text", ""), getattr(row, "autor", ""), getattr(row, "origin", ""), f"{getattr(row, 'total_base_currency', 0) or 0:.2f}", ] return " ".join(str(x or "") for x in fields).lower() def _normalize_date(self, value: str) -> str: value = (value or "").strip() if not value: return "" for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d", "%y%m%d"): try: return datetime.strptime(value, fmt).strftime("%Y-%m-%d") except Exception: pass return value def _date_key(self, value: str) -> str: text = str(value or "").strip() if not text: return "" candidates = [ text, text[:19], text[:16], text[:15], text[:13], text[:10], text[:8], text[:6], ] formats = ( "%y%m%d %H:%M:%S", "%y%m%d %H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%d.%m.%Y %H:%M:%S", "%d.%m.%Y %H:%M", "%Y-%m-%d", "%d.%m.%Y", "%y%m%d", ) for candidate in candidates: for fmt in formats: try: return datetime.strptime(candidate, fmt).strftime("%Y-%m-%d") except Exception: pass if len(text) >= 6 and text[:6].isdigit(): try: return datetime.strptime(text[:6], "%y%m%d").strftime("%Y-%m-%d") except Exception: pass return text[:10] def _open_calendar(self): selected = self._parse_date_filter() or date.today() month = date(selected.year, selected.month, 1) popup = Popup(title="Vyber datumu", size_hint=(None, None), size=(dp(430), dp(430))) root = BoxLayout(orientation="vertical", spacing=dp(6), padding=dp(8)) def render(target_month): root.clear_widgets() top = BoxLayout(size_hint_y=None, height=dp(46), spacing=dp(6)) btn_prev = Button(text="<", size_hint_x=None, width=dp(54)) title = Label(text=target_month.strftime("%m/%Y"), bold=True) btn_next = Button(text=">", size_hint_x=None, width=dp(54)) top.add_widget(btn_prev) top.add_widget(title) top.add_widget(btn_next) root.add_widget(top) days = GridLayout(cols=7, spacing=dp(3), size_hint_y=None, height=dp(34)) for name in ("Po", "Ut", "St", "Stv", "Pi", "So", "Ne"): days.add_widget(Label(text=name, bold=True)) root.add_widget(days) grid = GridLayout(cols=7, spacing=dp(3)) cal = calendar.Calendar(firstweekday=0) for week in cal.monthdayscalendar(target_month.year, target_month.month): for day in week: if not day: grid.add_widget(Label(text="")) continue current = date(target_month.year, target_month.month, day) btn = Button(text=str(day)) if current == selected: btn.background_color = (0.20, 0.55, 0.75, 1) btn.bind(on_press=lambda _btn, d=current: self._set_calendar_date(popup, d)) grid.add_widget(btn) root.add_widget(grid) bottom = BoxLayout(size_hint_y=None, height=dp(46), spacing=dp(6)) btn_today = Button(text="Dnes") btn_clear = Button(text="Bez datumu") btn_cancel = Button(text="Zrusit") btn_today.bind(on_press=lambda *_: self._set_calendar_date(popup, date.today())) btn_clear.bind(on_press=lambda *_: self._clear_calendar_date(popup)) btn_cancel.bind(on_press=lambda *_: popup.dismiss()) bottom.add_widget(btn_today) bottom.add_widget(btn_clear) bottom.add_widget(btn_cancel) root.add_widget(bottom) prev_month = date(target_month.year - (1 if target_month.month == 1 else 0), 12 if target_month.month == 1 else target_month.month - 1, 1) next_month = date(target_month.year + (1 if target_month.month == 12 else 0), 1 if target_month.month == 12 else target_month.month + 1, 1) btn_prev.bind(on_press=lambda *_: render(prev_month)) btn_next.bind(on_press=lambda *_: render(next_month)) popup.content = root render(month) popup.open() def _parse_date_filter(self): text = (self.date_filter.text or "").strip() if not text: return None key = self._normalize_date(text) try: return datetime.strptime(key, "%Y-%m-%d").date() except Exception: return None def _set_calendar_date(self, popup, value: date): self.date_filter.text = value.strftime("%d.%m.%Y") popup.dismiss() def _clear_calendar_date(self, popup): self.date_filter.text = "" popup.dismiss() def _render_list(self): self.list_grid.clear_widgets() for row in self.filtered: btn = Button( text=self._row_text(row), size_hint_y=None, height=dp(54), halign="left", valign="middle", ) btn.text_size = (dp(1100), None) if self.selected_summary and getattr(row, "ucislo", "") == getattr(self.selected_summary, "ucislo", ""): btn.background_color = (0.20, 0.55, 0.75, 1) else: status = str(getattr(row, "status_text", "") or "").upper() if status == "STORNO": btn.background_color = (0.52, 0.18, 0.18, 1) elif status == "STORNOVANY": btn.background_color = (0.36, 0.20, 0.20, 1) elif status in ("CIAST. STORNO", "VYSTORNOVANY"): btn.background_color = (0.55, 0.42, 0.18, 1) btn.bind(on_press=lambda _btn, item=row: self._select_receipt(item)) self.list_grid.add_widget(btn) def _row_text(self, row) -> str: return ( f"{getattr(row, 'status_text', '') or 'UCET'} " f"# {getattr(row, 'ucislo', '')} " f"{getattr(row, 'closed_at', '')} " f"stol {getattr(row, 'stul', '') or '-'} " f"{getattr(row, 'payments_text', '') or '-'} " f"{self._money(getattr(row, 'total_base_currency', 0))} " f"{getattr(row, 'autor', '') or '-'}" ) def _money(self, value) -> str: return f"{float(value or 0):.2f} {self.controller._currency()}" def _select_receipt(self, row): self.selected_summary = row self.selected_quantities = {} try: self.selected_ucet = self.controller.load_closed_ucet_detail(row.ucislo) except Exception as exc: Logger.exception("ClosedReceipts: detail failed") self.controller._popup_info("Ucty", f"Ucet {row.ucislo} sa nepodarilo nacitat:\n{exc}") self.selected_ucet = None self._render_list() self._render_detail() def _render_detail(self): self.detail_grid.clear_widgets() ucet = self.selected_ucet if not ucet: self.detail_title.text = "Vyber ucet" self.detail_subtitle.text = "" self._refresh_actions() return self.detail_title.text = f"Ucet {ucet.ucislo or ''}" self.detail_subtitle.text = f"Stol {ucet.stul or '-'} | {ucet.closed_at or ''} | {self._money(ucet.total_czk())}" for pol in getattr(ucet, "poloz", []) or []: self.detail_grid.add_widget(self._item_row(pol)) self._refresh_actions() def _item_row(self, pol): line_id = self._line_id(pol) available = self._storno_available_units(pol) selected = self.selected_quantities.get(line_id, 0.0) partial = self._is_partially_storned(pol) row = BoxLayout( orientation="horizontal", spacing=dp(4), size_hint_y=None, height=dp(78), ) bg = (0.30, 0.30, 0.30, 1) if available <= 0: bg = (0.35, 0.35, 0.35, 1) elif partial: bg = (0.55, 0.42, 0.18, 1) elif selected: bg = (0.20, 0.55, 0.75, 1) qty_btn = Button( text=self._left_qty_text(pol), markup=True, size_hint=(None, None), width=dp(74), height=dp(78), background_color=bg, background_normal="", background_down="", halign="center", valign="top", disabled=available <= 0, ) qty_btn.bind(on_press=lambda *_: self._select_quantity_with_numberpad(pol)) qty_btn.bind(size=lambda inst, *_: setattr(inst, "text_size", inst.size)) name_btn = Button( text=self._item_text(pol), markup=True, size_hint=(1, None), height=dp(78), background_color=bg, background_normal="", background_down="", halign="left", valign="top", disabled=available <= 0, ) name_btn.bind(on_press=lambda *_: self._toggle_all_units(pol)) name_btn.bind(size=lambda inst, *_: setattr(inst, "text_size", (inst.width - dp(8), None))) row.add_widget(qty_btn) row.add_widget(name_btn) return row def _item_text(self, pol) -> str: units = abs(float(getattr(pol, "pocet", 0) or 0)) qty = units / max(int(getattr(pol, "delitel", 1) or 1), 1) total = qty * float(getattr(pol, "cena", 0) or 0) available = self._storno_available_units(pol) return ( f"{getattr(pol, 'nazev', '')}\n" f"Pocet: {qty:g} Stornovat: {self._qty_text(available, pol)} " f"{float(getattr(pol, 'cena', 0) or 0):.2f} = {total:.2f}" ) def _line_id(self, pol) -> str: return str(getattr(pol, "line_id", "") or "") def _storno_available_units(self, pol) -> float: units = abs(float(getattr(pol, "pocet", 0) or 0)) raw = getattr(pol, "kstornu", None) if raw is None: return units try: return max(min(float(raw or 0), units), 0.0) except Exception: return units def _qty_text(self, units, pol) -> str: units = float(units or 0) den = max(int(getattr(pol, "delitel", 1) or 1), 1) if den != 1: num = int(units) if float(units).is_integer() else units return f"{num:g}/{den}" return f"{int(units) if float(units).is_integer() else units:g}" def _left_qty_text(self, pol) -> str: line_id = self._line_id(pol) available = self._storno_available_units(pol) selected = self.selected_quantities.get(line_id, 0.0) available_txt = self._qty_text(available, pol) if selected and selected < available: return f"{self._qty_text(selected, pol)}\n/{available_txt}" return available_txt def _select_quantity_with_numberpad(self, pol): available = self._storno_available_units(pol) if available <= 0: return current = self.selected_quantities.get(self._line_id(pol), 0.0) initial = str(int(current or available)) def accept(value: str): try: qty = int(str(value or "0").strip()) except Exception: return self._set_selected_units(pol, min(max(qty, 0), available)) NumberPad( mode="number", allow_fraction=False, show_dot=False, decimal_places=0, max_len=4, initial_value=initial, on_accept=accept, ).open() def _toggle_all_units(self, pol): line_id = self._line_id(pol) available = self._storno_available_units(pol) if not line_id or available <= 0: return selected = self.selected_quantities.get(line_id, 0.0) if selected >= available: self._set_selected_units(pol, 0) else: self._set_selected_units(pol, available) def _is_partially_storned(self, pol) -> bool: raw = getattr(pol, "kstornu", None) if raw is None: return False units = abs(float(getattr(pol, "pocet", 0) or 0)) try: available = float(raw or 0) except Exception: return False return 0 <= available < units def _change_selected_units(self, pol, delta: int): line_id = self._line_id(pol) if not line_id: return available = self._storno_available_units(pol) current = self.selected_quantities.get(line_id, 0.0) self._set_selected_units(pol, current + float(delta)) def _set_selected_units(self, pol, value): line_id = self._line_id(pol) if not line_id: return available = self._storno_available_units(pol) value = max(min(float(value or 0), available), 0.0) if value <= 0: self.selected_quantities.pop(line_id, None) else: self.selected_quantities[line_id] = value Clock.schedule_once(lambda *_: self._render_detail(), 0) def _select_all_items(self): ucet = self.selected_ucet if not ucet: return all_quantities = { self._line_id(pol): self._storno_available_units(pol) for pol in (getattr(ucet, "poloz", []) or []) if self._line_id(pol) and self._storno_available_units(pol) > 0 } if self.selected_quantities == all_quantities: self.selected_quantities = {} else: self.selected_quantities = all_quantities self._render_detail() def _refresh_actions(self): has_ucet = bool(self.selected_ucet) has_selected = bool(self.selected_quantities) is_storno = bool(getattr(self.selected_ucet, "is_storno", None)) if self.selected_ucet else False is_limit = bool(getattr(self.selected_ucet, "limit_id", None)) if self.selected_ucet else False has_storno_available = self._has_storno_available() self.btn_storno.text = "Storno vybraneho" if has_selected else "Stornovat ucet" for btn in (self.btn_storno, self.btn_return_table, self.btn_copy, self.btn_change_pay, self.btn_tip, self.btn_select_all): btn.disabled = not has_ucet if has_ucet and is_storno: self.btn_storno.disabled = True self.btn_return_table.disabled = True self.btn_change_pay.disabled = True self.btn_tip.disabled = True if has_ucet and is_limit: self.btn_return_table.disabled = True self.btn_select_all.disabled = True if has_selected: self.btn_storno.disabled = True self.btn_storno.text = "Stornovat ucet" if has_ucet and not has_storno_available: self.btn_storno.disabled = True self.btn_select_all.disabled = True def _has_storno_available(self) -> bool: ucet = self.selected_ucet if not ucet: return False return any( self._storno_available_units(pol) > 0 for pol in (getattr(ucet, "poloz", []) or []) ) def _print_copy(self): if self.selected_ucet: self.controller.closed_receipt_print_copy(self.selected_ucet) def _storno(self): if self.selected_ucet: if getattr(self.selected_ucet, "limit_id", None): self.selected_quantities = {} self.controller.closed_receipt_storno_full(self.selected_ucet) return if self.selected_quantities: self.controller.closed_receipt_storno_items(self.selected_ucet, self.selected_quantities) self.selected_quantities = {} self.refresh() else: self.controller.closed_receipt_storno_full(self.selected_ucet) def _return_to_table(self): if self.selected_ucet: self.controller.closed_receipt_storno_return_to_table(self.selected_ucet) def _change_payment(self): if self.selected_ucet: self.controller.closed_receipt_change_payment(self.selected_ucet) def _edit_tip(self): if self.selected_ucet: self.controller.closed_receipt_edit_tip(self.selected_ucet)