From b566c72a86b48ecf8e560f0ee322a3609482a407 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 1 Jan 2026 20:39:20 -0600 Subject: [PATCH] add initial contents for terminal gui app this is pretty rough yet, all needs a refactor --- pyproject.toml | 3 +- src/wuttapos/app.py | 17 + src/wuttapos/cli/__init__.py | 2 + src/wuttapos/cli/run.py | 39 + src/wuttapos/cli/serve.py | 69 + .../email-templates/pos_feedback.html.mako | 31 + .../email-templates/pos_feedback.txt.mako | 15 + .../uncaught_exception.html.mako | 36 + src/wuttapos/handler.py | 100 + src/wuttapos/server/views/__init__.py | 24 + src/wuttapos/terminal/__init__.py | 25 + src/wuttapos/terminal/app.py | 292 +++ src/wuttapos/terminal/assets/header_logo.png | Bin 0 -> 20687 bytes src/wuttapos/terminal/assets/testing.png | Bin 0 -> 10375 bytes src/wuttapos/terminal/controls/__init__.py | 25 + src/wuttapos/terminal/controls/buttons.py | 83 + src/wuttapos/terminal/controls/custlookup.py | 49 + src/wuttapos/terminal/controls/deptlookup.py | 76 + src/wuttapos/terminal/controls/feedback.py | 201 ++ src/wuttapos/terminal/controls/header.py | 281 +++ src/wuttapos/terminal/controls/itemlookup.py | 47 + .../terminal/controls/itemlookup_dept.py | 70 + src/wuttapos/terminal/controls/keyboard.py | 200 ++ src/wuttapos/terminal/controls/loginform.py | 313 +++ src/wuttapos/terminal/controls/lookup.py | 364 ++++ .../terminal/controls/menus/__init__.py | 27 + src/wuttapos/terminal/controls/menus/base.py | 59 + .../terminal/controls/menus/context.py | 46 + .../terminal/controls/menus/master.py | 132 ++ src/wuttapos/terminal/controls/menus/meta.py | 70 + .../terminal/controls/menus/suspend.py | 69 + .../terminal/controls/menus/tenkey.py | 164 ++ src/wuttapos/terminal/controls/timestamp.py | 74 + src/wuttapos/terminal/controls/txnitem.py | 175 ++ src/wuttapos/terminal/controls/txnlookup.py | 97 + src/wuttapos/terminal/util.py | 37 + src/wuttapos/terminal/views/__init__.py | 25 + src/wuttapos/terminal/views/base.py | 107 + src/wuttapos/terminal/views/login.py | 90 + src/wuttapos/terminal/views/pos.py | 1903 +++++++++++++++++ 40 files changed, 5436 insertions(+), 1 deletion(-) create mode 100644 src/wuttapos/cli/run.py create mode 100644 src/wuttapos/cli/serve.py create mode 100644 src/wuttapos/email-templates/pos_feedback.html.mako create mode 100644 src/wuttapos/email-templates/pos_feedback.txt.mako create mode 100644 src/wuttapos/email-templates/uncaught_exception.html.mako create mode 100644 src/wuttapos/handler.py create mode 100644 src/wuttapos/terminal/__init__.py create mode 100644 src/wuttapos/terminal/app.py create mode 100644 src/wuttapos/terminal/assets/header_logo.png create mode 100644 src/wuttapos/terminal/assets/testing.png create mode 100644 src/wuttapos/terminal/controls/__init__.py create mode 100644 src/wuttapos/terminal/controls/buttons.py create mode 100644 src/wuttapos/terminal/controls/custlookup.py create mode 100644 src/wuttapos/terminal/controls/deptlookup.py create mode 100644 src/wuttapos/terminal/controls/feedback.py create mode 100644 src/wuttapos/terminal/controls/header.py create mode 100644 src/wuttapos/terminal/controls/itemlookup.py create mode 100644 src/wuttapos/terminal/controls/itemlookup_dept.py create mode 100644 src/wuttapos/terminal/controls/keyboard.py create mode 100644 src/wuttapos/terminal/controls/loginform.py create mode 100644 src/wuttapos/terminal/controls/lookup.py create mode 100644 src/wuttapos/terminal/controls/menus/__init__.py create mode 100644 src/wuttapos/terminal/controls/menus/base.py create mode 100644 src/wuttapos/terminal/controls/menus/context.py create mode 100644 src/wuttapos/terminal/controls/menus/master.py create mode 100644 src/wuttapos/terminal/controls/menus/meta.py create mode 100644 src/wuttapos/terminal/controls/menus/suspend.py create mode 100644 src/wuttapos/terminal/controls/menus/tenkey.py create mode 100644 src/wuttapos/terminal/controls/timestamp.py create mode 100644 src/wuttapos/terminal/controls/txnitem.py create mode 100644 src/wuttapos/terminal/controls/txnlookup.py create mode 100644 src/wuttapos/terminal/util.py create mode 100644 src/wuttapos/terminal/views/__init__.py create mode 100644 src/wuttapos/terminal/views/base.py create mode 100644 src/wuttapos/terminal/views/login.py create mode 100644 src/wuttapos/terminal/views/pos.py diff --git a/pyproject.toml b/pyproject.toml index a1584f1..a7f6141 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,8 @@ dependencies = [ [project.optional-dependencies] server = ["WuttaWeb[continuum]"] -terminal = ["flet[all]<0.80.0"] +# terminal = ["flet[all]<0.80.0"] +terminal = ["flet[all]<0.21"] [project.scripts] diff --git a/src/wuttapos/app.py b/src/wuttapos/app.py index 4e0859c..7ede529 100644 --- a/src/wuttapos/app.py +++ b/src/wuttapos/app.py @@ -31,3 +31,20 @@ class WuttaPosAppProvider(base.AppProvider): """ Custom :term:`app provider` for WuttaPOS. """ + + email_templates = ["wuttapos:email-templates"] + + def get_transaction_handler(self): + """ + Get the configured :term:`transaction handler`. + + :rtype: :class:`~wuttapos.handler.TransactionHandler` + """ + if "transaction" not in self.app.handlers: + spec = self.config.get( + "wuttapos.transaction_handler", + default="wuttapos.handler:TransactionHandler", + ) + factory = self.app.load_object(spec) + self.app.handlers["transaction"] = factory(self.config) + return self.app.handlers["transaction"] diff --git a/src/wuttapos/cli/__init__.py b/src/wuttapos/cli/__init__.py index 3dc0190..e9b1fec 100644 --- a/src/wuttapos/cli/__init__.py +++ b/src/wuttapos/cli/__init__.py @@ -28,3 +28,5 @@ from .base import wuttapos_typer # nb. must bring in all modules for discovery to work from . import install +from . import run +from . import serve diff --git a/src/wuttapos/cli/run.py b/src/wuttapos/cli/run.py new file mode 100644 index 0000000..867be54 --- /dev/null +++ b/src/wuttapos/cli/run.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +See also: :ref:`wuttapos-run` +""" + +import typer + +from wuttapos.cli import wuttapos_typer +from wuttapos.terminal.app import run_app + + +@wuttapos_typer.command() +def run(ctx: typer.Context): + """ + Run the WuttaPOS GUI app + """ + config = ctx.parent.wutta_config + run_app(config) diff --git a/src/wuttapos/cli/serve.py b/src/wuttapos/cli/serve.py new file mode 100644 index 0000000..36d35f3 --- /dev/null +++ b/src/wuttapos/cli/serve.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +See also: :ref:`wuttapos-serve` +""" + +import logging + +import typer + +from wuttapos.cli import wuttapos_typer +from wuttjamaican.util import resource_path + + +log = logging.getLogger(__name__) + + +@wuttapos_typer.command() +def serve(ctx: typer.Context): + """ + Run the WuttaPOS web service + """ + import flet as ft + from wuttapos.terminal.app import main + + config = ctx.parent.wutta_config + kw = {} + + host = config.get("wuttapos.serve.host", default="0.0.0.0") + kw["host"] = host + + port = config.get_int("wuttapos.serve.port", default=8332) + kw["port"] = port + + # TODO: we technically "support" this, in that we do pass the + # value on to Flet, but in practice it does not work right + path = config.get("wuttapos.serve.path", default="") + if path: + path = path.strip("/") + "/" + kw["name"] = path + # kw['route_url_strategy'] = 'hash' + + log.info(f"will serve WuttaPOS on http://{host}:{port}/{path}") + ft.app( + target=main, + view=None, + assets_dir=resource_path("wuttapos.terminal:assets"), + **kw, + ) diff --git a/src/wuttapos/email-templates/pos_feedback.html.mako b/src/wuttapos/email-templates/pos_feedback.html.mako new file mode 100644 index 0000000..0fe52e4 --- /dev/null +++ b/src/wuttapos/email-templates/pos_feedback.html.mako @@ -0,0 +1,31 @@ +## -*- coding: utf-8; -*- + + + + + +

User feedback from POS

+ + +

${user_name}

+ + +

${referrer}

+ + +

${message}

+ + + diff --git a/src/wuttapos/email-templates/pos_feedback.txt.mako b/src/wuttapos/email-templates/pos_feedback.txt.mako new file mode 100644 index 0000000..a6f1c9d --- /dev/null +++ b/src/wuttapos/email-templates/pos_feedback.txt.mako @@ -0,0 +1,15 @@ +## -*- coding: utf-8; -*- + +# User feedback from POS + +**User Name** + +${user_name} + +**Referring View** + +${referrer} + +**Message** + +${message} diff --git a/src/wuttapos/email-templates/uncaught_exception.html.mako b/src/wuttapos/email-templates/uncaught_exception.html.mako new file mode 100644 index 0000000..34c09d8 --- /dev/null +++ b/src/wuttapos/email-templates/uncaught_exception.html.mako @@ -0,0 +1,36 @@ +## -*- coding: utf-8; -*- + + +

Uncaught Exception

+ +

+ The following error was not handled properly.  Please investigate and fix ASAP. +

+ +

Context

+ + % if extra_context is not Undefined and extra_context: +
    + % for key, value in extra_context.items(): +
  • + ${key}: + ${value} +
  • + % endfor +
+ % else: +

N/A

+ % endif + +

Error

+ +

+ ${error} +

+ +

Traceback

+ +
${traceback}
+ + + diff --git a/src/wuttapos/handler.py b/src/wuttapos/handler.py new file mode 100644 index 0000000..9f7f01f --- /dev/null +++ b/src/wuttapos/handler.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +Transaction Handler +""" + +import decimal + +import sqlalchemy as sa + +from wuttjamaican.app import GenericHandler + + +class TransactionHandler(GenericHandler): + """ + Base class and default implementation for the :term:`transaction + handler`. + + This is responsible for business logic while a transaction is + being rang up. + """ + + def get_terminal_id(self): + """ + Returns the ID string for current POS terminal. + """ + return self.config.get("wuttapos.terminal_id") + + def get_screen_txn_display(self, txn, **kwargs): + """ + Should return the text to be used for displaying transaction + identifier within the header of POS screen. + """ + return "-".join([txn["terminal_id"], txn["cashier_id"], txn["transaction_id"]]) + + def get_screen_cust_display(self, txn=None, customer=None, **kwargs): + """ + Should return the text to be used for displaying customer + identifier / name etc. within the header of POS screen. + """ + + if not customer and txn: + return txn["customer_id"] + + if not customer: + return + + # TODO: what about person_number + return str(customer.card_number) + + # TODO: should also filter this by terminal? + def get_current_transaction( + self, + user, + # terminal_id=None, + training_mode=False, + # create=True, + return_created=False, + **kwargs, + ): + """ + Get the "current" POS transaction for the given user, creating + it as needed. + + :param training_mode: Flag indicating whether the transaction + should be in training mode. The lookup will be restricted + according to the value of this flag. If a new batch is + created, it will be assigned this flag value. + """ + if not user: + raise ValueError("must specify a user") + + # TODO + created = False + lines = [] + if not lines: + # if not create: + if return_created: + return None, False + return None diff --git a/src/wuttapos/server/views/__init__.py b/src/wuttapos/server/views/__init__.py index 90e9371..c70be9d 100644 --- a/src/wuttapos/server/views/__init__.py +++ b/src/wuttapos/server/views/__init__.py @@ -35,3 +35,27 @@ def includeme(config): config.include("wuttapos.server.views.products") config.include("wuttapos.server.views.inventory_adjustments") config.include("wuttapos.server.views.customers") + + # TODO: these should probably live elsewhere? + config.add_wutta_permission_group("pos", "POS", overwrite=False) + + config.add_wutta_permission( + "pos", "pos.test_error", "Force error to test error handling" + ) + config.add_wutta_permission( + "pos", "pos.ring_sales", "Make transactions (ring sales)" + ) + config.add_wutta_permission( + "pos", "pos.override_price", "Override price for any item" + ) + config.add_wutta_permission( + "pos", "pos.del_customer", "Remove customer from current transaction" + ) + # config.add_wutta_permission('pos', 'pos.resume', + # "Resume previously-suspended transaction") + config.add_wutta_permission("pos", "pos.toggle_training", "Start/end training mode") + config.add_wutta_permission("pos", "pos.suspend", "Suspend current transaction") + config.add_wutta_permission( + "pos", "pos.swap_customer", "Swap customer for current transaction" + ) + config.add_wutta_permission("pos", "pos.void_txn", "Void current transaction") diff --git a/src/wuttapos/terminal/__init__.py b/src/wuttapos/terminal/__init__.py new file mode 100644 index 0000000..94479f9 --- /dev/null +++ b/src/wuttapos/terminal/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS Terminal +""" diff --git a/src/wuttapos/terminal/app.py b/src/wuttapos/terminal/app.py new file mode 100644 index 0000000..0fafd5c --- /dev/null +++ b/src/wuttapos/terminal/app.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS app +""" + +import logging +import socket +import sys +import threading +from collections import OrderedDict +from traceback import format_exception + +from wuttjamaican.conf import make_config +from wuttjamaican.util import resource_path + +import flet as ft + +import wuttapos +from wuttapos.terminal.controls.buttons import make_button + + +log = logging.getLogger(__name__) + + +def main(page: ft.Page): + config = make_config() + app = config.get_app() + model = app.model + handler = app.get_transaction_handler() + + # nb. as of python 3.10 the original hook is accessible, we needn't save the ref + # cf. https://docs.python.org/3/library/threading.html#threading.__excepthook__ + orig_thread_hook = threading.excepthook + + hostname = socket.gethostname() + email_context = OrderedDict( + [ + ("hostname", hostname), + ("ipaddress", socket.gethostbyname(hostname)), + ("terminal", handler.get_terminal_id() or "??"), + ] + ) + + def handle_error(exc_type, exc_value, exc_traceback): + + log.exception("unhandled error in POS") + + # nb. ignore this particular error; it is benign + if exc_type is RuntimeError and str(exc_value) == "Event loop is closed": + log.debug("ignoring error for closed event loop", exc_info=True) + return + + extra_context = OrderedDict(email_context) + traceback = "".join( + format_exception(exc_type, exc_value, exc_traceback) + ).strip() + + try: + uuid = page.session.get("user_uuid") + if uuid: + session = app.make_session() + user = session.get(model.User, uuid) + extra_context["username"] = user.username + # TODO + # batch = handler.get_current_batch(user, create=False) + # if batch: + # extra_context['batchid'] = batch.id_str + session.close() + else: + extra_context["username"] = "n/a" + + app.send_email( + "uncaught_exception", + { + "extra_context": extra_context, + "error": app.render_error(exc_value), + "traceback": traceback, + }, + ) + + except: + log.exception("failed to send error email") + + try: + + def close_bs(e): + bs.open = False + bs.update() + + bs = ft.BottomSheet( + ft.Container( + ft.Column( + [ + ft.Text( + "Unexpected Error", size=24, weight=ft.FontWeight.BOLD + ), + ft.Divider(), + ft.Text( + "Please be advised, something unexpected has gone wrong.\n" + "The state of your transaction may be questionable.\n\n" + "If possible you should consult the IT administrator.\n" + "(They may have already received an email about this.)", + size=20, + ), + ft.Container( + content=make_button( + "Dismiss", on_click=close_bs, height=80, width=120 + ), + alignment=ft.alignment.center, + expand=1, + ), + ], + ), + bgcolor="yellow", + padding=20, + ), + open=True, + dismissible=False, + ) + + page.overlay.append(bs) + page.update() + + except: + log.exception("failed to show error bottomsheet") + + def sys_exc_hook(exc_type, exc_value, exc_traceback): + handle_error(exc_type, exc_value, exc_traceback) + sys.__excepthook__(exc_type, exc_value, exc_traceback) + + def thread_exc_hook(args): + handle_error(args.exc_type, args.exc_value, args.exc_traceback) + # nb. as of python 3.10 could just call threading.__excepthook__ instead + # cf. https://docs.python.org/3/library/threading.html#threading.__excepthook__ + orig_thread_hook(args) + + # custom exception hook for main process + sys.excepthook = sys_exc_hook + + # custom exception hook for threads (requires python 3.8) + # cf. https://docs.python.org/3/library/threading.html#threading.excepthook + v = sys.version_info + if v.major >= 3 and v.minor >= 8: + threading.excepthook = thread_exc_hook + + page.title = f"WuttaPOS v{wuttapos.__version__}" + if hasattr(page, "window"): + page.window.full_screen = True + else: + page.window_full_screen = True + + # global defaults for button/text styles etc. + page.data = { + "default_button_height_pos": 100, + "default_button_height_dlg": 80, + } + + def clean_exit(): + # TODO: this doesn't do anything interesting now, but it could + if hasattr(page, "window"): + page.window.destroy() + else: + page.window_destroy() + + def keyboard(e): + # exit on ctrl+Q + if e.ctrl and e.key == "Q": + if not e.shift and not e.alt and not e.meta: + clean_exit() + + page.on_keyboard_event = keyboard + + def window_event(e): + if e.data == "close": + clean_exit() + + # cf. https://flet.dev/docs/controls/page/#window_destroy + if hasattr(page, "window"): + page.window.prevent_close = True + page.window.on_event = window_event + else: + page.window_prevent_close = True + page.window_on_event = window_event + + # TODO: probably these should be auto-loaded from spec + from wuttapos.terminal.views.pos import POSView + from wuttapos.terminal.views.login import LoginView + + # cf .https://flet.dev/docs/guides/python/navigation-and-routing#building-views-on-route-change + + def route_change(e): + page.views.clear() + + redirect = None + user_uuid = page.session.get("user_uuid") + if page.route == "/login" and user_uuid: + redirect = "/pos" + other = "/pos" + elif page.route == "/pos" and not user_uuid: + redirect = "/login" + other = "/login" + else: + redirect = "/pos" if user_uuid else "/login" + + if redirect and page.route != redirect: + page.go(redirect) + return + + if page.route == "/pos": + page.views.append(POSView(config, "/pos")) + + elif page.route == "/login": + page.views.append(LoginView(config, "/login")) + + if hasattr(page, "window"): + page.window.full_screen = True + else: + page.window_full_screen = True + + page.update() + + # TODO: this was in example docs but not sure what it's for? + # def view_pop(view): + # page.views.pop() + # top_view = page.views[-1] + # page.go(top_view.route) + + page.on_route_change = route_change + # page.on_view_pop = view_pop + + # TODO: this may be too hacky but is useful for now/dev + if not config.production(): + + training = page.client_storage.get("training") + page.session.set("training", training) + + user = None + uuid = page.client_storage.get("user_uuid") + if uuid: + session = app.make_session() + user = session.get(model.User, uuid) + if user: + page.session.set("user_uuid", user.uuid.hex) + page.session.set("user_display", str(user)) + + txn = handler.get_current_transaction(user, create=False) + if txn: + page.session.set("txn_display", handler.get_screen_txn_display(txn)) + if txn["customer_id"]: + page.session.set("cust_uuid", txn["customer_id"]) + page.session.set( + "cust_display", handler.get_screen_cust_display(txn=txn) + ) + + session.close() + page.go("/pos") + return + + session.close() + + page.go("/login") + + +# TODO: can we inject config to the main() via ft.app() kwargs somehow? +# pretty sure the `wuttapos open` command is trying to anyway.. +def run_app(config=None): + ft.app(target=main, assets_dir=resource_path("wuttapos.terminal:assets")) + + +if __name__ == "__main__": + run_app() diff --git a/src/wuttapos/terminal/assets/header_logo.png b/src/wuttapos/terminal/assets/header_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..96fae964432932828436639579f10d04ff4dc5e2 GIT binary patch literal 20687 zcmV)OK(@b$P)EpmZP>8j7a4jo0l(M}gdYqG1SDAoB*+Xh zWF`$~hLfk`?e3dz<#=-5Iq$vv&_mAj1PrMW*n_f z2KtcwKLmgUxC=~v5+#rTxZq@g$xompyX>b6PCj%4f2a%xg17%U^FKA=qAm+ee!P@` z^xhY5)59uF7N9Il=f_fFef71MuMWTCfCVKBHTkiIbMPhi6Uj;W@jSPv%L0=hBPHbi zAEI9r4r;XJq|~A?3rv0_CGpq4v+}v#Deo|KE29?SWRXpNct8;Uhc8}xc{oTQrLD}( z1tkkL`B7q$d~5G>cfRIG1g{2%3caYy0+Sy>N%+T|UsB$2^Hf@k#)*R#VzR*GhbbYi zUHrn@SIi1c)jFsG`q9F47MT1HC6h<~KcxSBST01(%Z$A%W(!Cb5%vcl0r22U;fuA0 z#%Pu_FYb@{ID0`^aPlD=AN(}i@#AWv^hOv>mv0{Qf#}4~GLp35WP!=|8xA<@GS&X_AfUnGl_1d9m!eUv0` zkJnr5ePpIrn}2Ft;jPWeSqBS6v?$C%P5vsp#;*@rD!A*mJG%fmoH=TG!Yij zWT7U16-%NAliD(M7IXb;YZvZ@9`1zuS(Gk?j=tbzfys9%@gI%ryUbo&Zojy8Z<=xT zt==VZ%uVq6l?5dW)A=qX)>|{a$?Ofmz0E5li^AOXlXtV>tl67b)*`}wpo4%P+~0sc zFfBRuNnO8WHxI6C9$!+hyxX}{68(7w>}AbGg#ADVB-syi!atC{34tIuoY2MA2`PU| zz0(;u@>=0=N*g61aoge6v&>>r=Yy4#h;ScxHX#s1rxPZ(^|-(FxKw}Yw5414HM=!p z@YFYQm07x24Ew=S0$K3>2NiuFl*E^|i1MdS?L=L_aWBEkNwV?tnZ zQt=e;D4cEWO!?k?W8m$cJ7uQ(l-6f6t?BL!7FGFK$ArN6EsdyLJL#8JO*mY-a+oYV zN;P=y?>4Mi{rcd(QR5d!oP5{@{yV0Gyrx?X{=pe4ZuGzaT{)-}jy+{6jb6d`lAG)M z_aLKD3)A^%tI1D!mJxyAojkiFof~udTKD2a-hAlyzLJ%2`QdJI_jIHA^l4>dHq91g z`DkMTllZ53&>{FX4kck|k@9ms_8IlDT6wUFB>nV7`0DtT@|R!LORloI*v<8kHy!v> ziOCU@G}U)Q3Vvb;Pvpi~X=4AqIcwZ|ZV<+wlwU`8m?!;uelhX)QSY0OpMnztPbaXr zboTpo_LC#`T;FahBkxB40STA05N`&Lm0fZ+(KfcZMcd>fo=xCi6RRH)Vh9X;N~l`n z)_1P;*n8SD{nIZQS1-Ai_(~uQOYYsFe)80M85~551p86XCVySmfl&&7P=>t2(5ab^ zS^K{-#e3T~`>MKnXP!TbUmlmnpGzKt>u?^epPp-rX!4Oaoxj05&HJ2#K;XU2B-_GQ z3&&e_;WGV1&m+mk6Q4#U(?9t_?=_+2Q*_y!M4xRes`9gf$q$eC8vw5=J^!H9DfhBZ z?Po5(HVIyTrg{p8*ZW6HP@b6PHZ;F-eGJzhSY|u!;7TEs%I-+ zJ8WHzj?X^3y(r4Z5|b2AKZ%lqSeL@LPd8}64O_!D^9%^B{<1Tn)}|+Fm6|6T#-U!@ zn!JmauH+Wkb(x9eOKp2REm0&7)=N5M1qF0G&-D z)u5E!weya|t>zZZwU1vmA@Ikx+~9YAMf3A)l{iDER6oqY!gy%h&iZF;D>K#$BUeMC ztOz6EZkE3`TW1pnEX3qvWIEp~X6&RXNdpiuGu}_4EQ$cf;?zo^%Pa>m*w^9n0K_AS zirSLi>tlrn6CoZi<+3xjNSU+iCdZ* zZ_EqKei0~IB+Cb^o;>@VyjbYWfV3C zaFQ>>)Hb*=dcfo}S6ISr@`8Dxt&Sh9RmL${YxP6MWomkHLCHrPlkXCeb~^3Jq~k~R zT#(7Q%*KUUnsTrTCwalv#mSOo6bMDB%{SOL*K>TynwvohS{aBrgdQ&mkz?&%Sj^4# zYnzfYQ!0lS@)fZ!C8k6RoRMEp^3lbFKmZ^JM;GU(Va$?T*6hoxj(gQ$!GzLf7~4!U z22wE#@_8BNTwKtj4L4{DjahX(W*>fbdVDiyQZ-?hK?m+L!#AJ3F+5scAIBSwQ-}Mc zb6WHZ+IT5DUCl2l^3lYEKmcIW=^Pym8vz$ES0iz&JHL6Ftk8Z|i+G5Q5}#tx&gJcb zbh5?e?P=EM%WZzWb>I}{`D|DwG#jRt5`-L5%VNQLY4;IoDwl(s+B7I#wPxwCCf#TH z$yRYukdGiH1OfoV^EXbO`ua}VTE|L1T?>|jT2baIW0YtCZ*m$Hmy<|H6-8F4PV$*J z%vUZ_S{O;}M!Pj$S)EQ3wI#_iVmhV!yQbhP#fiC&Cdv(dZ??TVord10<_~gx@(FRV z-R~p!LQEh4VDj+odxKT}mb)xM%4j7MWu@Ds{QIi1bl^lO@RPC(#;J}3Bgqx|h3wU5 zmMz4%GYMl!9$o12v051#EW@RviGI1>_Lyu*Uq`RXf59&Vt&tj8iUf#gVkVH_$uiS_)R00Q6MGQPmbA({xrd)& z3I|pd@VxjSldA%9&*I(GFWD2zX~=Hxf`--T-sL*G6yTPx2M;@U)~DX+4s$9b7cW-f zf|8FECIkWi=5PM#z@T1gwjxTTVGawv5Yf|#?la2?plP4rdB)l}2s&iodmc}bE0q7K^Z6?MG-^HHyH`8h zzC(irB_AD32n2v+(>K3ySG>A*O^8t;NyoTCGuX%(u@GdLCWj&wI6{Ag>hq~Y=3$ai z7v=o8fDb>_Gg1D4*`?b;D8dp5K^mX@zpocIetGvwL{|XBCaJ_ynkGJAr7V#FnIlBRG+hdznuabf z@&UGZ8p@o{WYTy8Z|&PMJ77B0g6 zD(wtn>)9kt_o+s&9P7(;9%6(RsC>vVArOFMdiVD(uUs!)^^-T=9{zp3#EuP0%ux(^ z@|N?Pw-BF>VV;UIDkDk8NyKAO3Ic#2A*#c%P7vtKM3dr)2v6$;GeCo;0!`{rUjb%R ze6_rO#^bO3@_ZaTyd!(QWi*_8_nfY^RR5GZsH>__WEhtEz8Jk2A1>1&0D^Sz)}Qrm z-&)-fjPAd9`yYIsv*H|}nz+YQ*uRzfmEFB63uquGSyU49!8oNECWIXHDI%Caof|b8kQ4%$V&?J`$RM_ISO6A+ zehynnVb~PSD5didtwzd~lbKKM4rMKdB#>)dT z=XnWYDoi=aAO^YprT^`JSp$$z>oYetGdVz-7*ITk5|n^M28@d^qWu0$kX1?~14@A$ zNi2;}^j>8fKrmbqK$Km)43Gk<89NJnT4q25<=!^qMoi6z0ej0z=&jmmS(P4Lbec7W z&(XY)lN6ez7O;GPm=Fl;-~Ii@FFs4D0s|BCcas+n|H}d)U?xMr5SlIpYz&ynTv&bn zYk%@z(*UAaV6|B;hJ(IT=KPGOK!P}wWU3*c6h+Czr8$x_`U36bg5zsATM92WNV@x^ zC*wd%%shzVr@MdE?L&47RT?r%JN#i&i^D2{+^F@8BL5>Y;eDa80GFgVg{CHAcF-0}xWUc@L~2J>9&pMSpPD;&OF*GmM0SQla8vf1)$noUg%n(DD=S{3w*g z+Z#`J7-KbL^uNM|EG7>Bc1`Dv-&y{AD-Sq2KK%1vZ?miSTae`#SdNeHfAPeKk?_UO z|6?X~F`dz(I=SF!949FQ6EO+-1cjN$q+x1^oO0lJ!X!yaJs@4jeyF6IISv3;o0b8D zUM&v*Ij8Fo6s)2^6^?XGkfW5E6{hwoM3|0936LO1x;=p%${nLr0a{|@rn-XUR`j?j zK10VVe(!WND&D_4c}Fl|v{H4RS;BS$gzWck10mq=mKg#+_M(R2Kb!tHn-W9}6}|QE zi{~$M=RYqi5CFPqz4B!vqw?P?f>2D3uEYs7R|EhCw=n>e!~q~xD;UsOWHPz54KV=mhz`S! z8EFYSNYC_bk%1QLh}{G$;K z38bPYcj$zjv<=#s!g>j?h{I$m@SMY21tPJDpl}*X_?jlyiUp44n5GT|Q3~C!Z>SD0 zJDASuQ&%?=u~)#+P~s5@Ek*=}0NQoT38C40bbgYYU)otP4{Ll%5R+Tm&a>%RyisD< zGp(RJZSjHVEUOBPo+*nwQ&7#DqWp=>=XB2w`u98YCr`}ISKbYKU+r6e;hTG^<47Jv z005XG2r~5k=l?$`HIVw4?~kWm{(}{9Spngf{>94Cz0c2-I9{(n2p|AxBmZPdK>|>~ zJrg^S3eDLhcPmfvlh}pSATc8^)6$IAaEeIADHNh%Mx%&>83zehWqO@YqAzcA;eZ!J zZp;m|W!uV+88FT#CKYK}p9{vu#hJ4|#ZH9hQIt|CP6d+ElJH8wDu93r^&ET8xT@-x z^=!3#b}gv^FE1jxwxX$p@Dm|3v&+aKMM~Em7uNi9o@U&8d;Q75z03P=7{7Pqy#BiS z&btNks~j?<_z2^UgeC&aZ0?7R<2`)-=E6fD`nVs;g5OyB^=-;8`<2bUB{M0 z0V)I8Om6iP2mzoTGLUnG)n>S#MVT_8MU$q=a|+E>7TAE|QzI^h9ElE|?{3iL@ZF8sSaD8g9YrFLqbDrv+o8qRg&;Je>>Ovm=2`;% zXf>Q&IUm)Nqa~36%br*ZdDB=;47#K3%u}4-$iQ-Ub1K!f(AGXV0hyQIjEpmdQ#Z!7 zBUI|U`ExQrf53O%zIM=8jFvqpo~5@ve-}Nh@7jZw>I~NO0jF6&fvyO$3l&B_V5y4r;r5cW6hCvw%ND^V1gnS|fc0I?}DS*-I z__MU;N-W^Cp=h7J;}Y&XDoG_s0Ta=l98sDrx+KIIQ$~95c;KxA5CcMSoCC(sd5#XK z{hJV%j-R3PZeLjAEz+yYyT>{AMelyG0Oo-nD2!5a$K-RtBpLEr$>C{z=gr+MxofVp z(RNU7GVz)`5rCxr8S~8SkGd=GB+y^7M%>oCP99wQ+~I%NEFG%PT=eeXvz^>)&B(tt zU^&}vTpZp^z2=x(ZiBVK?d9mrxhTpIV9|~id zrf5g+Qk0WV*dz$p<3jm#HDZmNosSrhr9#AH01<480iv=9Lj+(N&{WK&Bo<%-5zPPs zQG%$9qX2*)L*HWo0sulbr2v2+q|uLp{5^8;{DmjY5}kt2{@>E0!*zEAFZH+eg4E8G zz0O~@C$eW{&(3N4V=}ox91V)D43RR0{BRsg90zPvK`BFp9G&n@RE~U}k}Ao&xIjHT zt*yw+<+Y-CeC4`N4Rbx_`d}6Dy@Ggkm{wQB%@ZLxV49`L8_$hSL-6MDvy-tV9)#%2 z{K}Uus(KdxVS6LI|MN$CUXTd)!|Bd>`5WWhpzChmMWvZonV)Yqyv_v}y{U=an5S8O zXXPI0R_wPrzcsqI8D0n?k$OB=Ngm&brltPT7Y2^)mwS0HY9_I7U*p<7fBQ+;njncz zUwdh5_|>BFm9g~5F&>EW*+oNc`^vk`g89vQqo0_P z{!qBToi)DpiOFt?jwnA%)CP$iqe^U3Eq3Jy*SsiYsA$Szogf1b{f0|Imi5!ItehpWeTBuy za9V;63}1e7oDmuIRZT}(oC6G$#E{ZMmGKQpE>Yb-}n{vyr{14`MU7Z z6RBc&dv}`Vs2kQW`-sZxPgV-IIxFRj-rb&4NJHD*E3rava9`Br>+;k@j#5|yclOnZ z(n@cQEI+r?txMJ{tFQaT?iWmY^DR93Ird$&z3iw<*2=eT+zJI#M6@>INV-H_ z6$OZa%^?;8bF812gKI~Hkdru*RmaWAQu&=?u4D~3Z<20#^YVJBeD39!VTfX!&Fr~B zdcxqJJ;r(MW!w+CvUzO$!tboU7hy}B_>8TSL`r>4z{NM`YH)CiK3k}+ zGaO%8$sL(DOY(lr4CZU{JkpCTNV3~gPWL|3*`a%ni`UhNSJ(3vU#qIBTj8QwE@7{D z8N43PTbsov+Imycxza7E#*6)$kxr@&=8%4Y?I6B9%&PoUx_aHJNZRpduB_-BTfDZ7 zoAh&!TeHtdLT!YezmYLlHvz0}hlQTL!Gq z4hTa?$tp@*k1!+xodQ(Ez8+h%=#v-%eA)rGC{Pb%fg**8m_0KFzyuF(AfSHdhL-T> z*)33mMuTS#D>j?oHh6a}8#R$)_^jX+0iT?|2(W$kSAmEFR;2N?!4fAlD{M9qbe>A+ zc)f^9vR|lQp6qHTrH0U)T~+UVJ6EaSyu4N_QZgLBdy^}C_0vUYo282LI(~k(Z*6?; zGT96WolJyl(z2ypnU5~Zl|9LR#W`G?zG6(mHEf1(BCYh!e~mgm_-rH(Rm}=`yTI;8 z+lo4QsbL@L&2miVY?+HE*GK^xCAQn*yVfexBdd{B%Eg!bwsp~}I}96TG*lVspjzzY zbvuaMkQpcNG%6lNE031TW0&PXHpeO_AWG|}BFyNNs%3Nu;2@k8lgdeBTzFjQLr7Wu z&N$_n+Us^azI>xtV@Ad(f9(5rL$=PyMEKO%MAvU#_uSwbpF=$T!`Xz=b~kU#@+xLQ zB&!t4J$+k;tRh=X9NhkloTSXkZ~gbd%*mlCn4<@rlHTEbAxrU?V@EcRb!-rB3^@;k z2(m7qQwSm|ixMhK557f8a)0{vBy4J7VTt2UP0wOwo)_uZCm{?F2SGe+RoB;6 z4M$E_PZ~uC!sCJ^fXI{lnw99baAUtgos6r6Wa#^=U~*>X*leZ-I!-K^7a~80OCw%1 zcQ0vvOo$TAQCb6&N16u5=S>R4p?~<%S8j@UT37i#+)($un^asfEYx5o;j+XYR0=|$N($M2d5K9- zefL7li9qs)>pZrb*ooIOp9;+yGafFn=_D#kzDdcwh}-D#^TK^OkZdY)V#N3YGBYK` zCO!38==EY?TGf$(s52@Z3Wk%%zBbJiv796|%`D`A1M1zJ)R`?pBt*KHU&~+9?y35nuWI&;8nOZcjNPv}wvZ zY@gj;Y1~diL_~}QL@XiZ2#KU1u=DYr#Bzc&QRq0M5O7SU8%`^`o;I3H^37#z1VbOl zIgJKv>;m8G%}Ink;*+~+OiANU6(kKLF-1#7U&KV7bhZGQ^$?@jfi{ulCrxJj>9oWpM~e=wQ=PY|?eH5>yii6>mnoi$ltrXn3WrOJ8PV+X1cA<=@8ZuHCQ ziF(<3WY7{ad?&HwbP~{)0muy%1rMo)+8Z|9j7U>}RKvkiNI`?C(KR_Ns+yaxd7)aC zL!fY8sF+q^Ot`!?LaA}cO_IxgemI&~uXU%#vwru%qbJ9Y+81AP=G~N`w=Zp8xqf}U z6tD1Qz5*XWG1f&Yo>@Yx`f|CohF|%IpMUQ9)=Dw2^WSUz{D}7*>e|cKj;osI(sAMv zqzZNVbx$sL<0hASQ{Rr-^t}fwAf_ z38rB*f##`Gczk==E<8OQ`((p4mNY-(1B#O3xkG!7!E>pN*fGI#uzI87kRf8RW$83K zmFSQ(+8Uer;pGl(oZ3-|xe%>7_DJGa!jw;zEm|2D;)^9X=bQ$yI(=D+$Gs|@PN_Tt zrwW#=sRC+-#P}P>3d?Cvb$awRwF510py$RiE$y6YcoOAAFCZe5J1K{SK^{(yxpKA_ zECDA`Xyrl{N@S*$LQ|!ReGx@-HfPH+70vQ;Y-71H740=L57gDOl0EWpYzJm63a&I3 zMn!2(c! zt)dn@Hc!T#%lrn;NIrs^yH(A&mp4-BnOgn%Pw#A&Dw4ES!We&l`{&1YVP9DCTRDk@ z6psi6@+7`J`SZ~@5%O7#rVfQoGlec3M^AurfBM{2|8~xGQ<4UJnz<&P3KH=h8PgVx z`cVM^#%FvEkT8~JrTzXLehJN?beygQvnU+^j!0A#rf>>^grhh^+FYyMX<7LKIn-bE zmvCZpG{rJhk7cMpNz*!tv`O~3xP%7B51$9=6K99A{VbLOS{xTEM-|{V1~i+l22p{* z?ILg5`%6sH17+w8WR)_7fT-zZ717B>gDU#GI6LBM6fhWuqub@AIOEB9oa4bX=PgZ* zqFb5d=((@(@KKhd&fPqnc4|2G6006CGp)E+35N6H#LTp4LSw=3gFqjoI&H)gEkB50 za|q3NAUVCNY)^N{ERsrcs$n99s7&Il2yM(Nf}a%iBep1v*c{d8gv5v)W3I$TJP1>F z&)PE|55}|kaQt9Ay)Z7%yR+%s8Xfd6dWq`$c`eL0Tr=R1E;m-R%5!UCK`+TbZV@gj z)Jt55^En=InjQmg`5&z}wmGi3zH)8-N=e?mnU^bNnv?nO?==5)8h=;>DPRBx3FX=X zFcHW9kE1^y=cEqGKtN#RNPfU|9Y(-LFL>E5*Np(F7Qw!ag-aYod;&C#0tO|^{fL5m zno>zJbz;5}kJIV3$oBw>qKSJ^(rGY|Y@j*N^@EfxxA>!a832%PZ4toeuLuB?#3!Yd zbX*7+Q0M~NE~qoWZ|CEbTHg8=0qKxoGsT-O1I6p;7?k`e4RS*Xa;4BF#dSBwY?_xl zlsGH!APjLF^|E!KAJ{U=4-!!&lYEgGr9fzh8?in>k}~fKBpx6+o(jRr-KE)vbA0QW z;Ux%n&q9yJ=dIW~-9Y10m{ZK-^(Anj)n4}m?QxOLdqkJdg%Y2&xQtA!m;H9OEi7?H z8J%3QMkQPg2{EO@$kCMhyllDrklxdcQal4Tt&ZDlVD`7@px+rMt~8I}A(>MuF*v{a zu9|6iE60pfT4y+DP{DgrdJeHw8=K&9$U`%%b8yp{mO#^T{c6#4SRiT~vC z=iuxYR2OMn%&>9DrG11YF=A0bMTEE1bgprPf(Y9a-?hvLbA<*McF(ZIZ66NlD1{C& zO@X65ZL79)H7~GY53&$iDzrGs=BN~*p`aPPVUok?e$!)wFj0LWH&lV343OK#pV@h!!qJtr%(-_csLSwtO z7x3&XGC6+?w2h-g&Cq$KjlDj_46B?t$}Ghb41WDc52Z=U$9{grkDV6g26QcP=>|Vm zia{cC`B4>wY)T77AJbvvYx00oRJ$gQSYGS85^XcFE5OSrPn|LYX+D&!u5%Fjm)|zX z!8gP9(HqX*1ftX<)*clbsJP`k} z`lYTfV~)qH)1CPgW!c6<*L{9{|MSZcMH5Q|*pm5>iK!%$yd+9JZHgzki-1aKkEcY6 zcKRs~-2geWc@*D%u{zmNX*$euDqI&0dA%B3MqJ`^p1H#X0VIR%)D@Ad;j#!o>sy-1 zn}Z9c1Y-LP6)*~?MUW>XZW{YbUS({`PN8Be6pVa2NhCWDpaTS$ktgdwX$-8*{81;^ zwB|9r(X%qJGrE7Bqb{&SjqC<5#Yrq(I}t-=7OZjxZn8N&$%&&fR>;b7IBu^}oVUTd zY_Og(xOo=p8-)D8VwXQXoV$hjl>lnjn>E8|gtRW-0yMY475XoHc>~!2e`t>{9>awPMO9d>jY} zCMg41LeKP?b@AGlroe5UDB6 z^o5j*d6XuxHDkg9KG%4yeG)Zq{+zINHNU%350=(8T6)Yc0~mSQ#MkO`o|~nq$Kh-NoNPS%bZAN=7gxUaX!uqzS8!pc#49mkMOx78#?Ik&MHZ_HVu>+EgRAR z_Uu?18lY(Q%(8NJpBKa=g+MP%mt$2>bY~`3nXwg=k>r<+LM~G?I^iYS$u#8Apulxj zJ&B}LOc8+vb{?2srbkwRkK9OP9Bbaxyfj_2D1moCMh#7RI_@gr$$1aRf`;=jcfHK!S8KUkzLeLCGM8hpz!RDQkRtGJ8ZrLk)#S#R`dpMo0SqXL zO(F`JL`{-^aP7~&?#*k%CoPYi?6s|njJvAJFI;CG8WUSZQOJUjjYq74vlMX1V;C03 zqURL=l^~ip5zb8BrUqwEE1Nr5&uDQVRE2^vX#x^LQ>7>mxB?HBAaLuZu}$J6NH+i= zXvU-3g%mi~c%62$&3=Y$F<~*77QXXRgui#_sbEyBW8F=WsLj$$lFkDt><-IB z&w8o57S$q*gfc*IT&FXxTNJc;f^JT1KBYTl0R2A80y>bCi5CNVs^=nvS#0XrIE<&Jx95jJD5|*PYy%$>0Q z=de^4gpFsmmqUyl;?WUJBAIZMthi?l%(w|p^A-qm^zxKI5;9~q@-uamjxIBsZamkt zJs>gEyfWrXmxr6_c;o3UE}IrK9st%GFdQ4FV5o@G1P9hwt6|`fu!8vB2CeCC#?Qnn zGkMHnu}+6fy$G;HHHB>HR8xR3#A`qt42lfOO^TAm@nI<$#JFi>j`-rx5g|XQ5Z|?B zfq1seSq>8Np;bt6cN3&0tYO8lsnugs&nlBiE;me7l#Pm1GL;$7RUyr_VZPg561yj0 zR;3@WT&%seX{Doc>_k3t&&q|d%VZNgwmB_kuQ0KM?d{TjsBtAF%T&t<8{!x*(HWYX zGGk2k^lyMxdHs_ktkt zd6qDoAITw;hrossQE%@y3vrORquEBjvG&5}?tDUaQj{h*&4Ls_2ofsm3vwv~0LN79 zJAxy_8N*_f(pi9H-VRfq4pwW=vLlaGfs3Tma!!4+-UUmhQ*dVnRV2`zPM0uBe0H`Z zgr+*6`+OMzbH*35@dywN%~CxTxo)wyByb`%P)ZhSYN^QkY65u}l36qJM^OU?{y5KC zQ76xWNt$C^ClhlsKEMh}v~EotRJ_pRS=W;l4DH-hk%vXcOhYvtOf;36rj%Gb(usej zX3?X$We}2{A8?btJ8xkRq> zxX3xGIEuET4$pb4h}~Q>HB~Ft?QsldMmG1O$%s{mP?VKYrB%(zdSz?7q2()$iYUmC zq9FC*TmFr^4(ffaEq=zALpoDJU-gL#I4a1boo#1qPOZnS4NOzDlAFzw90U~9>Sa-yi}AQf;m)v_139iY+rmrxVb%hJ zNdZ7f;~>b+^*RRBsUC%y6Y;0oZn89VR#4N$7^MfZ%0%ej zo4$38vZtFSZJ*l_bHe!Z8DAhiYdVKvR_m}b5-AAp;`5%>;vXnaNm4KlXHUSKW08M2S zf&c)n001EHeaA(?4|?s=4{(bAyTZReaO}c%q7-?^W&+jK113e7%?Qcm0x-M7(?u@@ zb2>%zj201se8i_g0fnimr6BQR!D2xI<4Ca}L(QasL<3K8QyS&!%SZ^q;SwBLIovq+ z2uMk?#sOY3(mJy3f(LSQ002l7fxs*)X}CTMg;6z(>Db-~V_?&(76gEtzYKvjuR$hq zJskpLUdCa)*1<-y#v3Pmbg;30UlZ-q#>$ylz5Mcab5Hgc7VP%7JwKTF39Y=+jm4-3^ zX-cFpm9jJF#uRUK>RX1ddv}fiWxqiPa zSQq0R0E5RUF9IszQ^<`l1IPTl3_%1NG=LVMNse{p8PKyyc@A4PB|9Ev>o8^B-C@!` zX(880tL!CZd39oSNC_HQoX8_R@S(*m$<<$}Ii)kTiu zIYd$DeZ7ni>k3)~e{egOBsE_7{eSu3^y=-%-%ZjC+1O>oOrNquMj;L7VcwQJh00<& zRiRA;obWioRKO%j5+|WTNW=_tBAZ4;;G%p49g&ee#vi%$CM2PWt^$DZI1}jHB>&{v z<+ndY{HT-%fC(&~1;~=p&I^4@0r9cW1b`=JH7E^hqYVIEFDwH#1s#3m>zf^+utz{# zOU6}DPcvYy(-b60GHk_ZzpxBE{dkp$l2er?l&J##@^DG1jnUbbbl+C9$rrbHa++3^ z`|@&vzC0GE4cR{JrUz#lo({B~wXRMv%ax6|s`;4i@px>Nb5>LmO-KI?7V3n26iGDsp@`)j%+bH4%uh6KoXj?~n;#6A>hnBTh5woEYgz#W^_VlVCa`^xPO4h1_Ail#O!H{A|>7-+CR54w9X(>J12 z5yHe)65F-|NO4pOZD}Ggb9ydD9t&+6+B8ku0ia1ZW+;GTD#I`xZeF?=+pHTEBfyPB zyw|#Q$qU1}@?;IfPyH1d7`@I7o;lX(vzyeo0LD&`SD+W=rzM(lv}u1k31wi+RULVu zy^cW~WJm*piP2Et>EU$-(%7+Uv1Q5%JhAE=>=&^hix<9DH5rS_Ay@JS%MSr>__jZh zSvfgKx*2KT9p_K%hp2rrj}g$LqqcZeyLF|xqH1cnWW$vedQe1#^OYJ=Sg&>3wBKZF72O;>F z+%-o1Hh_>oh*y4bXYb&N`@-N=x@-$1;TaUBag=a#F_lFpP9PVCB1q|o%2JM=AdY9~ zbV~WYCa`|W%ym9dy`Tso=8_EbLU{L1RUL`*Iu&Aml*54zE<0CC5Dqmjry_vpGxpkv z$g?3XUiD4Q?*w} zXJg!=Px>o8(KQ$)ri45VdeIY#@%$Gm)@DGv1rw9iWg@g#WmP`@gVgs{KphJK$P0?c} z5ktZz=MJ6EA}f(>k~5_gQFAIwfojhHgQMFrJfEzvAPnh#5gID&KYyHK;CRk10T`#O zCuzY0RVaWMIFAlz_ikxxfr5i%^4k?)9-Fz1+DR;WQQ8D$k zfmu)iS_TrSPP*lSeB57SW<`tj(Dg6KK z-C2xe*LfJ=bMC&cwO4gj@5}6*#pXJuOi7d>BM$Tszzz@~ddWi!I7pD^1ju6$Aj!Mo z06_pFa7-tHD2t{zBoz%gLk?%@>FMdMy1VwhZdKiVJ9!w2k`=K@$r6V%yyu~x8~1$o z|Nrx!b5E%Z1uzI9dn$w6{HQzy8v}HI|b@n)YlGM*>Ao`7yzd z*qEYFI~4J0jJQw$hzRWtpDqS5@=(Bd2G&X28Im(BAq+Th7(YV=AM#5OKyMPylYPz} z=far^$VC^S(5a?rAU?u z1P@Zh`5eRF4ylxb`|Wr!-5PVLoC;)4!i973x++hNJNcl6t$ATDzA&m$sbV&>l zB=QZ{gAUCA+KzoGI~F4xN4`xGQQxTJXQ*Myo~6-rcXkOw1~n;(bSsPcr;TAI9*k2O z25>OtXweXmAIjTBIOBO%gvh{C80vY|b;6rQE-Q3#?U0v-@osv(KMDPsjHW69fT7 z06-*OEjO;U+|qZL4>}^z7bpx+7#2isS1?IFh#@I~oaI4qHS$DSPHQ!@wr*4w&1p6-indsJ`LClLDP#BvRh z3v=}H<+9>`Q$O^0Jhe08^V*f2!GjOMWk5~lmb)CXvG z`lI2rU3w;U<#hS%xl}&KVxOE3eX2hY{`^S?A^;J95F)tps{j6Gm%MOc>mJO~E{}!` z9r*j4U~|Z@A&N0hBx4An#YPAbp{NJ=*n*g-%{(HGJQ@R&CNXos1L=(EWQnFi%+`>R zYGV~&q`-kboFn3H&K_kX>VA>$bfJKYRcdS*HCe5nAXJDngBtyhQs$) zd1CwSDa>m)P6XTiV|oa;3>s|yWQy6CMGX*5>Fdf$?#yh4=9QyHUN;YMk+laCy|^V~ z^xOpZH)0>8tq0th>1ZaZPEm<02al=~CNeKS-(6Zdw|3#`+48(D5}&j=lACpYA?W}J zAwU2k`i0+VZ5`AL&#Uj%Fgm7Xi|A63%m5IP3`rn^rXBzd=R|_Q2zuD#RYpO$W9iNL zq2v$&h8%<7CuobCpOh&y4(Kc$nyEHd9+8|q7}FUL&!mtQyhw@aMT=(85i`{oY_`j= zGGTD`Ks6}7sVv@4gPtx|L?7_WSl>})c{{LSYC99tvyLeEy(wK5O!9(qY;vU0BfNXmb6Iun%xQsJ z%`&1yJ&|pWFN_I-5JHGx3?W3Y@J*(*L!vWt?|g)-d>ls#3b8I5lAJ@L$ijj^p#3<6 zJ{w_9O%go96M!RF%8cefC}U)daMzu1P}5*sZ#iWU8Xlh_fhmkR&P~Jc02flgkHsl1 zb3@r5gdmUL^x9K!!?1Lv{eLZ1l>uS0sK-zO1!K~om@srH5=wl2==J&sVuN20q%99@YwhLrWd#*rOgF__VOaZ7BBRs zesQ`htK{xvW&TdIoE?p#`Pn<~2^L&O>*vbUMw($^Q*!Uxt8*-EBV$( zi}TSgUQ8X?i;MehDbc8f!Fva*vZfM8z@o^yLT)S{KozjZy%G2Hs`;9aC z=l^xPK6F^>QEM4IiYN%Or|0agJ^q!A#v9vNk)3H669gc{+!;xZcuJg+E}{cEW@uX# zAV!D2ABZLa5fjs_F(TZ~gWt`KBDtF-P&;Mna7ZvsV^XBR1eXUnwwHrr$Bkvo2t!^4 zXsQioS&L$9$24_&TlbkxH4;N=&>b7j$Q9D8mAy2V4dB^Dv}@*bvs-i_HM01UxdWGt z&2T|z440KQ!$R_a%W}KqGN@v*fOVjhquPzAP?>1r{F(%Dg2a+bAAe;gEB~qgS8u)Z z-i^JU1%*de9v#KiSMlpRndi>qHxHn~siqfrfkpy?2z?cqame8gq9GO;p+e#WMpKUL zMi>P|PjDlKsgAxb4aYT2g;6tRRcr*sqa`@ju_+2?0frEH?!vDomeYP9#ZR6x3QkJXvGaib$80<(z$FY#gjkX#ZQ#{ zs!Z_lcUXDUJ$&!ZkKg?9-e^K{@Z+PngZC8V%S;pt%Q9a7w?l9=!%+R7|(7;QpIWKpyMD((H_IMl5vU+_py+m&w7t7oZ zuWI8hcHO#_KFah%GGND)g^cyd% zpU!$1KYF{gj8XC&kFMoE}C#Ui&&$@=tKUT2u2u+3pYwPyCy&f+YSMmCTO?QEf2?Qa=d61aV!XP5>s3%d*kd`Q) zVgjcx*aQ($3dDvT>N!T)tFY{GAs#Gg6B2~-fhvJry|yX|w^z}uMw9_?JDyI%b`<&4 z7P~+Cpy?Rx#v$x*=gy@zg)15Rc2p4kcV?w@_j_7J7urz4w9XB6UB1;`ROssFe3lzJ z>)JtgQ9DeRQ|580)P9IGv(KfLDp^h7NelxBCPMz9O#Y_v-|16~ps$?KE5+>TMX|V2 zau+yUvd4SY@lVEgjk6;iea439!Nj@y8foODQzGD%ttfmGw1tBc+g~3BEYD@54YCJ$y*u zsdeE2FmK4KROm#+gktUHB0q}(^1CI?ZiW;W$%8;LSqpNMZDGDx0**^&aKGCbE6B(+=rub zhTnrFnctjLSbERQaSPU)+_FL)h*{b@PP39+r zJDopogB*cj1kJE&d1-lNMT#eWzik<2?9GNZv3YW-&<;jNodQjYEE$b)+G3Cz&{Ej$ zcVbEAW7<>4GGw6ViIN$RzLTr;a@c6Lyj3Al?QU)k4@bFLk*b#vu>3Gc&AN(jfht3c zcGMAn@OF2wW!5hJ*;NGx*nIBKx6c;JJNGLUcBh^e6yvy%k*a|p5;tH9E#ZF|ZY+|w zgq)a*7*WKGK1PgJH`W*DDl$!xFeycT(@g#y#2S@XcV0U@c4HRb>p22L@@nzo)pJ@w zXY!@22gdh%B3NDRcHo0rMYZeD_Z^-_^dXjIio%SL7%KEF0!GlLF%skH)?Py>%XEij z?wpdbQQE6F=mp@r;V7TKo8sbK->Lv}%tc{kY={vy!Q!{Z_xYRYfAwd}1V9jB*=OE* zaK2LNyq_wS?{3eNT6H_6m!^MPoXhOBbU2r9kwrqT&scphxEt{EguJ{YaWqBXShAk^ z?KJCQaQ~0~VCjP&-FPQTDcW%Foy|Qjj#X{$)N?PseEIV4|K1tCc9geHucID!@9&r; z+u%uoB!d_$xIKbP%NSvbEX|KF;Xoo0gHNjOwy%~k*ph2P4x3Qj<}0O@f}O zJ9|W22)&p=7$Y6%POBphw;FloyZ_-89wGn%M3{Qz?YCCu7RfiG#f9c=jGdqLC@L#G zI67D8_EKDW+1|xt2Hlu)bNW(NNGT)%f4bq2jNb+m4EsT5b^hi2t-i7UkzG17kHGGa zzW>^_h7&_ohnZ;Y`R57+thu!4?8LKcw&ddB)Uq|ZXbpAbQP4*?PEIt7^*NJk3rge7 zM*gA$~Pvb!dAiZV@S}hEs)Y-v-gquPyWTrA_mc; zt__0Rm3x0tT+RvG1MLd_x1LrIt`EzqdidaMX}nLuA#+&MSF;-nMNTHLM;n(3k>Bc` z4!nEwRh(M=LutPShnoX(X?2xZsVKd7_qX4x-rlB%IF+tUj-91S<3ncBkTPC|(>Z#k z;(`Ed0)!y6X^U{A$pohu$^5(h%quP?TCKDFaT%b19%mTfF|{Co7{cl^@qoY@dX}}^Yvjo zo_%c%x0E(R&>(gKGB5z^;RwS6j975!qW4#?QcRC+o>%cP%H=B8w z2N()=GEp$I0zmn!-=1(s$KBGOzQ7{*_a_?R8|A;*ljJ3I82>NiE%}dM;(iec01(0Kv*VkTv`{|i>p8965s|fx6YHm$k3WY= zgnVHGA^-r`{?C8#kAM0d`tc9;rZOY4_`E|hJjufl8{&Pg+8UYrt(nS?#ee$(JEKhm zGdQX$1Wu4n%!Z7U^Q1Al``1U{)mO?QHNsBldo6)m;U^? zxcA@%@u+V_L;o9BQ=hV9iy#2)AA)D+z;07K&;9k}88)2*$*({@CMF1?AO7WcuYU4d zE8+XsHU(Ns@v2D$agNC$&yS2IXkge;diwsef08k2piX4lg8%xM?iB3zud;?nULQBT%RK5;_~Ui4M&3O)iGHxo zRfjbVue^9s`>csa5Cn$>1`r@2@_1_#03aexDRCD2=_&(3cKPeii$=A2eB2+fl;qic zr)MI49Am?^C5OQAX^^(>@z3K~AvoS{Pa2)K8ACp^v72eQ^Iq4Us8Dcb%KHJ|d$&p} zep}F}16y&UL;kwckD`CnlV1Mrx0l$@?5IP4aGiRzf0q;^kGVGa*=g9HuM0r+C+~G; z=8{B`nkZngnFTZrScoLX9f(=O6!`-&8+sO&cA|dyfXkw{OK~MLI*z%L30uR((Zt#^ z*;vZ>vLx0;0%Y&Qo%x1F{%cPplko7wG5M4N5`e+|<4&WGb>_9=0G-o=5iK|{C)vkR z-oh#4;R*pr+LD}U*{q9tvoktX5rzkI0fB-$MuF@%S{8W<=Q`txII}zCMqs8cDk~h7 z7?w`N=B<|(H9t{J7LV-n|pXQcCsCB4Fdq^th)F!>o3?JD?Av)X8O8y{c`8Jmsc zqxZ5j;}6>&I8+3obwIDW9WJoa>0mg+Mf<<- z5P6!Id_sjgBhm_NwRsmYLj19sQ@$>O{S)GV757Tl$)nhJ~}VrGfi(10Fmj{*P!{#L8ZzhKy_r6vPK3O}wiJ4%o!_7mC{HbupRT9uU$oZ5v9Y zhmqJK<^w#O^*All!swWfDS=^eH;87P|EB6?PR@1+GKi};&J?c-QUc{^X7cx2TnNT4 z3Il+MI3^-QAq=4a5DWki;_&0kRZ#zEx-%|nvNB=#8Q&by`;h(G+2p}L7*f>F!?1=Zg;d^$P-KMR=~h6{%RrF+DVxF z5+|)4L!{z9v)bNUrv~=Q8k>ZZgvl>iIDhEPaN2Cp-jdZ1l*=ng+Icd@7ue;30KqFu z^dOopR1hj(P$Jt)(#~TYpF2i;fzjmSCC`WLS>E?U87Cc3StOIWdYmI5KHq!#1^3J$ z!hiP}t&K;TFr^D6-v=m}tH(I}FK09X03hgXwStO)3qGWBSv;Am$Cz~BmzD_t0MvH( z>4k{mCh*y0v6HNHKGm>u{~*rtzzK`XR5A$(lTVFx?SsC^$o}M6E(s@J{y{tzn*hPQ z-aF{9CbBq~I7y<(2?51wH=8QIl1rQ<(d2}H!Y>x&dN)*=L`f1&PK+pl-PuGJlAe9S zwZspVV;D^5!r;vNl=^{El<2upb{tvlM|tg9(1O; z^E#a%Ntm3tI_}?ce0C$xCnZY4#7~GQlh@yiuDrPXnKvFJ<15Pq002O| z^W&;he(6%u!%vdVi6si?+`Qgg{=-Y)NzXm#2|u9+(U&VivMJP zKmgH~{4E4v?C$+A|Kj;4yM-U&36tM86z_Pi8NRqi;7_cRBY=X0D$<^U*FJE_k$0e zyN8>tgR>2tr=PnGosF-9Edb!Vbdqo2VGZ+Ec;UmqMtBB%4@;WF5)5p>X0BUJ2nOkf zu*tvY%g9*(3_1fV#9yw97oJefE3=P1KEDrWC~8`Pq(q2}hXf_v2;4pf_cWPFhhTaVGpesd8GRg^DwvyiAPExQ{30}I zeRd~JbG5g=dQ+Rm_cKH8^3j>%!}Loo${?cjo#|fJ%~(pYSdgOXm$JVD56{#Ky)XBj zX`N@KA-c~ona{pA)%rXy3+kPulV^I<#r*mqUC*OirH|e#Pg0dUZoL5>4aGheDT9Nz zI2UetkoAL3pRWPG7LFAg)nUQR*OEVPlK)nfT{l7lDPI!YFuwGNuP0?2N;nZl4&Pu% z%1`^g!x2w(|L)hs!5N*q6*u6Nn4r%+*r&+v53Ueotz?U#b_ow<)=H3pym)O8RPP5R^_es^G`3Z(VZ;2GuHz_Q3OQDM{owVGCTs)md>UW z5>#b}5y{M|a&#x=L*LfFualeoFx*01XFFfN?9SD`qFOg@?9`n^6M*lb%8J7~QvbnR+Ro zyzj|#bUP1rOJ2WRnNi)j{lal4E|sCD z>T4TnH|C|uOpMRP$C}x_j8VNV!L6OaEM#m_Td^*KP%hvm*^q9N0NLe)eO9vf-@pDO zx0Lm^#Y?FcD6^Fer@FOy)(n%DW6}{C@Uv1gC3=3kuX~8X+>ia^me5+W9vh>x(@ty8 zuVr90JbS$aX?l+);|E^WS_i+4Z}@GuI7OI0?}pupI}v^`hJkG!$7vu!dJo%#bL{wu zNf-WH^i0cVol$K4L~X^BRGkvI1g2jN%j|G5ff?ZTjNTcYp@U8qeAu&sjUVFu0#vQ$ z-SKnAt5rs?EQPfS4OV}_N0_h51v^?hB#n8Tuyj}c4Ih!6Z~Sd<)k*-K9J3`m4(2R+ z^IyrGcJfO0sR`Goy)n@TGs4@&PJ1Y{a2%tjjH$o`mM*W_%Dq3kASV-u zo1kK>%92j$o5^Oxk(QP*l5{L#<8E^xkBVVVGZw8)psX2$J5E~B153N2=269Lc0sxt z12^=_TgNj^QooB!X{Z;a7@99{4M%L(|w{c(hW>q15 zc4@*S8B%&*RXk>O69((HCCqmX>g&KaYwHh%OZ=EiuheUF9YIw zq?koz9#?wxJ&BBp$(`6p8eDnK@Ux2vjTt}XgD{GyFbzL4#z*`!^)dZ7z;D15EbyVJ zSKJ2Odl(4AqgEF#F2fU(VTBH9AU;I6(<_hBVzJ>^XHG`jA-TXxB&_|giBw^p^KO3gM|)V#?}P$)WzLI2gAd#E-qiIe4+6!z=_jZ zRCG_qpj1R+vt@sY{+-v^zkRSywn17qE1i_YoVG3zlY!9MX(4GCd0J6ly|L~a#;=)r zP(5Y|Po<=wjkjIWfV)SSH}?_P6W2!}Ots4|0BKhG`&D_>FrwvDJ@fnpYa7|sQ|Pep z>jx6Pmtt1&KE`u(Ahw+q9#cnjLN}yhE#6JZ-qjG`rI%!KuidDnCh5>yFlDc8JD^df z;CtxY@=;$+v6oHRC>QHvVD~C~O{B(EQu|G2Y@yfC|F|7HcCms(pqL-oIja|q&T<}8J@LowM6w{SWHl2j*rxy@EeUw%nqYp1Wl5*@CFcVNea>M+b$#Nq)dV@ zOT#vvM|cr$Z*-0cmAuS$51`nrUjPAisV@@Sc#5w*rmDT`1fmV z3^+LE6Sst?MafvFs);_DwAvGCrcui{rY(9%%uq?`+>?z@*sP2!^dX@Q*pc|LJ6)E-f7e; zMJ?mSXnWPz6k=aya=A)|nW-gSj((ODKxufZ9oFT3OV_A^h*6NcMJBzmy?_`R$&>MS zi6JHn)>))Zly6vNB<|NW{&rXmL^0^2KiqZctbzCS&}1Z0SL;h1Q@M&EYg-HJ4GGog z5FG{ZXEIOmk`@!Ctof@e&&S+cE>E@>rv*%pI!5vxY zH~U+OyHyA~?^CIKrALTul8of%v`cGv1D8#v!lw}geYs}l@X&&gTfI?6@h>DLOP8X& z=t&3SabzilGi^fVk7T~pV6YCQG7kQMlb=Q|EWwdP(w<}d`8LojHV6c>hIrB!Vh#+_ z{*)kT6Kxn}$U+2xH>C9}yj0#pRIJ+0mdR*8i`p#||CR1Xinm!!f2*4ve-22~>ss9Q-kafrT+MP|Uc;^(bs7qk6}-?I`rHl6M%9E+nW3Pul%w-%0YUUx*&t%-KDq-nr) zZU}c)j**(_Vq~P}^kl!K*C1l-C30rOogJOo@`IA&p%i`OApDVOkVbQS35#TH58zE` z%blG0MwXXBxI~w|R!*S>@K1tw0EmEMQy+>}HFUcqdB}2HfbpDtm zadelGh~h^kUc|unaqI(mbKoV+H($mmylZFZe3+8rxw~W|eOk5y$GifgnEke(G}N&SF0S_ovG6BsfETw+*iS_~3)!AF{ ziF9Nx<7O4&U;Fmf^_VE;lssNGiiVX5=$`Beu9c1n%5HU$pGgcUhl#e_fZT}h5nX(! z1eFsUN4cEiiSPl&<2<#aM1Cv3U?w+%KzD3pWH0lOAz4~`k@ZxwW-?|Qb$J-XE1s)wow`V9$Gm+1wq8%N?k`89`%D7GY~nt$e?I;`^awH?nMG!?b)WK) zh{8$OW?Ab(WpNpRnbSqNtcPJg)P5@vxz%g`s~jrDk6gfdfR_Mx4*i8T$lbXrC_(66 z874*w35c0BgF2xbKjAC@XtJH|sc|Xjb9r5r&c*({_AVQ7Rk+1Ryr|caZ{4%0xN7z0 zpIke^v2M1{??HKUd&*^$c&+Ombl_%c)rZWB&9}MKb8#_mko9RolYYv9d6no{-VR}3 z9O?+D&sv4?RAPT@CvBjKqjkim>ixxgR!dFTpsKl|5M)AMp7U<<6DT4v)~ORh4 z>`IsRSYgvFS#mZ?Ls7KT!y0jk;gO23<$c*iC$3j0memfw-v!FN8PV1)Q>Ljp#SoKX zp06B8Qvv={EhA8$?~zDbPN}K~!V(PyZHHdxY`GFX_WpGDS0Jf~3_Tm53&6CY9<`xi z){_0zePRFMI}xl9&5!E`Q~**WA{gf@_ou}h16`K+jY3_IVKSqiA3llRk3w_WLq6H% zyKj8jet1noX;5yM1NGN5eFxJ9$jhbU?wR?j`E!X7N}OsYa8e&ZV;{G<>JpzWRx+f` zOfijP3KSmhdgKZkUev``+=wE{Z2!&So1~|PMl;Bh)2Lifqd6H#xzHGjN9rQ$h6V9d zA5~Ih!^;SR=&-oxLTsXK=au5|Xk~R06%?3MnOY1BDBIKdz0wLi^}n-0pW6pKvwgSi z?=W(Z=Dyyj(Eq@mCJWKXM)9^o>@;9)Ue9+zNoo6Co5J*?Qq&<05KErPCyKm0qf(`U z1JEBz}+uxLHPkSb4xGBElRn0xedE5nN$L-82r?X7i2s7l8t%4?SXIJ=bs`Yd=S!M=2M7u3)<~R z8$0IOYcEn+PS@hyt#JRWe^?SRJKe*c+W^8$rkfO2B)>hq40z^Z-TaN*BcZ@Na*J%#Ei40|fc%@-$980m``Te2qwbMu*`{?cIW!dY2{8?;2F!)^2GN0$ZcwZWE zg`59i&0`xohDl>2wo4sp_`Z@&JYqDA6ik+Fu~)?u4N!HSMHE>j|U1fKO_4 ztjsvl>DtVF3*w_D8$o?xIuZic-mOg`8asm(N5j{OUW0*2O{jfF7>di6jjb~Sz0G$9HbKc+DvRtvC(@)DmJx>o$6K9VT z=tziRGIV%o|IhIq9+|Q+R0_q0qw5fz@Q@_@q8>eK=f=9h$Dh@=Kyc?zRZB z)ob7n-A~VhyJK{x2=`CNO%5f?2-3u9A*eWj8U5=vg98|k-rOhx2{+hqc*ld}_g0~k zS*vXldcXe(l8^?d3H6y-CB3epz1q$^W$TvSLYfx@T2J+Jcn5NCXlSr!qB&QvymwIx?|s~m@XNh4xmC}f!h*vGPjV|^ z(Y-1JgV>=E+fy&(W{^A$8p%->{wxy66yp)dyDr8;&hZ!Ejwi}3t|u^14>cCVU*uwF zfH$m3_I%c@B?=2#bL@tnw6(Qe4Sww6NZz7mxP)sfw7agp-HLdOgMUrrbAbzQq$eJA zj*8v9$M<81XTZD?-D~6@kiZl)bh5K^^#kPMga2>{eD6fLP5dehNCEaj5AK~_UKH{S zkAi8%)^aR_aReIy~XYWTD|J*|r%{GL5au3gK@YU`dgem>yn3rz_O z29q=XaAB{emVnvse+Rtc-iUzLh62Cb=Xi`}b1h3-T=iJT0Z*aCIzd5CZ%eQQjht|s zaLt0Q=T3VbP9KEMAsg@U?+Rlj_5aV%`Kw0@DwZwE*TO;$i721s9_ zx)XJKuUjifY;m#q6Wv{_VFTdWb)m?@Ag*9NgI9dV0D2PNpx9e+VYqBk%i&(#A*>a1 zI;oA4qrW)YYms$r4cHmUf{7;M-UaV86FjO5{a6ESYn5s|48`M>N~2ZZ@0oV_g2OSa&HuM z^aFkoPrw+?*L8@zm{l&(olX)zZVjA(n=NFg7Z)A;0|E*{$1zE{>u;T0Nzz7~glKlj z@bq!M!4oR_M0|i^)7Rg)fM9`vUua2uyG|dea7EReoSZ1lYxSS!O`3T~6Mi~Y1r=$L zACqkgh@^729!q=fPgVs=t$%Y~Y}tEq$^k&+w_h9^VN+y(S4ES3pof)G=iYEdX?(E> zvcCzU$v4T7AH@z$OZjYt52lrY=WTqub~;2sw6{9UMg9maBeN0SX zRN)>4_kLh4YnfMszNNh-;zcK$nVfXD1?fMpIXzW;a6D0;GvT73q&!2Jzms?A+nWv+ z)s^?s?~nt1a!q7=Ew<|RcCSQ;YvZq@m4oW}kVZO#SCAvE)j(YsJS*j8m^?x_DAE)b-IWp3_x|T<^_aKT*kcO8Iad^t&`^$py>GpT=(e2ASrQ zE}+AmH$2_QFX6sCKSTgrz_G`ozaMiC&dzAPCG`UW0$f9PHA~P^M|Xlz$Ed}Ou7UK6Vf4CX{>a}3kPdF^?k$(^CJzBlfe z%>>`5zs>M`4U7;>2rh$aQf~ZOLkN^E5T)8|?d=A0o9iq~1wY;j!PT8kGUZB}X@93N z4l}h2n1H(NL*u@O5GVY^P@?yGBd>i8XM%?_st6-~rL?MtoB{!_McUn4^}=YOQmHG3 z)OHV^c0>6bI#UP<2)j?TZ#{Js$zw{-$=IzKCTu{GT3lT8>(r`1Y_{k_tcKpa-NS%u zJw0?Nf_x~fiY|x8YAbezGg=?8Q+B&!c7;f0<)=9>3;4%PAg~pUD7n7cL#05lM_sJ* z<>FNooIIwN+{>%j&B1|ffsq>W)ADl}wAz={m%)9#LM0>nSFyA-`L2K#{c3Ff&0P!X z>m8?ZrP}{YU`0e}q+@jAZ{H4F-Q2u%^X-nl0!nudGqg33;A@fRQ4ov!nR4MDY9W%* zrr$hQw60l$p;WIXzU^>| ze?ld+HE_hjdt8lDemhyV@s2TsQ>pg$#|8zyn4rvhN``Gk9TlCax|s-Atps|8LZPr7 z;<#jocotW-U-#O9Q(rZ5h%_btsc#qLRMnz9R$`2GL)oPM&P+cJa!O-0wgrE9JXS&- zKB{ME*pXsoFsHGA|Ak*Lw0tMiyF{M}wrGu&;K!oZ0OG_drxXXR9TYxn0Qi=?K|!1< z=|6)REW$V%+D*bFKT;VKLhOJxR%70Z=t{GeuTW%DNiW2#Z`Eo~g_-5^}zRAygkVWM( z2L?W+eC_o*Ao_+u|FvrAj?kTVrY2YAgOY%qsXp;^HU9;kyc(K>_7D)#Xs9^Pe7|bVhPa z=bI>B(_q3Q(Y+NmxGBRWXL%yN&T8snh)+W7$_q=6`y8~Uv-5!#^_D?m6ZmWwbL*27 zD;nw+@~~W0TYI2-=CRy0iRB&+IlcdEH5lwLRi;1;daB^7l1$!;L2&IjsItGmI>De z4}XDZvMJSIXQ<07+rWe_0{2$_a6Vrh%oKzk4CH>HgW@xDO6x7*s_V?npaxE|8gH3y z_QnmJFkC+3AqjnVQ+&z|InxXC$yTlG5KRtH7I;bmf;&h=$EAY=nKYv|4& zCtNT+{FD9~Ml@6S*5MY?QqoBsF|8U&%*K7cWmJ)pH#Ym*yI5$P&gk~fA?|vOVy|zzn{Cpz#W_!6>=XWn=L~h_&e_NGnMQ(W5ZBkE!3PeO}Y#IwVH1lyJzV`59uiNN2(j!d(YeZnnC_D2^nKP ze9?K!`W(F3g9`N7j6z7mYqvFhVF+phR=pCxGt-Yhi62l7{_?=T%3B+x_OZ4Z_Ix{g zR}ru;445%j*Dsobi=z@|?bSWcZo%#Q&)!$*v+uOT3_uUDq4(L+TlZSCg(IY74J2xy zqpr>Zp!1h`*SIo!lh1~aK~*N`zN8KlM?dXR*H9$LqWy?|VW?H=0uS($X2O<3ffF!+ zksS{;4b@Gpi5V(JuM4#1sp1nnGL3#{hj=KXiYA?g@)|qCvVye+kwPzut&x)!C7goG zul6B9`Qh_>HJZrl>9Tx;z_&Ak2)^N0$52NM3*2U5ldAO3ihi$*?*m#yvtz^XjEopz zt_XloVH;UULx)Hbu#NiLxno-FD<{?;R>JJ{ZSAK!PEb&B`#(n75(8L9+jefCR4=+{ zYj<}yeL%5lF2A$jIrXeQjaKRk`f7>3y{G2v)^Kv5%yY6H-1iS{R!4@+`FK{b`vjP zkfiol(}IS;0JsV&P~T3d?DI#$BJlv{J8|SS)udAcHSj>wVncRs1vZdto=Vg9@*X7l zPX4sN*uvnoLddK*4|@3^pCPB^&*Tdv2|u5C!R27Xf!yH=znXh>yfvJz%ATH{b=t08 z646&F0HlFTaG|`4*t}OE8KcD(s30FKRLEr-W~FR60jmxbA+s9Eo9+7x&n_w|vgIj> z3B`H+_f}|YLp%0bzZm{vj${ifUSw44@n4dojK=v~2xjdq zGz)UoK;q6M<31s?*C(> g{Lgox>VA}tT7FCq#mSC&^^E{33Yzkbau(tL0U;%!VgLXD literal 0 HcmV?d00001 diff --git a/src/wuttapos/terminal/controls/__init__.py b/src/wuttapos/terminal/controls/__init__.py new file mode 100644 index 0000000..b9ad28e --- /dev/null +++ b/src/wuttapos/terminal/controls/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - flet controls +""" diff --git a/src/wuttapos/terminal/controls/buttons.py b/src/wuttapos/terminal/controls/buttons.py new file mode 100644 index 0000000..94f7473 --- /dev/null +++ b/src/wuttapos/terminal/controls/buttons.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - buttons +""" + +import flet as ft + + +def make_button(text, font_size=24, font_bold=True, font_weight=None, **kwargs): + """ + Generic function for making a button. + """ + if "content" not in kwargs: + if not font_weight and font_bold: + font_weight = ft.FontWeight.BOLD + text = ft.Text( + text, size=font_size, weight=font_weight, text_align=ft.TextAlign.CENTER + ) + kwargs["content"] = text + + return WuttaButton(**kwargs) + + +class WuttaButton(ft.Container): + """ + Base class for buttons to be shown in the POS menu etc. + """ + + def __init__( + self, + pos=None, + pos_cmd=None, + pos_cmd_entry=None, + pos_cmd_kwargs={}, + *args, + **kwargs + ): + kwargs.setdefault("alignment", ft.alignment.center) + kwargs.setdefault("border", ft.border.all(1, "black")) + kwargs.setdefault("border_radius", ft.border_radius.all(5)) + super().__init__(*args, **kwargs) + + self.pos = pos + self.pos_cmd = pos_cmd + self.pos_cmd_entry = pos_cmd_entry + self.pos_cmd_kwargs = pos_cmd_kwargs + + if not kwargs.get("on_click") and self.pos and self.pos_cmd: + self.on_click = self.handle_click + + def handle_click(self, e): + self.pos.cmd(self.pos_cmd, entry=self.pos_cmd_entry, **self.pos_cmd_kwargs) + + +class WuttaButtonRow(ft.Row): + """ + Base class for a row of buttons + """ + + def __init__(self, *args, **kwargs): + kwargs.setdefault("spacing", 0) + super().__init__(*args, **kwargs) diff --git a/src/wuttapos/terminal/controls/custlookup.py b/src/wuttapos/terminal/controls/custlookup.py new file mode 100644 index 0000000..8803ecb --- /dev/null +++ b/src/wuttapos/terminal/controls/custlookup.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - customer lookup control +""" + +from .lookup import WuttaLookup + + +class WuttaCustomerLookup(WuttaLookup): + + def get_results_columns(self): + return [ + self.app.get_customer_key_label(), + "Name", + "Phone", + "Email", + ] + + def get_results(self, session, entry): + return self.app.get_clientele_handler().search_customers(session, entry) + + def make_result_row(self, customer): + return [ + customer["_customer_key_"], + customer["name"], + customer["phone_number"], + customer["email_address"], + ] diff --git a/src/wuttapos/terminal/controls/deptlookup.py b/src/wuttapos/terminal/controls/deptlookup.py new file mode 100644 index 0000000..f0b12b0 --- /dev/null +++ b/src/wuttapos/terminal/controls/deptlookup.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - department lookup control +""" + +from .lookup import WuttaLookup + + +class WuttaDepartmentLookup(WuttaLookup): + + def __init__(self, *args, **kwargs): + + # nb. this forces first query + kwargs.setdefault("initial_search", "") + kwargs.setdefault("allow_empty_query", True) + + super().__init__(*args, **kwargs) + + def get_results_columns(self): + return [ + "Number", + "Name", + ] + + def get_results(self, session, entry): + corepos = self.app.get_corepos_handler() + op_model = corepos.get_model_lane_op() + op_session = corepos.make_session_lane_op() + + query = op_session.query(op_model.Department).order_by( + op_model.Department.number + ) + + if entry: + query = query.filter(op_model.Department.name.ilike(f"%{entry}%")) + + departments = [] + for dept in query: + departments.append( + { + # TODO + # 'uuid': dept.uuid, + "uuid": str(dept.number), + "number": str(dept.number), + "name": dept.name, + } + ) + op_session.close() + return departments + + def make_result_row(self, dept): + return [ + dept["number"], + dept["name"], + ] diff --git a/src/wuttapos/terminal/controls/feedback.py b/src/wuttapos/terminal/controls/feedback.py new file mode 100644 index 0000000..365f2fb --- /dev/null +++ b/src/wuttapos/terminal/controls/feedback.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - feedback control +""" + +import time + +import flet as ft + +from .keyboard import WuttaKeyboard +from wuttapos.terminal.util import show_snackbar + + +class WuttaFeedback(ft.Container): + + default_font_size = 20 + default_button_height = 60 + + def __init__(self, config, page=None, *args, **kwargs): + self.on_reset = kwargs.pop("on_reset", None) + self.on_send = kwargs.pop("on_send", None) + self.on_cancel = kwargs.pop("on_cancel", None) + + super().__init__(*args, **kwargs) + + self.config = config + self.app = config.get_app() + self.enum = self.app.enum + + # TODO: why must we save this aside from self.page ? + # but sometimes self.page gets set to None, so we must.. + self.mypage = page + + self.content = ft.Text( + "Feedback", size=self.default_font_size, weight=ft.FontWeight.BOLD + ) + self.height = self.default_button_height + self.width = self.default_button_height * 3 + self.on_click = self.initial_click + self.alignment = ft.alignment.center + self.border = ft.border.all(1, "black") + self.border_radius = ft.border_radius.all(5) + self.bgcolor = "blue" + + def informed_refresh(self, **kwargs): + pass + + def reset(self, e=None): + if self.on_reset: + self.on_reset(e=e) + + def initial_click(self, e): + + self.message = ft.TextField( + label="Message", multiline=True, min_lines=5, autofocus=True + ) + + self.dlg = ft.AlertDialog( + modal=True, + title=ft.Text("User Feedback"), + content=ft.Container( + content=ft.Column( + [ + ft.Text( + "Questions, suggestions, comments, complaints, etc. " + "are welcome and may be submitted below. " + ), + ft.Divider(), + self.message, + ft.Divider(), + WuttaKeyboard( + self.config, + on_keypress=self.keypress, + on_long_backspace=self.long_backspace, + ), + ], + expand=True, + ), + height=800, + ), + actions=[ + ft.Row( + [ + ft.Container( + content=ft.Text( + "Send Message", + size=self.default_font_size, + color="black", + weight=ft.FontWeight.BOLD, + ), + height=self.default_button_height, + width=self.default_button_height * 3, + alignment=ft.alignment.center, + bgcolor="blue", + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + on_click=self.send_feedback, + ), + ft.Container( + content=ft.Text( + "Cancel", + size=self.default_font_size, + weight=ft.FontWeight.BOLD, + ), + height=self.default_button_height, + width=self.default_button_height * 2.5, + alignment=ft.alignment.center, + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + on_click=self.cancel, + ), + ], + alignment=ft.MainAxisAlignment.CENTER, + ), + ], + ) + + # # TODO: leaving this for reference just in case..but hoping + # the latest flet does not require this hack? + + # if self.mypage.dialog and self.mypage.dialog.open and self.mypage.dialog is not self.dlg: + # self.mypage.dialog.open = False + # self.mypage.update() + # # cf. https://github.com/flet-dev/flet/issues/1670 + # time.sleep(0.1) + + # self.mypage.open(self.dlg) + + self.mypage.dialog = self.dlg + self.dlg.open = True + self.mypage.update() + + def keypress(self, key): + if key == "⏎": + self.message.value += "\n" + elif key == "⌫": + self.message.value = self.message.value[:-1] + else: + self.message.value += key + + self.message.focus() + + # TODO: why is keypress happening with no page? + if self.page: + self.update() + + def long_backspace(self): + self.message.value = self.message.value[:-10] + self.message.focus() + self.update() + + def cancel(self, e): + self.dlg.open = False + self.mypage.update() + + if self.on_cancel: + self.on_cancel(e) + + def send_feedback(self, e): + if self.message.value: + + self.app.send_email( + "pos_feedback", + { + "user_name": self.mypage.session.get("user_display"), + "referrer": self.mypage.route, + "message": self.message.value, + }, + ) + + self.dlg.open = False + show_snackbar(self.mypage, "MESSAGE WAS SENT", bgcolor="green") + self.mypage.update() + + if self.on_send: + self.on_send() + + else: + self.message.focus() + self.mypage.update() diff --git a/src/wuttapos/terminal/controls/header.py b/src/wuttapos/terminal/controls/header.py new file mode 100644 index 0000000..08bf069 --- /dev/null +++ b/src/wuttapos/terminal/controls/header.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - header control +""" + +import datetime + +import flet as ft + +import wuttapos +from .timestamp import WuttaTimestamp +from .feedback import WuttaFeedback +from wuttapos.terminal.controls.buttons import make_button + + +class WuttaHeader(ft.Stack): + + def __init__(self, config, page=None, *args, **kwargs): + self.terminal_id = kwargs.pop("terminal_id", None) + self.on_reset = kwargs.pop("on_reset", None) + + super().__init__(*args, **kwargs) + + self.config = config + self.app = config.get_app() + self.enum = self.app.enum + + self.txn_display = ft.Text("Txn: N", weight=ft.FontWeight.BOLD, size=20) + self.cust_display = ft.Text("Cust: N", weight=ft.FontWeight.BOLD, size=20) + + self.training_mode = ft.Text(size=40, weight=ft.FontWeight.BOLD) + + self.user_display = ft.Text("User: N", weight=ft.FontWeight.BOLD, size=20) + self.logout_button = ft.OutlinedButton( + "Logout", on_click=self.logout_click, visible=False + ) + self.logout_divider = ft.VerticalDivider(visible=False) + self.title_button = ft.FilledButton( + self.app.get_title(), on_click=self.title_click + ) + + terminal_style = ft.TextStyle(size=20, weight=ft.FontWeight.BOLD) + if not self.terminal_id: + terminal_style.bgcolor = "red" + terminal_style.color = "white" + + self.controls = [ + ft.Container( + content=ft.Row( + [ + ft.Container( + content=self.training_mode, + bgcolor="yellow", + ), + ], + alignment=ft.MainAxisAlignment.CENTER, + ), + ), + ft.Row( + [ + ft.Row( + [ + self.txn_display, + ft.VerticalDivider(), + self.cust_display, + ft.VerticalDivider(), + WuttaTimestamp( + self.config, weight=ft.FontWeight.BOLD, size=20 + ), + ], + ), + ft.Row( + [ + self.user_display, + ft.VerticalDivider(), + self.logout_button, + self.logout_divider, + ft.Text( + spans=[ + ft.TextSpan( + style=terminal_style, + text=f"Term: {self.terminal_id or '??'}", + ), + ], + ), + ft.VerticalDivider(), + self.title_button, + ], + ), + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + ), + ] + + def reset(self, e=None): + if self.on_reset: + self.on_reset(e=e) + + def did_mount(self): + self.informed_refresh() + + def informed_refresh(self): + self.update_txn_display() + self.update_cust_display() + self.update_user_display() + self.update_training_display() + self.update() + + def update_txn_display(self): + txn_display = None + if self.page: + txn_display = self.page.session.get("txn_display") + self.txn_display.value = f"Txn: {txn_display or 'N'}" + + def update_cust_display(self): + cust_display = None + if self.page: + cust_display = self.page.session.get("cust_display") + self.cust_display.value = f"Cust: {cust_display or 'N'}" + + def update_training_display(self): + if self.page.session.get("training"): + self.training_mode.value = " TRAINING MODE " + else: + self.training_mode.value = "" + + def update_user_display(self): + user_display = None + if self.page: + user_display = self.page.session.get("user_display") + self.user_display.value = f"User: {user_display or 'N'}" + + if self.page and self.page.session.get("user_uuid"): + self.logout_button.visible = True + self.logout_divider.visible = True + + def logout_click(self, e): + + # TODO: hacky but works for now + if not self.config.production(): + self.page.client_storage.set("user_uuid", "") + + self.page.session.clear() + self.page.go("/login") + + def title_click(self, e): + title = self.app.get_title() + + year = self.app.localtime().year + if year > 2026: + year_range = f"2026 - {year}" + else: + year_range = year + + license = f"""\ +WuttaPOS -- Point of Sale system based on Wutta Framework +Copyright © {year_range} Lance Edgar + +WuttaPOS is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later +version. + +WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +WuttaPOS. If not, see . +""" + + buttons = [] + + user_uuid = self.page.session.get("user_uuid") + if user_uuid: + + session = self.app.make_session() + model = self.app.model + user = session.get(model.User, user_uuid) + auth = self.app.get_auth_handler() + has_perm = auth.has_permission(session, user, "pos.test_error") + session.close() + if has_perm: + test_error = make_button( + "TEST ERROR", + font_size=24, + height=60, + width=60 * 3, + bgcolor="red", + on_click=self.test_error_click, + ) + buttons.append(test_error) + + feedback = WuttaFeedback( + self.config, page=self.page, on_send=self.reset, on_cancel=self.reset + ) + buttons.append(feedback) + + self.dlg = ft.AlertDialog( + title=ft.Text(title), + content=ft.Container( + content=ft.Column( + [ + ft.Divider(), + ft.Text(f"{title} v{wuttapos.__version__}"), + ft.Divider(), + ft.Text(license), + ft.Container( + content=ft.Row( + controls=buttons, + alignment=ft.MainAxisAlignment.CENTER, + ), + alignment=ft.alignment.center, + expand=True, + ), + ], + expand=True, + ), + height=600, + ), + actions=[ + ft.Row( + [ + ft.Container( + content=ft.Text( + "Close", size=20, weight=ft.FontWeight.BOLD + ), + height=60, + width=60 * 2.5, + alignment=ft.alignment.center, + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + on_click=self.close_dlg, + ), + ], + alignment=ft.MainAxisAlignment.CENTER, + ), + ], + ) + + # self.page.open(self.dlg) + + self.page.dialog = self.dlg + self.dlg.open = True + self.page.update() + + def test_error_click(self, e): + + # first get the dialog out of the way + self.dlg.open = False + self.reset() + self.page.update() + + raise RuntimeError("FAKE ERROR - to test error handling") + + def close_dlg(self, e): + self.dlg.open = False + self.reset() + self.page.update() diff --git a/src/wuttapos/terminal/controls/itemlookup.py b/src/wuttapos/terminal/controls/itemlookup.py new file mode 100644 index 0000000..192bb12 --- /dev/null +++ b/src/wuttapos/terminal/controls/itemlookup.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - item lookup control +""" + +from .lookup import WuttaLookup + + +class WuttaProductLookup(WuttaLookup): + + def get_results_columns(self): + return [ + self.app.get_product_key_label(), + "Description", + "Price", + ] + + def get_results(self, session, entry): + return self.app.get_products_handler().search_products(session, entry) + + def make_result_row(self, product): + return [ + product["product_key"], + product["full_description"], + product["unit_price_display"], + ] diff --git a/src/wuttapos/terminal/controls/itemlookup_dept.py b/src/wuttapos/terminal/controls/itemlookup_dept.py new file mode 100644 index 0000000..119af4e --- /dev/null +++ b/src/wuttapos/terminal/controls/itemlookup_dept.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - item lookup for department +""" + +from .itemlookup import WuttaProductLookup + + +class WuttaProductLookupByDepartment(WuttaProductLookup): + + def __init__(self, config, department, *args, **kwargs): + + # nb. this forces first query + kwargs.setdefault("initial_search", True) + + kwargs.setdefault("show_search", False) + + super().__init__(config, *args, **kwargs) + model = self.app.model + + if isinstance(department, model.Department): + self.department_key = department.uuid + else: + self.department_key = department + + # TODO: should somehow combine these 2 approaches, so the user can + # still filter items within a department + + # def get_results(self, session, entry): + # return self.app.get_products_handler().search_products(session, entry) + + def get_results(self, session, entry): + org = self.app.get_org_handler() + prod = self.app.get_products_handler() + model = self.app.model + + department = org.get_department(session, self.department_key) + if not department: + raise ValueError(f"department not found: {self.department_key}") + + products = ( + session.query(model.Product) + .filter(model.Product.department == department) + .all() + ) + + products = [prod.normalize_product(p) for p in products] + + return products diff --git a/src/wuttapos/terminal/controls/keyboard.py b/src/wuttapos/terminal/controls/keyboard.py new file mode 100644 index 0000000..9603cb3 --- /dev/null +++ b/src/wuttapos/terminal/controls/keyboard.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - keyboard control +""" + +import flet as ft + + +class WuttaKeyboard(ft.Container): + + default_font_size = 20 + default_button_size = 80 + + def __init__(self, config, page=None, *args, **kwargs): + self.on_reset = kwargs.pop("on_reset", None) + self.on_keypress = kwargs.pop("on_keypress", None) + self.on_long_backspace = kwargs.pop("on_long_backspace", None) + + super().__init__(*args, **kwargs) + + self.config = config + self.app = config.get_app() + self.enum = self.app.enum + + self.caps_lock = False + self.caps_map = dict([(k, k.upper()) for k in "abcdefghijklmnopqrstuvwxyz"]) + + self.shift = False + self.shift_map = { + "`": "~", + "1": "!", + "2": "@", + "3": "#", + "4": "$", + "5": "%", + "6": "^", + "7": "&", + "8": "*", + "9": "(", + "0": ")", + "-": "_", + "=": "+", + "[": "{", + "]": "}", + "\\": "|", + ";": ":", + "'": '"', + ",": "<", + ".": ">", + "/": "?", + } + + self.keys = {} + + def make_key( + key, + data=None, + on_click=self.simple_keypress, + on_long_press=None, + width=self.default_button_size, + bgcolor=None, + ): + button = ft.Container( + content=ft.Text( + key, size=self.default_font_size, weight=ft.FontWeight.BOLD + ), + data=data or key, + height=self.default_button_size, + width=width, + on_click=on_click, + on_long_press=on_long_press, + alignment=ft.alignment.center, + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + bgcolor=bgcolor, + ) + self.keys[key] = button + return button + + def caps_click(e): + self.update_caps_lock(not self.caps_lock) + + self.caps_key = make_key("CAPS", on_click=caps_click) + if self.caps_lock: + self.caps_key.bgcolor = "blue" + + def shift_click(e): + self.update_shift(not self.shift) + + self.shift_key = make_key("SHIFT", on_click=shift_click) + if self.shift: + self.shift_key.bgcolor = "blue" + + rows = [ + [make_key(k) for k in "`1234567890-="] + + [make_key("⌫", bgcolor="yellow", on_long_press=self.long_backspace)], + [make_key(k) for k in "qwertyuiop[]\\"], + [self.caps_key] + + [make_key(k) for k in "asdfghjkl;'"] + + [make_key("⏎", bgcolor="blue")], + [self.shift_key] + [make_key(k) for k in "zxcvbnm,./"], + [make_key("SPACE", width=self.default_button_size * 5)], + ] + + rows = [ + ft.Row(controls, alignment=ft.MainAxisAlignment.CENTER) for controls in rows + ] + + self.content = ft.Column(rows) + + def informed_refresh(self, **kwargs): + pass + + def reset(self, e=None): + if self.on_reset: + self.on_reset(e=e) + + def update_caps_lock(self, caps_lock): + self.caps_lock = caps_lock + + if self.caps_lock: + self.caps_key.bgcolor = "blue" + else: + self.caps_key.bgcolor = None + + for key, button in self.keys.items(): + if key in self.caps_map: + if self.caps_lock or self.shift: + button.content.value = self.caps_map[key] + else: + button.content.value = key + + self.update() + + def update_shift(self, shift): + self.shift = shift + + if self.shift: + self.shift_key.bgcolor = "blue" + else: + self.shift_key.bgcolor = None + + for key, button in self.keys.items(): + if key in self.caps_map: + if self.shift or self.caps_lock: + button.content.value = self.caps_map[key] + else: + button.content.value = key + elif key in self.shift_map: + if self.shift: + button.content.value = self.shift_map[key] + else: + button.content.value = key + + self.update() + + def simple_keypress(self, e): + + # maybe inform parent + if self.on_keypress: + key = e.control.content.value + + # avoid callback for certain keys + if key not in ("CAPS", "SHIFT"): + + # translate certain keys + if key == "SPACE": + key = " " + + # let 'em know + self.on_keypress(key) + + # turn off shift key if set + if self.shift: + self.update_shift(False) + + def long_backspace(self, e): + if self.on_long_backspace: + self.on_long_backspace() diff --git a/src/wuttapos/terminal/controls/loginform.py b/src/wuttapos/terminal/controls/loginform.py new file mode 100644 index 0000000..4193459 --- /dev/null +++ b/src/wuttapos/terminal/controls/loginform.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - login form control +""" + +import logging + +import flet as ft + +from wuttapos.terminal.controls.buttons import make_button +from wuttapos.terminal.controls.keyboard import WuttaKeyboard +from wuttapos.terminal.controls.menus.tenkey import WuttaTenkeyMenu + + +log = logging.getLogger(__name__) + + +class WuttaLoginForm(ft.Column): + + def __init__(self, config, page=None, pos=None, *args, **kwargs): + self.on_reset = kwargs.pop("on_reset", None) + + # permission to be checked for login to succeed + self.perm_required = kwargs.pop("perm_required", "pos.ring_sales") + + # may or may not show the username field + # nb. must set this before normal __init__ + if "show_username" in kwargs: + self.show_username = kwargs.pop("show_username") + else: + self.show_username = config.get_bool( + "wuttapos.login.show_username", default=True + ) + + # may or may not show 10-key menu instead of full keyboard + if "use_tenkey" in kwargs: + self.use_tenkey = kwargs.pop("use_tenkey") + else: + self.use_tenkey = config.get_bool( + "wuttapos.login.use_tenkey", default=False + ) + + self.on_login_failure = kwargs.pop("on_login_failure", None) + self.on_authz_failure = kwargs.pop("on_authz_failure", None) + self.on_login_success = kwargs.pop("on_login_success", None) + + super().__init__(*args, **kwargs) + + self.config = config + self.app = config.get_app() + self.enum = self.app.enum + self.pos = pos + + # track which login input has focus + self.focused = None + + login_form = self.build_login_form() + self.expand = True + self.alignment = ft.MainAxisAlignment.CENTER + + if self.use_tenkey: + self.controls = [ + ft.Row( + [ + login_form, + ft.VerticalDivider(), + WuttaTenkeyMenu( + self.config, + pos=self.pos, + simple=True, + on_char=self.tenkey_char, + on_enter=self.tenkey_enter, + ), + ], + alignment=ft.MainAxisAlignment.CENTER, + ), + ] + + else: # full keyboard + self.controls = [ + login_form, + ft.Row(), + ft.Row(), + ft.Row(), + WuttaKeyboard( + self.config, + on_keypress=self.keyboard_keypress, + on_long_backspace=self.keyboard_long_backspace, + ), + ] + + def informed_refresh(self, **kwargs): + pass + + def reset(self, e=None): + if self.on_reset: + self.on_reset(e=e) + + def build_login_form(self): + form_fields = [] + + self.password = ft.TextField( + label="Password", + width=200, + password=True, + on_submit=self.password_submit, + on_focus=self.password_focus, + autofocus=not self.show_username, + ) + self.focused = self.password + + if self.show_username: + self.username = ft.TextField( + label="Login", + width=200, + on_submit=self.username_submit, + on_focus=self.username_focus, + autofocus=True, + ) + form_fields.append(self.username) + self.focused = self.username + + form_fields.append(self.password) + + login_button = make_button( + "Login", + height=60, + width=60 * 2.5, + bgcolor="blue", + on_click=self.attempt_login, + ) + + reset_button = make_button( + "Clear", height=60, width=60 * 2.5, on_click=self.clear_login + ) + + if self.use_tenkey: + form_fields.extend( + [ + ft.Row(), + ft.Row(), + ft.Row( + [ + reset_button, + login_button, + ], + ), + ] + ) + return ft.Column( + controls=form_fields, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ) + + else: # full keyboard + form_fields.extend( + [ + login_button, + reset_button, + ] + ) + return ft.Row( + controls=form_fields, + alignment=ft.MainAxisAlignment.CENTER, + ) + + def keyboard_keypress(self, key): + assert self.focused + + if key == "⏎": # ENTER + + # attempt to submit the login form.. + if self.show_username and self.focused is self.username: + self.username_submit() + else: + if self.password_submit(): + + # here the login has totally worked, which means + # this form has fulfilled its purpose. hence must + # exit early to avoid update() in case we are to + # be redirected etc. otherwise may get errors + # trying to update controls which have already + # been dropped from the page.. + return + + elif key == "⌫": + self.focused.value = self.focused.value[:-1] + + else: + self.focused.value += key + + self.focused.focus() + self.update() + + def keyboard_long_backspace(self): + assert self.focused + self.focused.value = "" + self.focused.focus() + self.update() + + def tenkey_char(self, key): + if key == "@": + return + + self.focused.value = f"{self.focused.value or ''}{key}" + self.update() + + def tenkey_enter(self, e): + if self.show_username and self.focused is self.username: + self.username_submit(e) + self.update() + else: + if not self.password_submit(e): + self.update() + + def username_focus(self, e): + self.focused = self.username + + def username_submit(self, e=None): + if self.username.value: + self.password.focus() + else: + self.username.focus() + + def password_focus(self, e): + self.focused = self.password + + def password_submit(self, e=None): + if self.password.value: + return self.attempt_login(e) + else: + self.password.focus() + return False + + def attempt_login(self, e=None): + if self.show_username and not self.username.value: + self.username.focus() + return False + if not self.password.value: + self.password.focus() + return False + + session = self.app.make_session() + auth = self.app.get_auth_handler() + try: + user = auth.authenticate_user( + session, + self.username.value if self.show_username else None, + self.password.value, + ) + except: + log.exception("user authentication error") + session.close() + if self.on_login_failure: + self.on_login_failure(e) + self.clear_login() + return False + + user_display = str(user) if user else None + has_perm = ( + auth.has_permission(session, user, self.perm_required) if user else False + ) + session.close() + + if user: + + if has_perm: + if self.on_login_success: + self.on_login_success(user, user_display) + return True + + else: + if self.on_authz_failure: + self.on_authz_failure(user, user_display) + self.clear_login() + + else: + if self.on_login_failure: + self.on_login_failure(e) + self.clear_login() + + return False + + def clear_login(self, e=None): + if self.show_username: + self.username.value = "" + self.password.value = "" + if self.show_username: + self.username.focus() + else: + self.password.focus() + self.update() diff --git a/src/wuttapos/terminal/controls/lookup.py b/src/wuttapos/terminal/controls/lookup.py new file mode 100644 index 0000000..69464a5 --- /dev/null +++ b/src/wuttapos/terminal/controls/lookup.py @@ -0,0 +1,364 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - base lookup control +""" + +import flet as ft + +from .keyboard import WuttaKeyboard +from wuttapos.terminal.controls.buttons import make_button + + +class WuttaLookup(ft.Container): + + default_font_size = 40 + font_size = default_font_size * 0.8 + default_button_height_dlg = 80 + disabled_bgcolor = "#aaaaaa" + + long_scroll_delta = 500 + + def __init__(self, config, page=None, *args, **kwargs): + self.on_reset = kwargs.pop("on_reset", None) + self.show_search = kwargs.pop("show_search", True) + self.initial_search = kwargs.pop("initial_search", None) + self.allow_empty_query = kwargs.pop("allow_empty_query", False) + self.on_select = kwargs.pop("on_select", None) + self.on_cancel = kwargs.pop("on_cancel", None) + + super().__init__(*args, **kwargs) + + self.config = config + self.app = config.get_app() + self.enum = self.app.enum + + # TODO: this feels hacky + self.mypage = page + + # track current selection + self.selected_uuid = None + self.selected_datarow = None + + self.search_results = ft.DataTable( + columns=[ + ft.DataColumn(self.make_cell_text(text)) + for text in self.get_results_columns() + ], + data_row_min_height=50, + ) + + self.no_results = ft.Text( + "NO RESULTS", size=32, color="red", weight=ft.FontWeight.BOLD, visible=False + ) + + self.select_button = make_button( + "Select", + font_size=self.font_size * 0.8, + height=self.default_button_height_dlg * 0.8, + width=self.default_button_height_dlg * 1.3, + on_click=self.select_click, + disabled=True, + bgcolor=self.disabled_bgcolor, + ) + + self.up_button = make_button( + "↑", + font_size=self.font_size, + height=self.default_button_height_dlg, + width=self.default_button_height_dlg, + on_click=self.up_click, + on_long_press=self.up_longpress, + ) + + self.down_button = make_button( + "↓", + font_size=self.font_size, + height=self.default_button_height_dlg, + width=self.default_button_height_dlg, + on_click=self.down_click, + on_long_press=self.down_longpress, + ) + + self.search_results_wrapper = ft.Column( + [ + self.search_results, + self.no_results, + ], + expand=True, + height=400, + scroll=ft.ScrollMode.AUTO, + ) + + controls = [] + + if self.show_search: + + self.searchbox = ft.TextField( + "", + text_size=self.font_size * 0.8, + on_submit=self.lookup, + autofocus=True, + expand=True, + ) + + controls.extend( + [ + ft.Row( + [ + ft.Text("SEARCH FOR:"), + self.searchbox, + make_button( + "Lookup", + font_size=self.font_size * 0.8, + height=self.default_button_height_dlg * 0.8, + width=self.default_button_height_dlg * 1.3, + on_click=self.lookup, + bgcolor="blue", + ), + make_button( + "Reset", + font_size=self.font_size * 0.8, + height=self.default_button_height_dlg * 0.8, + width=self.default_button_height_dlg * 1.3, + on_click=self.reset, + bgcolor="yellow", + ), + ], + ), + ft.Divider(), + WuttaKeyboard( + self.config, + on_keypress=self.keypress, + on_long_backspace=self.long_backspace, + ), + ] + ) + + controls.extend( + [ + ft.Divider(), + ft.Row( + [ + self.search_results_wrapper, + ft.VerticalDivider(), + ft.Column( + [ + self.select_button, + ft.Row(), + ft.Row(), + ft.Row(), + ft.Row(), + ft.Row(), + self.up_button, + self.down_button, + ft.Row(), + ft.Row(), + ft.Row(), + ft.Row(), + ft.Row(), + make_button( + "Cancel", + font_size=self.font_size * 0.8, + height=self.default_button_height_dlg * 0.8, + width=self.default_button_height_dlg * 1.3, + on_click=self.cancel, + ), + ], + ), + ], + vertical_alignment=ft.CrossAxisAlignment.START, + ), + ] + ) + + self.content = ft.Column(controls=controls) + self.height = None if self.show_search else 600 + + def informed_refresh(self, **kwargs): + pass + + def reset(self, e=None): + if self.on_reset: + self.on_reset(e=e) + + def get_results_columns(self): + raise NotImplementedError + + def did_mount(self): + if self.initial_search is not None: + if self.show_search: + self.searchbox.value = self.initial_search + self.initial_search = None # only do it once + self.update() + self.lookup() + + def make_cell_text(self, text): + return ft.Text(text, size=32) + + def make_cell(self, text): + return ft.DataCell(self.make_cell_text(text)) + + def cancel(self, e): + if self.on_cancel: + self.on_cancel(e) + + def keypress(self, key): + if key == "⏎": + self.lookup() + else: + if key == "⌫": + self.searchbox.value = self.searchbox.value[:-1] + else: + self.searchbox.value += key + self.searchbox.focus() + self.update() + + def long_backspace(self): + self.searchbox.value = self.searchbox.value[:-10] + self.searchbox.focus() + self.update() + + def get_results(self, session, entry): + raise NotImplementedError + + def make_result_row(self, obj): + return obj + + def lookup(self, e=None): + + if self.show_search: + entry = self.searchbox.value + if not entry and not self.allow_empty_query: + self.searchbox.focus() + self.update() + return + else: + entry = None + + session = self.app.make_session() + results = self.get_results(session, entry) + + self.search_results.rows.clear() + self.selected_uuid = None + self.select_button.disabled = True + self.select_button.bgcolor = self.disabled_bgcolor + + if results: + for obj in results: + self.search_results.rows.append( + ft.DataRow( + cells=[ + self.make_cell(row) for row in self.make_result_row(obj) + ], + on_select_changed=self.select_changed, + data={"uuid": obj["uuid"]}, + ) + ) + self.no_results.visible = False + + else: + if self.show_search: + self.no_results.value = f"NO RESULTS FOR: {entry}" + else: + self.no_results.value = "NO RESULTS FOUND" + self.no_results.visible = True + + if self.show_search: + self.searchbox.focus() + self.update() + + def reset(self, e): + if self.show_search: + self.searchbox.value = "" + self.search_results.rows.clear() + self.no_results.visible = False + self.selected_uuid = None + self.selected_datarow = None + self.select_button.disabled = True + self.select_button.bgcolor = self.disabled_bgcolor + if self.show_search: + self.searchbox.focus() + self.update() + + def set_selection(self, row): + if self.selected_datarow: + self.selected_datarow.color = None + + row.color = ft.colors.BLUE + self.selected_uuid = row.data["uuid"] + self.selected_datarow = row + + self.select_button.disabled = False + self.select_button.bgcolor = "blue" + + def select_changed(self, e): + if e.data: # selected + self.set_selection(e.control) + self.update() + + def up_click(self, e): + + # select previous row, if selection in progress + if self.selected_datarow: + i = self.search_results.rows.index(self.selected_datarow) + if i > 0: + self.search_results_wrapper.scroll_to(delta=-48, duration=100) + self.set_selection(self.search_results.rows[i - 1]) + self.update() + return + + self.search_results_wrapper.scroll_to(delta=-50, duration=100) + self.update() + + def up_longpress(self, e): + self.search_results_wrapper.scroll_to( + delta=-self.long_scroll_delta, duration=100 + ) + self.update() + + def down_click(self, e): + + # select next row, if selection in progress + if self.selected_datarow: + i = self.search_results.rows.index(self.selected_datarow) + if (i + 1) < len(self.search_results.rows): + self.search_results_wrapper.scroll_to(delta=48, duration=100) + self.set_selection(self.search_results.rows[i + 1]) + self.update() + return + + self.search_results_wrapper.scroll_to(delta=50, duration=100) + self.update() + + def down_longpress(self, e): + self.search_results_wrapper.scroll_to( + delta=self.long_scroll_delta, duration=100 + ) + self.update() + + def select_click(self, e): + if not self.selected_uuid: + raise RuntimeError("no record selected?") + if self.on_select: + self.on_select(self.selected_uuid) diff --git a/src/wuttapos/terminal/controls/menus/__init__.py b/src/wuttapos/terminal/controls/menus/__init__.py new file mode 100644 index 0000000..3557a06 --- /dev/null +++ b/src/wuttapos/terminal/controls/menus/__init__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - button menus +""" + +from .base import WuttaMenu diff --git a/src/wuttapos/terminal/controls/menus/base.py b/src/wuttapos/terminal/controls/menus/base.py new file mode 100644 index 0000000..796dbf9 --- /dev/null +++ b/src/wuttapos/terminal/controls/menus/base.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - button menus +""" + +import flet as ft + +from wuttapos.terminal.controls.buttons import make_button, WuttaButtonRow + + +class WuttaMenu(ft.Container): + """ + Base class for button menu controls. + """ + + # TODO: should be configurable somehow + default_button_size = 100 + default_font_size = 40 + + def __init__(self, config, pos=None, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.config = config + self.app = self.config.get_app() + self.pos = pos + + self.content = ft.Column(controls=self.build_controls(), spacing=0) + + def make_button(self, *args, **kwargs): + kwargs.setdefault("font_size", self.default_font_size) + kwargs.setdefault("height", self.default_button_size) + kwargs.setdefault("width", self.default_button_size * 2) + kwargs.setdefault("pos", self.pos) + return make_button(*args, **kwargs) + + def make_button_row(self, *args, **kwargs): + return WuttaButtonRow(*args, **kwargs) diff --git a/src/wuttapos/terminal/controls/menus/context.py b/src/wuttapos/terminal/controls/menus/context.py new file mode 100644 index 0000000..8b7a088 --- /dev/null +++ b/src/wuttapos/terminal/controls/menus/context.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - "context" menu +""" + +from .base import WuttaMenu + + +class WuttaContextMenu(WuttaMenu): + + def build_controls(self): + # TODO: this should be empty by default, just giving + # a couple of exmples until more functionality exists + return [ + self.make_button_row( + [ + self.make_button( + "Refresh", bgcolor="blue", font_size=30, pos_cmd="refresh_txn" + ), + self.make_button( + "No-op", bgcolor="blue", font_size=30, pos_cmd="noop" + ), + ] + ), + ] diff --git a/src/wuttapos/terminal/controls/menus/master.py b/src/wuttapos/terminal/controls/menus/master.py new file mode 100644 index 0000000..ff4dfff --- /dev/null +++ b/src/wuttapos/terminal/controls/menus/master.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - menus master control +""" + +import flet as ft + +from wuttapos.terminal.controls.menus.tenkey import WuttaTenkeyMenu +from wuttapos.terminal.controls.menus.meta import WuttaMetaMenu +from wuttapos.terminal.controls.menus.suspend import WuttaSuspendMenu + + +class WuttaMenuMaster(ft.Column): + """ + Base class and default implementation for "buttons master" + control. This represents the overall button area in POS view. + """ + + def __init__(self, config, pos=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.config = config + self.app = self.config.get_app() + self.pos = pos + self.controls = self.build_controls() + + def build_controls(self): + self.tenkey_menu = self.build_tenkey_menu() + self.meta_menu = self.build_meta_menu() + self.context_menu = self.build_context_menu() + self.suspend_menu = self.build_suspend_menu() + + return [ + ft.Row( + [ + self.tenkey_menu, + self.meta_menu, + ], + ), + ft.Row( + [ + self.context_menu, + self.suspend_menu, + ], + vertical_alignment=ft.CrossAxisAlignment.START, + ), + ] + + ############################## + # tenkey + ############################## + + def build_tenkey_menu(self): + return WuttaTenkeyMenu( + self.config, + pos=self.pos, + on_char=self.tenkey_char, + on_enter=self.tenkey_enter, + on_up_click=self.tenkey_up_click, + on_up_longpress=self.tenkey_up_longpress, + on_down_click=self.tenkey_down_click, + on_down_longpress=self.tenkey_down_longpress, + ) + + def tenkey_char(self, key): + self.pos.cmd("entry_append", key) + + def tenkey_enter(self, e): + self.pos.cmd("entry_submit") + + def tenkey_up_click(self, e): + self.pos.cmd("scroll_up") + + def tenkey_up_longpress(self, e): + self.pos.cmd("scroll_up_page") + + def tenkey_down_click(self, e): + self.pos.cmd("scroll_down") + + def tenkey_down_longpress(self, e): + self.pos.cmd("scroll_down_page") + + ############################## + # meta + ############################## + + def build_meta_menu(self): + return WuttaMetaMenu(self.config, pos=self.pos) + + ############################## + # context + ############################## + + def build_context_menu(self): + spec = self.config.get( + "wuttapos.menus.context.spec", + default="wuttapos.terminal.controls.menus.context:WuttaContextMenu", + ) + factory = self.app.load_object(spec) + return factory(self.config, pos=self.pos) + + def replace_context_menu(self, menu): + controls = menu.build_controls() + self.context_menu.content.controls = controls + self.update() + + ############################## + # suspend + ############################## + + def build_suspend_menu(self): + return WuttaSuspendMenu(self.config, pos=self.pos) diff --git a/src/wuttapos/terminal/controls/menus/meta.py b/src/wuttapos/terminal/controls/menus/meta.py new file mode 100644 index 0000000..fd42621 --- /dev/null +++ b/src/wuttapos/terminal/controls/menus/meta.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - "meta" menu +""" + +from .base import WuttaMenu + + +class WuttaMetaMenu(WuttaMenu): + + def build_controls(self): + return [ + self.make_button_row( + [ + self.make_button("CUST", bgcolor="blue", pos_cmd="customer_dwim"), + self.make_button("VOID", bgcolor="red", pos_cmd="void_dwim"), + ] + ), + self.make_button_row( + [ + self.make_button("ITEM", bgcolor="blue", pos_cmd="item_dwim"), + self.make_button("MGR", bgcolor="yellow", pos_cmd="manager_dwim"), + ] + ), + self.make_button_row( + [ + self.make_button( + "OPEN RING", + font_size=32, + bgcolor="blue", + pos_cmd="open_ring_dwim", + ), + self.make_button( + "NO SALE", bgcolor="yellow", pos_cmd="no_sale_dwim" + ), + ] + ), + self.make_button_row( + [ + self.make_button( + "Adjust\nPrice", + font_size=30, + bgcolor="yellow", + pos_cmd="adjust_price_dwim", + ), + self.make_button("REFUND", bgcolor="red", pos_cmd="refund_dwim"), + ] + ), + ] diff --git a/src/wuttapos/terminal/controls/menus/suspend.py b/src/wuttapos/terminal/controls/menus/suspend.py new file mode 100644 index 0000000..802686f --- /dev/null +++ b/src/wuttapos/terminal/controls/menus/suspend.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - "suspend" menu +""" + +from .base import WuttaMenu + + +class WuttaSuspendMenu(WuttaMenu): + + def build_controls(self): + return [ + self.make_button_row( + [ + self.make_button( + "SUSPEND", bgcolor="purple", pos_cmd="suspend_txn" + ), + self.make_button("RESUME", bgcolor="purple", pos_cmd="resume_txn"), + ] + ), + self.make_button_row( + [ + self.make_button( + "Cash", + bgcolor="orange", + pos_cmd="tender", + pos_cmd_kwargs={"tender": {"code": "CA"}}, + ), + self.make_button( + "Check", + bgcolor="orange", + pos_cmd="tender", + pos_cmd_kwargs={"tender": {"code": "CK"}}, + ), + ] + ), + self.make_button_row( + [ + self.make_button( + "Food Stamps", + bgcolor="orange", + font_size=34, + pos_cmd="tender", + pos_cmd_kwargs={"tender": {"code": "FS"}}, + ), + ] + ), + ] diff --git a/src/wuttapos/terminal/controls/menus/tenkey.py b/src/wuttapos/terminal/controls/menus/tenkey.py new file mode 100644 index 0000000..4226591 --- /dev/null +++ b/src/wuttapos/terminal/controls/menus/tenkey.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - ten-key menu +""" + +import flet as ft + +from .base import WuttaMenu + + +class WuttaTenkeyMenu(WuttaMenu): + + def __init__(self, *args, **kwargs): + self.simple = kwargs.pop("simple", False) + self.on_char = kwargs.pop("on_char", None) + self.on_enter = kwargs.pop("on_enter", None) + self.on_up_click = kwargs.pop("on_up_click", None) + self.on_up_longpress = kwargs.pop("on_up_longpress", None) + self.on_down_click = kwargs.pop("on_down_click", None) + self.on_down_longpress = kwargs.pop("on_down_longpress", None) + + super().__init__(*args, **kwargs) + + def build_controls(self): + + row1 = [ + self.make_tenkey_button("1"), + self.make_tenkey_button("2"), + self.make_tenkey_button("3"), + ] + if not self.simple: + row1.extend( + [ + self.make_tenkey_button("-"), + ] + ) + + row2 = [ + self.make_tenkey_button("4"), + self.make_tenkey_button("5"), + self.make_tenkey_button("6"), + ] + if not self.simple: + row2.extend( + [ + self.make_tenkey_button("↑", on_long_press=self.up_long_press), + ] + ) + + row3 = [ + self.make_tenkey_button("7"), + self.make_tenkey_button("8"), + self.make_tenkey_button("9"), + ] + if not self.simple: + row3.extend( + [ + self.make_tenkey_button("↓", on_long_press=self.down_long_press), + ] + ) + + row4 = [ + self.make_tenkey_button("0"), + # self.make_tenkey_button("00"), + self.make_tenkey_button("."), + ] + if self.simple: + row4.extend( + [ + self.make_tenkey_button("⏎"), + ] + ) + else: + row4.extend( + [ + self.make_tenkey_button( + "ENTER", width=self.default_button_size * 2 + ), + ] + ) + + return [ + ft.Row( + controls=row1, + spacing=0, + ), + ft.Row( + controls=row2, + spacing=0, + ), + ft.Row( + controls=row3, + spacing=0, + ), + ft.Row( + controls=row4, + spacing=0, + ), + ] + + def make_tenkey_button( + self, + text, + width=None, + on_long_press=None, + ): + if not width: + width = self.default_button_size + + return self.make_button( + text, + bgcolor="green", + width=width, + on_click=self.tenkey_click, + on_long_press=on_long_press, + ) + + def tenkey_click(self, e): + value = e.control.content.value + + if value in ("ENTER", "⏎"): + if self.on_enter: + self.on_enter(e) + + elif value == "↑": # UP + if self.on_up_click: + self.on_up_click(e) + + elif value == "↓": # DOWN + if self.on_down_click: + self.on_down_click(e) + + else: # normal char key + if self.on_char: + self.on_char(value) + + def up_long_press(self, e): + if self.on_up_longpress: + self.on_up_longpress(e) + + def down_long_press(self, e): + if self.on_down_longpress: + self.on_down_longpress(e) diff --git a/src/wuttapos/terminal/controls/timestamp.py b/src/wuttapos/terminal/controls/timestamp.py new file mode 100644 index 0000000..a0ec102 --- /dev/null +++ b/src/wuttapos/terminal/controls/timestamp.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - timestamp control +""" + +import asyncio +import datetime +import threading +import time + +import flet as ft + + +class WuttaTimestamp(ft.Text): + + def __init__(self, config, page=None, *args, **kwargs): + self.on_reset = kwargs.pop("on_reset", None) + + super().__init__(*args, **kwargs) + + self.config = config + self.app = self.config.get_app() + + self.value = self.render_time(datetime.datetime.now()) + + def did_mount(self): + self.running = True + if hasattr(self.page, "run_task"): + self.page.run_task(self.update_display) + else: + # nb. daemonized thread should be stopped when app exits + # cf. https://docs.python.org/3/library/threading.html#thread-objects + thread = threading.Thread(target=self.update_display_blocking, daemon=True) + thread.start() + + def will_unmount(self): + self.running = False + + def render_time(self, value): + return value.strftime("%a %d %b %Y - %I:%M:%S %p") + + async def update_display(self): + while self.running: + self.value = self.render_time(datetime.datetime.now()) + self.update() + await asyncio.sleep(0.5) + + def update_display_blocking(self): + while self.running: + # self.value = self.render_time(self.app.localtime()) + self.value = self.render_time(datetime.datetime.now()) + self.update() + time.sleep(0.5) diff --git a/src/wuttapos/terminal/controls/txnitem.py b/src/wuttapos/terminal/controls/txnitem.py new file mode 100644 index 0000000..93c0d1e --- /dev/null +++ b/src/wuttapos/terminal/controls/txnitem.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - txn item control +""" + +import flet as ft + + +class WuttaTxnItem(ft.Row): + """ + Control for displaying a transaction line item within main POS + items list. + """ + + font_size = 24 + + def __init__(self, config, line, page=None, *args, **kwargs): + self.on_reset = kwargs.pop("on_reset", None) + + super().__init__(*args, **kwargs) + + self.config = config + self.app = config.get_app() + self.enum = self.app.enum + + self.line = line + + self.major_style = ft.TextStyle(size=self.font_size, weight=ft.FontWeight.BOLD) + + self.minor_style = ft.TextStyle(size=int(self.font_size * 0.8), italic=True) + + # if self.row.row_type in (self.enum.POS_ROW_TYPE_SELL, + # self.enum.POS_ROW_TYPE_OPEN_RING): + # self.build_item_sell() + + # elif self.row.row_type in (self.enum.POS_ROW_TYPE_TENDER, + # self.enum.POS_ROW_TYPE_CHANGE_BACK): + # self.build_item_tender() + + if self.line.trans_type in ("I",): + self.build_item_sell() + + elif self.line.trans_type in ("T",): + self.build_item_tender() + + def build_item_sell(self): + + self.quantity = ft.TextSpan(style=self.minor_style) + self.txn_price = ft.TextSpan(style=self.minor_style) + + self.sales_total_style = ft.TextStyle( + size=self.font_size, weight=ft.FontWeight.BOLD + ) + + self.sales_total = ft.TextSpan(style=self.sales_total_style) + + self.fs_flag = ft.TextSpan(style=self.minor_style) + self.tax_flag = ft.TextSpan(style=self.minor_style) + + # set initial text display values + self.refresh(update=False) + + self.controls = [ + ft.Text( + spans=[ + ft.TextSpan(f"{self.line.description}", style=self.major_style), + ft.TextSpan("× ", style=self.minor_style), + self.quantity, + ft.TextSpan(" @ ", style=self.minor_style), + self.txn_price, + ], + ), + ft.Text( + spans=[ + self.fs_flag, + self.tax_flag, + self.sales_total, + ], + ), + ] + self.alignment = ft.MainAxisAlignment.SPACE_BETWEEN + + def build_item_tender(self): + self.controls = [ + ft.Text( + spans=[ + ft.TextSpan(f"{self.line.description}", style=self.major_style), + ], + ), + ft.Text( + spans=[ + ft.TextSpan( + self.app.render_currency(self.line.total), + style=self.major_style, + ), + ], + ), + ] + self.alignment = ft.MainAxisAlignment.SPACE_BETWEEN + + def informed_refresh(self, **kwargs): + pass + + def reset(self, e=None): + if self.on_reset: + self.on_reset(e=e) + + def refresh(self, update=True): + + # if self.row.void: + if self.line.voided: + self.major_style.color = None + self.major_style.decoration = ft.TextDecoration.LINE_THROUGH + self.major_style.weight = None + self.minor_style.color = None + self.minor_style.decoration = ft.TextDecoration.LINE_THROUGH + else: + self.major_style.color = None + self.major_style.decoration = None + self.major_style.weight = ft.FontWeight.BOLD + self.minor_style.color = None + self.minor_style.decoration = None + + # if self.row.row_type in (self.enum.POS_ROW_TYPE_SELL, + # self.enum.POS_ROW_TYPE_OPEN_RING): + if self.line.trans_type in ("I",): + self.quantity.text = self.app.render_quantity(self.line.ItemQtty) + self.txn_price.text = self.app.render_currency(self.line.unitPrice) + self.sales_total.text = self.app.render_currency(self.line.total) + self.fs_flag.text = "FS " if self.line.foodstamp else "" + self.tax_flag.text = f"T{self.line.tax} " if self.line.tax else "" + + if self.line.voided: + self.sales_total_style.color = None + self.sales_total_style.decoration = ft.TextDecoration.LINE_THROUGH + self.sales_total_style.weight = None + else: + # if (self.row.row_type == self.enum.POS_ROW_TYPE_SELL + # and self.row.txn_price_adjusted): + # self.sales_total_style.color = 'orange' + # elif (self.row.row_type == self.enum.POS_ROW_TYPE_SELL + # and self.row.cur_price and self.row.cur_price < self.row.reg_price): + # self.sales_total_style.color = 'green' + # else: + # self.sales_total_style.color = None + + # TODO + self.sales_total_style.color = None + + self.sales_total_style.decoration = None + self.sales_total_style.weight = ft.FontWeight.BOLD + + if update: + self.update() diff --git a/src/wuttapos/terminal/controls/txnlookup.py b/src/wuttapos/terminal/controls/txnlookup.py new file mode 100644 index 0000000..f51958f --- /dev/null +++ b/src/wuttapos/terminal/controls/txnlookup.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - transaction lookup control +""" + +from .lookup import WuttaLookup + + +class WuttaTransactionLookup(WuttaLookup): + + def __init__(self, *args, **kwargs): + + # nb. this forces first query + kwargs.setdefault("initial_search", True) + + # TODO: how to deal with 'modes' + self.mode = kwargs.pop("mode", None) + if not self.mode: + raise ValueError("must specify mode") + if self.mode != "resume": + raise ValueError("only 'resume' mode is supported") + + kwargs.setdefault("show_search", False) + + super().__init__(*args, **kwargs) + + def get_results_columns(self): + return [ + "Date/Time", + "Terminal", + "Txn ID", + "Cashier", + "Customer", + "Balance", + ] + + def get_results(self, session, entry): + # model = self.app.model + + # # TODO: how to deal with 'modes' + # assert self.mode == 'resume' + # training = bool(self.mypage.session.get('training')) + # query = session.query(model.POSBatch)\ + # .filter(model.POSBatch.status_code == model.POSBatch.STATUS_SUSPENDED)\ + # .filter(model.POSBatch.executed == None)\ + # .filter(model.POSBatch.training_mode == training)\ + # .order_by(model.POSBatch.created.desc()) + + # transactions = [] + # for batch in query: + # # TODO: should use 'suspended' timestamp instead here? + # # dt = self.app.localtime(batch.created, from_utc=True) + # dt = batch.created + # transactions.append({ + # 'uuid': batch.uuid, + # 'datetime': self.app.render_datetime(dt), + # 'terminal': batch.terminal_id, + # 'txnid': batch.id_str, + # 'cashier': str(batch.cashier or ''), + # 'customer': str(batch.customer or ''), + # 'balance': self.app.render_currency(batch.get_balance()), + # }) + # return transactions + + # TODO + return [] + + def make_result_row(self, txn): + return [ + txn["datetime"], + txn["terminal"], + txn["txnid"], + txn["cashier"], + txn["customer"], + txn["balance"], + ] diff --git a/src/wuttapos/terminal/util.py b/src/wuttapos/terminal/util.py new file mode 100644 index 0000000..fe4f974 --- /dev/null +++ b/src/wuttapos/terminal/util.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS utilities +""" + +import flet as ft + + +def show_snackbar(page, text, bgcolor="yellow"): + snack_bar = ft.SnackBar( + ft.Text(text, color="black", size=40, weight=ft.FontWeight.BOLD), + bgcolor=bgcolor, + duration=1500, + ) + page.overlay.append(snack_bar) + snack_bar.open = True diff --git a/src/wuttapos/terminal/views/__init__.py b/src/wuttapos/terminal/views/__init__.py new file mode 100644 index 0000000..b74fc0f --- /dev/null +++ b/src/wuttapos/terminal/views/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - flet views +""" diff --git a/src/wuttapos/terminal/views/base.py b/src/wuttapos/terminal/views/base.py new file mode 100644 index 0000000..669a2b2 --- /dev/null +++ b/src/wuttapos/terminal/views/base.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - flet views (base class) +""" + +import os + +from wuttjamaican.util import resource_path + +import flet as ft + +from wuttapos.terminal.controls.header import WuttaHeader +from wuttapos.terminal.controls.buttons import make_button +from wuttapos.terminal.util import show_snackbar + + +class WuttaView(ft.View): + """ + Base class for all Flet views used in WuttaPOS + """ + + def __init__(self, config, *args, **kwargs): + super().__init__(*args, **kwargs) + self.config = config + self.app = self.config.get_app() + + controls = self.build_controls() + self.controls = [ + WuttaViewContainer( + self.config, content=ft.Column(controls=controls), expand=True + ), + ] + + def build_controls(self): + return [self.build_header()] + + def build_header(self): + handler = self.get_transaction_handler() + self.header = WuttaHeader( + self.config, on_reset=self.reset, terminal_id=handler.get_terminal_id() + ) + return self.header + + def get_transaction_handler(self): + return self.app.get_transaction_handler() + + def make_button(self, *args, **kwargs): + return make_button(*args, **kwargs) + + def reset(self, *args, **kwargs): + pass + + def make_logo_image(self, **kwargs): + + # we have a default header logo, but prefer custom if present + custom = resource_path("wuttapos.terminal:assets/custom_header_logo.png") + if os.path.exists(custom): + logo = "/custom_header_logo.png" + else: + logo = "/header_logo.png" + + # but config can override in any case + logo = self.config.get("wuttapos.header.logo", default=logo) + + kwargs.setdefault("height", 100) + return ft.Image(src=logo, **kwargs) + + def show_snackbar(self, text, bgcolor="yellow"): + show_snackbar(self.page, text, bgcolor=bgcolor) + + +class WuttaViewContainer(ft.Container): + """ + Main container class to wrap all controls for a view. Used for + displaying background image etc. + """ + + def __init__(self, config, *args, **kwargs): + self.config = config + + # # add testing watermark when not in production + # if "image_src" not in kwargs and not self.config.production(): + # kwargs["image_src"] = "/testing.png" + # kwargs.setdefault("image_repeat", ft.ImageRepeat.REPEAT) + + super().__init__(*args, **kwargs) diff --git a/src/wuttapos/terminal/views/login.py b/src/wuttapos/terminal/views/login.py new file mode 100644 index 0000000..fd587ce --- /dev/null +++ b/src/wuttapos/terminal/views/login.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - login view +""" + +import flet as ft + +from .base import WuttaView +from wuttapos.terminal.controls.loginform import WuttaLoginForm + + +class LoginView(WuttaView): + """ + Main POS view for WuttaPOS + """ + + def build_controls(self): + title = self.app.get_title() + + controls = [ + ft.Row( + [self.make_logo_image(height=200)], + alignment=ft.MainAxisAlignment.CENTER, + ), + ft.Row( + [ + ft.Text( + value=f"Welcome to {title}", weight=ft.FontWeight.BOLD, size=28 + ) + ], + alignment=ft.MainAxisAlignment.CENTER, + ), + ft.Row(), + ft.Row(), + ft.Row(), + WuttaLoginForm( + self.config, + on_login_failure=self.login_failure, + on_authz_failure=self.authz_failure, + on_login_success=self.login_success, + ), + ] + + return [ + self.build_header(), + ft.Column( + controls=controls, expand=True, alignment=ft.MainAxisAlignment.CENTER + ), + ] + + def login_failure(self, e): + self.show_snackbar("Login failed!", bgcolor="yellow") + self.page.update() + + def authz_failure(self, user, user_display): + self.show_snackbar( + f"User not allowed to ring sales: {user_display}", bgcolor="yellow" + ) + self.page.update() + + def login_success(self, user, user_display): + self.page.session.set("user_uuid", user.uuid.hex) + self.page.session.set("user_display", user_display) + + # TODO: hacky but works for now + if not self.config.production(): + self.page.client_storage.set("user_uuid", user.uuid.hex) + + self.page.go("/pos") diff --git a/src/wuttapos/terminal/views/pos.py b/src/wuttapos/terminal/views/pos.py new file mode 100644 index 0000000..600255e --- /dev/null +++ b/src/wuttapos/terminal/views/pos.py @@ -0,0 +1,1903 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttaPOS -- Point of Sale system based on Wutta Framework +# Copyright © 2026 Lance Edgar +# +# This file is part of WuttaPOS. +# +# WuttaPOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# WuttaPOS. If not, see . +# +################################################################################ +""" +WuttaPOS - POS view +""" + +import decimal +import logging +import time + +import flet as ft + +from .base import WuttaView +from wuttapos.terminal.controls.loginform import WuttaLoginForm +from wuttapos.terminal.controls.custlookup import WuttaCustomerLookup +from wuttapos.terminal.controls.itemlookup import WuttaProductLookup +from wuttapos.terminal.controls.itemlookup_dept import WuttaProductLookupByDepartment +from wuttapos.terminal.controls.deptlookup import WuttaDepartmentLookup +from wuttapos.terminal.controls.txnlookup import WuttaTransactionLookup +from wuttapos.terminal.controls.txnitem import WuttaTxnItem +from wuttapos.terminal.controls.menus.tenkey import WuttaTenkeyMenu + + +log = logging.getLogger(__name__) + + +class POSView(WuttaView): + """ + Main POS view for WuttaPOS + """ + + # TODO: should be configurable? + default_button_size = 100 + default_font_size = 40 + + disabled_bgcolor = "#aaaaaa" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # keep a list of "informed" controls - i.e. child controls + # within this view, which need to stay abreast of global + # changes to the transaction, customer etc. + self.informed_controls = [] + if hasattr(self, "header"): + self.informed_controls.append(self.header) + + def informed_refresh(self): + for control in self.informed_controls: + control.informed_refresh() + + def reset(self, e=None, clear_quantity=True): + """ + This is a convenience method, meant only to clear the main + input and set focus to it. Will also update() the page. + + The ``e`` arg is ignored and accepted only so this method may + be registered as an event handler, e.g. ``on_cancel``. + """ + # clear set (@) quantity + if clear_quantity: + if self.set_quantity.data: + self.set_quantity.data = None + self.set_quantity.value = None + self.set_quantity_button.visible = True + + # clear/focus main input + self.main_input.value = "" + self.main_input.focus() + + self.page.update() + + def set_customer(self, customer, batch=None, user=None): + session = self.app.get_session(customer) + if not batch: + batch = self.get_current_batch(session) + if user: + user = session.get(user.__class__, user.uuid) + else: + user = self.get_current_user(session) + + handler = self.get_batch_handler() + handler.set_customer(batch, customer, user=user) + + self.page.session.set("txn_display", handler.get_screen_txn_display(batch)) + self.page.session.set("cust_uuid", customer.uuid) + self.page.session.set( + "cust_display", handler.get_screen_cust_display(customer=customer) + ) + self.informed_refresh() + self.refresh_totals(batch) + + self.show_snackbar(f"CUSTOMER SET: {customer}", bgcolor="green") + + def refresh_totals(self, txn): + reg = ft.TextStyle(size=22) + bold = ft.TextStyle(size=24, weight=ft.FontWeight.BOLD) + + self.subtotals.spans.clear() + + sales_total = txn["sales_total"] + self.subtotals.spans.append(ft.TextSpan("Sales ", style=reg)) + total = self.app.render_currency(sales_total) + self.subtotals.spans.append(ft.TextSpan(total, style=bold)) + + tax_total = 0 + for tax_id, tax in sorted(txn["taxes"].items()): + if tax["tax_total"]: + self.subtotals.spans.append( + ft.TextSpan(f" Tax {tax_id} ", style=reg) + ) + total = self.app.render_currency(tax["tax_total"]) + self.subtotals.spans.append(ft.TextSpan(total, style=bold)) + tax_total += tax["tax_total"] + + tender_total = sum( + [tender["tender_total"] for tender in txn["tenders"].values()] + ) + if tender_total: + self.subtotals.spans.append(ft.TextSpan(f" Tend ", style=reg)) + total = self.app.render_currency(tender_total) + self.subtotals.spans.append(ft.TextSpan(total, style=bold)) + + self.fs_balance.spans.clear() + fs_total = txn["foodstamp"] + fs_balance = fs_total + tender_total + if fs_balance: + self.fs_balance.spans.append(ft.TextSpan("FS ", style=reg)) + total = self.app.render_currency(fs_balance) + self.fs_balance.spans.append(ft.TextSpan(total, style=bold)) + + self.balances.spans.clear() + total_due = sales_total + tax_total + tender_total + total_due = self.app.render_currency(total_due) + self.balances.spans.append(ft.TextSpan(" ", style=reg)) + self.balances.spans.append( + ft.TextSpan( + total_due, style=ft.TextStyle(size=40, weight=ft.FontWeight.BOLD) + ) + ) + + self.totals_row.bgcolor = "orange" + + def attempt_add_product(self, uuid=None, record_badscan=False): + session = self.app.make_session() + handler = self.get_batch_handler() + user = self.get_current_user(session) + batch = self.get_current_batch(session, user=user) + entry = self.main_input.value + + quantity = 1 + if self.set_quantity.data is not None: + quantity = self.set_quantity.data + + product = None + item_entry = entry + if uuid: + product = session.get(self.model.Product, uuid) + assert product + key = self.app.get_product_key_field() + item_entry = str(getattr(product, key) or "") or uuid + + try: + row = handler.process_entry( + batch, + product or entry, + quantity=quantity, + item_entry=item_entry, + user=user, + ) + except Exception as error: + session.rollback() + self.show_snackbar(f"ERROR: {error}", bgcolor="yellow") + row = None + + else: + + if row: + session.commit() + + if row.row_type == self.enum.POS_ROW_TYPE_BADPRICE: + self.show_snackbar( + f"Product has invalid price: {row.item_entry}", bgcolor="yellow" + ) + + else: + self.add_row_item(row, scroll=True) + self.refresh_totals(batch) + self.reset() + + else: + + if record_badscan: + handler.record_badscan(batch, entry, quantity=quantity, user=user) + + self.show_snackbar(f"PRODUCT NOT FOUND: {entry}", bgcolor="yellow") + + session.commit() + self.refresh_totals(batch) + + session.close() + self.page.update() + return bool(row) + + def item_lookup(self, value=None): + + def select(uuid): + self.attempt_add_product(uuid=uuid) + dlg.open = False + self.reset() + + def cancel(e): + dlg.open = False + self.reset(clear_quantity=False) + + dlg = ft.AlertDialog( + modal=True, + title=ft.Text("Product Lookup"), + content=WuttaProductLookup( + self.config, initial_search=value, on_select=select, on_cancel=cancel + ), + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def customer_lookup(self, value=None, user=None): + + def select(uuid): + session = self.app.make_session() + customer = session.get(self.model.Customer, uuid) + self.set_customer(customer, user=user) + session.commit() + session.close() + + dlg.open = False + self.reset() + + def cancel(e): + dlg.open = False + self.reset() + + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.reset() + + # dlg = ft.AlertDialog( + # modal=True, + # title=ft.Text("Customer Lookup"), + # content=WuttaCustomerLookup(self.config, initial_search=value, + # on_select=select, on_cancel=cancel), + # ) + + # # self.page.open(dlg) + + # self.page.dialog = dlg + # dlg.open = True + # self.page.update() + + def customer_info(self): + # clientele = self.app.get_clientele_handler() + # session = self.app.make_session() + + # entry = self.main_input.value + # if entry: + # different = True + # customer = clientele.locate_customer_for_entry(session, entry) + # if not customer: + # session.close() + # self.show_snackbar(f"CUSTOMER NOT FOUND: {entry}", bgcolor='yellow') + # self.page.update() + # return + + # else: + # different = False + # customer = session.get(self.model.Customer, self.page.session.get('cust_uuid')) + # assert customer + + # info = clientele.get_customer_info_markdown(customer) + # session.close() + + info = "TODO: customer info" + different = False + + def close(e): + dlg.open = False + self.reset() + + font_size = self.default_font_size * 0.8 + dlg = ft.AlertDialog( + # modal=True, + title=ft.Text("Customer Info"), + content=ft.Container( + ft.Column( + [ + ft.Container( + content=ft.Text( + "NOTE: this is a DIFFERENT customer than the txn has!" + ), + bgcolor="yellow", + visible=different, + ), + ft.Divider(), + ft.Container( + theme_mode=ft.ThemeMode.SYSTEM, + theme=ft.Theme( + text_theme=ft.TextTheme( + body_medium=ft.TextStyle( + size=24, + color="black", + ) + ) + ), + content=ft.Markdown(info), + ), + ], + height=500, + width=500, + ) + ), + actions=[ + ft.Container( + content=ft.Text("Close", size=font_size, weight=ft.FontWeight.BOLD), + height=self.default_button_size * 0.8, + width=self.default_button_size * 1.2, + alignment=ft.alignment.center, + bgcolor="blue", + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + on_click=close, + ), + ], + # actions_alignment=ft.MainAxisAlignment.END, + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def customer_prompt(self): + + def view_info(e): + dlg.open = False + self.page.update() + + # cf. https://github.com/flet-dev/flet/issues/1670 + time.sleep(0.1) + + self.customer_info() + + def remove(e): + dlg.open = False + self.page.update() + + # cf. https://github.com/flet-dev/flet/issues/1670 + time.sleep(0.1) + + self.remove_customer_prompt() + + def replace(e): + dlg.open = False + self.page.update() + + # nb. do this just in case we must show login dialog + # cf. https://github.com/flet-dev/flet/issues/1670 + time.sleep(0.1) + + self.authorized_action( + "pos.swap_customer", self.replace_customer, message="Replace Customer" + ) + + def cancel(e): + dlg.open = False + self.reset() + + font_size = self.default_font_size * 0.8 + dlg = ft.AlertDialog( + # modal=True, + title=ft.Text("Customer Already Selected"), + content=ft.Text("What would you like to do?", size=20), + actions=[ + ft.Container( + content=ft.Text( + "Remove", size=font_size, weight=ft.FontWeight.BOLD + ), + height=self.default_button_size, + width=self.default_button_size * 2.5, + alignment=ft.alignment.center, + bgcolor="red", + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + on_click=remove, + ), + ft.Container( + content=ft.Text( + "Replace", + size=font_size, + color="black", + weight=ft.FontWeight.BOLD, + ), + height=self.default_button_size, + width=self.default_button_size * 2.5, + alignment=ft.alignment.center, + bgcolor="yellow", + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + on_click=replace, + ), + ft.Container( + content=ft.Text( + "View Info", size=font_size, weight=ft.FontWeight.BOLD + ), + height=self.default_button_size, + width=self.default_button_size * 2.5, + alignment=ft.alignment.center, + bgcolor="blue", + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + on_click=view_info, + ), + ft.Container( + content=ft.Text( + "Cancel", + size=font_size, + # color='black', + weight=ft.FontWeight.BOLD, + ), + height=self.default_button_size, + width=self.default_button_size * 2.5, + alignment=ft.alignment.center, + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + on_click=cancel, + ), + ], + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def remove_customer_prompt(self): + + def remove(e): + dlg.open = False + self.page.update() + + # nb. do this just in case we must show login dialog + # cf. https://github.com/flet-dev/flet/issues/1670 + time.sleep(0.1) + + self.authorized_action( + "pos.del_customer", self.remove_customer, message="Remove Customer" + ) + + def cancel(e): + dlg.open = False + self.reset() + + font_size = self.default_font_size * 0.8 + dlg = ft.AlertDialog( + title=ft.Text("Remove Customer"), + content=ft.Text( + "Really remove the customer from this transaction?", size=20 + ), + actions=[ + ft.Container( + content=ft.Text( + "Yes, Remove", size=font_size, weight=ft.FontWeight.BOLD + ), + height=self.default_button_size, + width=self.default_button_size * 2.5, + alignment=ft.alignment.center, + bgcolor="red", + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + on_click=remove, + ), + ft.Container( + content=ft.Text( + "Cancel", size=font_size, weight=ft.FontWeight.BOLD + ), + height=self.default_button_size, + width=self.default_button_size * 2.5, + alignment=ft.alignment.center, + border=ft.border.all(1, "black"), + border_radius=ft.border_radius.all(5), + on_click=cancel, + ), + ], + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def remove_customer(self, user): + + # session = self.app.make_session() + # handler = self.get_batch_handler() + # batch = self.get_current_batch(session) + # user = session.get(user.__class__, user.uuid) + # handler.set_customer(batch, None, user=user) + # session.commit() + # session.close() + + # self.page.session.set('cust_uuid', None) + # self.page.session.set('cust_display', None) + # self.informed_refresh() + # self.show_snackbar("CUSTOMER REMOVED", bgcolor='yellow') + # self.reset() + + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.reset() + + def replace_customer(self, user): + entry = self.main_input.value + if entry: + if not self.attempt_set_customer(entry, user=user): + self.customer_lookup(entry, user=user) + else: + self.customer_lookup(user=user) + + def attempt_set_customer(self, entry=None, user=None): + session = self.app.make_session() + + customer = self.app.get_clientele_handler().locate_customer_for_entry( + session, entry + ) + if customer: + + self.set_customer(customer, user=user) + self.reset() + + else: # customer not found + self.show_snackbar(f"CUSTOMER NOT FOUND: {entry}", bgcolor="yellow") + # TODO: should use reset() here? + self.main_input.focus() + self.page.update() + + session.commit() + session.close() + return bool(customer) + + def build_controls(self): + + # handler = self.get_transaction_handler() + # corepos = self.app.get_corepos_handler() + # op_session = corepos.make_session_lane_op() + # self.tender_cash = handler.get_tender(op_session, 'cash') + # self.tender_check = handler.get_tender(op_session, 'check') + # self.tender_foodstamp = handler.get_tender(op_session, 'foodstamp') + # op_session.expunge_all() + # op_session.close() + + self.main_input = ft.TextField( + on_submit=self.main_submit, + text_size=24, + text_style=ft.TextStyle(weight=ft.FontWeight.BOLD), + autofocus=True, + ) + + self.selected_item = None + self.items = ft.ListView( + item_extent=50, + height=800, + ) + + self.subtotals = ft.Text(spans=[]) + self.fs_balance = ft.Text(spans=[]) + self.balances = ft.Text(spans=[]) + + self.totals_row = ft.Container( + ft.Row( + [ + self.subtotals, + ft.Row( + [ + self.fs_balance, + self.balances, + ], + ), + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + ), + padding=ft.padding.only(10, 0, 10, 0), + ) + + self.items_column = ft.Column( + controls=[ + ft.Container(content=self.items, padding=ft.padding.only(10, 0, 10, 0)), + self.totals_row, + ], + expand=1, + ) + + def backspace_click(e): + if self.main_input.value: + self.main_input.value = self.main_input.value[:-1] + self.main_input.focus() + self.page.update() + + def clear_entry_click(e): + if self.main_input.value: + self.main_input.value = "" + elif self.set_quantity.data is not None: + self.set_quantity.data = None + self.set_quantity.value = None + self.set_quantity_button.visible = True + elif self.selected_item: + self.clear_item_selection() + self.main_input.focus() + self.page.update() + + self.set_quantity = ft.Text( + value=None, data=None, weight=ft.FontWeight.BOLD, size=40 + ) + + self.set_quantity_button = self.make_button( + "@", + font_size=40, + height=70, + width=70, + bgcolor="green", + on_click=self.set_quantity_click, + ) + + spec = self.config.get( + "wuttapos.menus.master.spec", + default="wuttapos.terminal.controls.menus.master:WuttaMenuMaster", + ) + factory = self.app.load_object(spec) + self.menu_master = factory(self.config, pos=self) + + return [ + self.build_header(), + ft.Row( + [ + self.make_logo_image(height=80), + ft.Row( + [ + ft.Row( + [ + self.set_quantity, + self.set_quantity_button, + ], + ), + self.main_input, + self.make_button( + "⌫", + font_size=40, + bgcolor="green", + height=70, + width=70, + on_click=backspace_click, + ), + self.make_button( + "CE", + font_size=40, + bgcolor="green", + height=70, + width=70, + on_click=clear_entry_click, + ), + ], + alignment=ft.MainAxisAlignment.CENTER, + expand=True, + ), + ], + ), + ft.Row(), + ft.Row(), + ft.Row(), + ft.Row( + [ + self.items_column, + self.menu_master, + ], + vertical_alignment=ft.CrossAxisAlignment.START, + ), + ] + + def make_button(self, *args, **kwargs): + kwargs.setdefault("pos", self) + return super().make_button(*args, **kwargs) + + def make_text(self, *args, **kwargs): + kwargs.setdefault("weight", ft.FontWeight.BOLD) + kwargs.setdefault("size", 24) + return ft.Text(*args, **kwargs) + + def set_quantity_click(self, e): + quantity = self.main_input.value + valid = False + + if self.set_quantity.data is not None: + quantity = self.set_quantity.data + self.show_snackbar(f"QUANTITY ALREADY SET: {quantity}", bgcolor="yellow") + + else: + try: + quantity = decimal.Decimal(quantity) + valid = True + except decimal.InvalidOperation: + pass + + if valid and quantity: + self.set_quantity.data = quantity + self.set_quantity.value = self.app.render_quantity(quantity) + " @ " + self.set_quantity_button.visible = False + self.main_input.value = "" + self.main_input.focus() + + else: + self.show_snackbar(f"INVALID @ QUANTITY: {quantity}", bgcolor="yellow") + + self.page.update() + + def suspend_transaction(self, user): + # session = self.app.make_session() + # batch = self.get_current_batch(session) + # user = session.get(user.__class__, user.uuid) + # handler = self.get_batch_handler() + + # handler.suspend_transaction(batch, user) + + # session.commit() + # session.close() + # self.clear_all() + # self.reset() + + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.reset() + + def get_current_user(self, session): + model = self.app.model + uuid = self.page.session.get("user_uuid") + if uuid: + return session.get(model.User, uuid) + + def get_current_batch(self, session, user=None, create=True): + handler = self.get_batch_handler() + + if not user: + user = self.get_current_user(session) + + training = bool(self.page.session.get("training")) + batch, created = handler.get_current_batch( + user, training_mode=training, create=create, return_created=True + ) + + if created: + self.page.session.set("txn_display", handler.get_screen_txn_display(batch)) + self.informed_refresh() + + return batch + + def refresh_training(self): + if self.page.session.get("training"): + self.bgcolor = "#E4D97C" + else: + self.bgcolor = None + + def get_current_transaction(self, session, user=None, create=True): + handler = self.get_transaction_handler() + + if not user: + user = self.get_current_user(session) + + training = bool(self.page.session.get("training")) + txn, created = handler.get_current_transaction( + user, training_mode=training, create=create, return_created=True + ) + + if created: + self.page.session.set("txn_display", handler.get_screen_txn_display(txn)) + self.informed_refresh() + + return txn + + def did_mount(self): + session = self.app.make_session() + + txn = self.get_current_transaction(session, create=False) + if txn: + self.load_transaction(txn) + else: + self.page.session.set("txn_display", None) + self.page.session.set("cust_uuid", None) + self.page.session.set("cust_display", None) + self.informed_refresh() + + self.refresh_training() + + # TODO: i think commit() was for when it auto-created the + # batch, so that can go away now..right? + # session.commit() + session.close() + self.page.update() + + def load_transaction(self, txn): + """ + Load the given data as the current transaction. + """ + handler = self.get_transaction_handler() + self.page.session.set("txn_display", handler.get_screen_txn_display(txn)) + self.page.session.set("cust_uuid", txn["customer_id"]) + self.page.session.set("cust_display", handler.get_screen_cust_display(txn=txn)) + + self.items.controls.clear() + for line in txn["lines"]: + self.add_row_item(line) + self.items.scroll_to(offset=-1, duration=100) + + self.refresh_totals(txn) + self.informed_refresh() + + def not_supported(self, e=None, feature=None): + + # test error handler + if e.control.data and e.control.data.get("error"): + raise RuntimeError("NOT YET SUPPORTED") + + text = "NOT YET SUPPORTED" + if not feature and e: + feature = e.control.content.value.replace("\n", " ") + if feature: + text += f": {feature}" + self.show_snackbar(text, bgcolor="yellow") + self.page.update() + + def require_decimal(self, value): + try: + amount = decimal.Decimal(value) + except decimal.InvalidOperation: + self.show_snackbar(f"Amount is not valid: {value}", bgcolor="yellow") + return False + + if "." not in value: + self.show_snackbar(f"Decimal point required: {value}", bgcolor="yellow") + return False + + return amount + + def adjust_price(self, user): + + def cancel(e): + dlg.open = False + self.main_input.focus() + self.page.update() + + def clear(e): + price_override.value = "" + price_override.focus() + self.page.update() + + def tenkey_char(key): + price_override.value = f"{price_override.value or ''}{key}" + self.page.update() + + def confirm(e): + price = self.require_decimal(price_override.value) + if price is False: + self.main_input.focus() + self.page.update() + return + + dlg.open = False + + session = self.app.make_session() + user = self.get_current_user(session) + handler = self.get_batch_handler() + + row = self.selected_item.data["row"] + row = session.get(row.__class__, row.uuid) + + new_row = handler.override_price(row, user, price) + session.commit() + + # update screen to reflect new balance + batch = row.batch + self.refresh_totals(batch) + + # update item display + self.selected_item.data["row"] = row + self.selected_item.content.row = row + self.selected_item.content.refresh() + self.items.update() + + session.expunge_all() + session.close() + self.clear_item_selection() + self.reset() + + row = self.selected_item.data["row"] + + price = f"{row.txn_price:0.2f}" + if self.main_input.value: + try: + price = decimal.Decimal(self.main_input.value) + except decimal.InvalidOperation: + pass + else: + price = f"{price:0.2f}" + + price_override = ft.TextField( + value=price, + text_size=32, + text_style=ft.TextStyle(weight=ft.FontWeight.BOLD), + autofocus=True, + on_submit=confirm, + ) + + current_price = self.app.render_currency(row.cur_price) + if current_price: + current_price += " [{}]".format( + self.enum.PRICE_TYPE.get(row.cur_price_type, row.cur_price_type) + ) + + dlg = ft.AlertDialog( + modal=True, + title=ft.Text("Adjust Price"), + content=ft.Container( + ft.Column( + [ + ft.Divider(), + ft.Row( + [ + ft.Text( + "Reg Price:", size=32, weight=ft.FontWeight.BOLD + ), + ft.Text( + self.app.render_currency(row.reg_price), + size=32, + weight=ft.FontWeight.BOLD, + ), + ], + ), + ft.Row(), + ft.Row( + [ + ft.Text( + "Cur Price:", size=32, weight=ft.FontWeight.BOLD + ), + ft.Text( + current_price, size=32, weight=ft.FontWeight.BOLD + ), + ], + ), + ft.Row(), + ft.Row(), + ft.Row( + [ + ft.Text( + "Txn Price:", size=32, weight=ft.FontWeight.BOLD + ), + ft.VerticalDivider(), + ft.Text("$", size=32, weight=ft.FontWeight.BOLD), + price_override, + ], + ), + ft.Row(), + ft.Row(), + ft.Row( + [ + WuttaTenkeyMenu( + self.config, + simple=True, + on_char=tenkey_char, + on_enter=confirm, + ), + self.make_button( + "Clear", + height=self.default_button_size * 0.8, + width=self.default_button_size * 1.2, + on_click=clear, + ), + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.START, + ), + ], + ), + height=700, + width=550, + ), + actions=[ + self.make_button( + "Cancel", + height=self.default_button_size * 0.8, + width=self.default_button_size * 1.2, + on_click=cancel, + ), + self.make_button( + "Confirm", + bgcolor="blue", + height=self.default_button_size * 0.8, + width=self.default_button_size * 1.2, + on_click=confirm, + ), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def toggle_training_mode(self, user): + was_training = self.page.session.get("training") + now_training = not was_training + + # TODO: hacky but works for now + if not self.config.production(): + self.page.client_storage.set("training", now_training) + + self.page.session.set("training", now_training) + + self.refresh_training() + self.informed_refresh() + self.reset() + + def kick_drawer(self): + self.show_snackbar("TODO: Drawer Kick", bgcolor="yellow") + self.page.update() + + def add_row_item(self, line, scroll=False): + + # TODO: row types ugh + if line.trans_type not in ("I", "T"): + return + + # # TODO: row types ugh + # if row.row_type not in (self.enum.POS_ROW_TYPE_SELL, + # self.enum.POS_ROW_TYPE_OPEN_RING, + # self.enum.POS_ROW_TYPE_TENDER, + # self.enum.POS_ROW_TYPE_CHANGE_BACK): + # return + + self.items.controls.append( + ft.Container( + content=WuttaTxnItem(self.config, line), + border=ft.border.only(bottom=ft.border.BorderSide(1, "gray")), + padding=ft.padding.only(5, 5, 5, 5), + on_click=self.list_item_click, + # data={'row': row}, + data={"line": line}, + # key=row.uuid, + key=line.trans_id, + bgcolor="white", + ) + ) + + if scroll: + self.items.scroll_to(offset=-1, duration=100) + + def list_item_click(self, e): + self.select_txn_item(e.control) + + def select_txn_item(self, item): + if self.selected_item: + self.clear_item_selection() + + self.selected_item = item + self.selected_item.bgcolor = "blue" + self.page.update() + + def authorized_action(self, perm, action, cancel=None, message=None): + auth = self.app.get_auth_handler() + + # current user is assumed if they have the perm + session = self.app.make_session() + user = self.get_current_user(session) + has_perm = auth.has_permission(session, user, perm) + session.expunge(user) + session.close() + if has_perm: + action(user) + return + + # otherwise must prompt for different user credentials... + + def login_cancel(e): + dlg.open = False + if cancel: + cancel() + self.reset() + + def login_failure(e): + self.show_snackbar("Login failed", bgcolor="yellow") + self.page.update() + + def authz_failure(user, user_display): + self.show_snackbar( + f"User does not have permission: {user_display}", bgcolor="yellow" + ) + self.page.update() + + def login_success(user, user_display): + dlg.open = False + self.page.update() + + # nb. just in case next step requires a dialog + # cf. https://github.com/flet-dev/flet/issues/1670 + time.sleep(0.1) + + action(user) + self.reset() + + title = "Manager Override" + if message: + title = f"{title} - {message}" + + dlg = ft.AlertDialog( + modal=True, + title=ft.Text(title), + content=ft.Container( + ft.Column( + [ + ft.Divider(), + WuttaLoginForm( + self.config, + pos=self, + perm_required=perm, + on_login_success=login_success, + on_login_failure=login_failure, + on_authz_failure=authz_failure, + ), + ], + ), + height=600, + ), + actions=[ + self.make_button("Cancel", on_click=login_cancel, height=80, width=120), + ], + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def void_transaction(self, user): + # session = self.app.make_session() + # batch = self.get_current_batch(session) + # user = session.get(user.__class__, user.uuid) + # handler = self.get_batch_handler() + + # handler.void_batch(batch, user) + + # session.commit() + # session.close() + # self.clear_all() + # self.reset() + + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.reset() + + def clear_item_selection(self): + if self.selected_item: + self.selected_item.bgcolor = "white" + self.selected_item.content.refresh() + self.selected_item = None + + def clear_all(self): + self.items.controls.clear() + + self.subtotals.spans.clear() + self.fs_balance.spans.clear() + self.balances.spans.clear() + self.totals_row.bgcolor = None + + self.page.session.set("txn_display", None) + self.page.session.set("cust_uuid", None) + self.page.session.set("cust_display", None) + self.informed_refresh() + + def main_submit(self, e=None): + if self.main_input.value: + self.attempt_add_product(record_badscan=True) + self.reset() + + ############################## + # pos cmd methods + ############################## + + def cmd(self, cmdname, entry=None, **kwargs): + """ + Run a POS command. + """ + meth = getattr(self, f"cmd_{cmdname}", None) + if meth: + meth(entry=entry, **kwargs) + else: + log.warning( + "unknown cmd requested: %s, entry=%s, %s", cmdname, repr(entry), kwargs + ) + self.show_snackbar(f"Unknown command: {cmdname}", bgcolor="yellow") + self.page.update() + + def cmd_noop(self, entry=None, **kwargs): + """ """ + self.show_snackbar("Doing nothing", bgcolor="green") + self.main_input.focus() + self.page.update() + + def cmd_adjust_price_dwim(self, entry=None, **kwargs): + + if not len(self.items.controls): + self.show_snackbar("There are no line items", bgcolor="yellow") + self.reset() + return + + if not self.selected_item: + self.show_snackbar("Must first select a line item", bgcolor="yellow") + self.main_input.focus() + self.page.update() + return + + # row = self.selected_item.data['row'] + # if row.void or row.row_type not in (self.enum.POS_ROW_TYPE_SELL, + # self.enum.POS_ROW_TYPE_OPEN_RING): + # self.show_snackbar("This item cannot be adjusted", bgcolor='yellow') + # self.main_input.focus() + # self.page.update() + # return + + # self.authorized_action('pos.override_price', self.adjust_price, + # message="Adjust Price") + + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.page.update() + + def cmd_context_menu(self, entry=None, **kwargs): + """ + Swap out which context menu is currently shown. + """ + spec = self.config.require(f"wuttapos.menus.{entry}.spec") + factory = self.app.load_object(spec) + menu = factory(self.config, pos=self) + self.menu_master.replace_context_menu(menu) + + def cmd_customer_dwim(self, entry=None, **kwargs): + + # prompt user to replace customer if already set + if self.page.session.get("cust_uuid"): + self.customer_prompt() + + else: + value = self.main_input.value + if value: + # okay try to set it with given value + if not self.attempt_set_customer(value): + self.customer_lookup(value) + + else: + # no value provided, so do lookup + self.customer_lookup() + + def cmd_entry_append(self, entry=None, **kwargs): + """ + Run a POS command. + """ + if entry is not None: + self.main_input.value = f"{self.main_input.value or ''}{entry}" + self.main_input.focus() + self.page.update() + + def cmd_entry_submit(self, entry=None, **kwargs): + self.main_submit() + + def cmd_item_dwim(self, entry=None, **kwargs): + + value = self.main_input.value + if value: + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.reset() + # if not self.attempt_add_product(): + # self.item_lookup(value) + + elif self.selected_item: + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.reset() + # row = self.selected_item.data['row'] + # if row.product_uuid: + # if self.attempt_add_product(uuid=row.product_uuid): + # self.clear_item_selection() + # self.page.update() + # else: + # self.item_lookup() + + else: + # self.item_lookup() + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.reset() + + def cmd_item_menu_dept(self, entry=None, **kwargs): + """ + Show item lookup dialog, restricted to the given department. + """ + key = entry + if not key: + raise ValueError("must specify department key") + + org = self.app.get_org_handler() + session = self.app.make_session() + department = org.get_department(session, key) + session.close() + if not department: + raise ValueError(f"department not found: {key}") + + def select(uuid): + self.attempt_add_product(uuid=uuid) + dlg.open = False + self.reset() + + def cancel(e): + dlg.open = False + self.reset() + + dlg = ft.AlertDialog( + title=ft.Text(f"Item from Department: {department.name}"), + content=WuttaProductLookupByDepartment( + self.config, + department, + page=self.page, + on_select=select, + on_cancel=cancel, + ), + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def cmd_manager_dwim(self, entry=None, **kwargs): + + def toggle_training(e): + dlg.open = False + self.page.update() + + session = self.app.make_session() + txn = self.get_current_transaction(session, create=False) + session.close() + if txn: + self.show_snackbar("TRANSACTION IN PROGRESS") + self.reset() + + else: + # nb. do this just in case we must show login dialog + # cf. https://github.com/flet-dev/flet/issues/1670 + time.sleep(0.1) + + training = self.page.session.get("training") + toggle = "End" if training else "Start" + self.authorized_action( + "pos.toggle_training", + self.toggle_training_mode, + message=f"{toggle} Training Mode", + ) + + def cancel(e): + dlg.open = False + self.reset() + + font_size = 32 + toggle = "End" if self.page.session.get("training") else "Start" + dlg = ft.AlertDialog( + title=ft.Text("Manager Menu"), + content=ft.Text("What would you like to do?", size=20), + actions=[ + self.make_button( + f"{toggle} Training", + font_size=font_size, + height=self.default_button_size, + width=self.default_button_size * 2.5, + bgcolor="yellow", + on_click=toggle_training, + ), + self.make_button( + "Cancel", + font_size=font_size, + height=self.default_button_size, + width=self.default_button_size * 2.5, + on_click=cancel, + ), + ], + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def cmd_no_sale_dwim(self, entry=None, **kwargs): + + session = self.app.make_session() + txn = self.get_current_transaction(session, create=False) + session.close() + + if txn: + self.show_snackbar("TRANSACTION IN PROGRESS", bgcolor="yellow") + self.page.update() + return + + self.kick_drawer() + + def cmd_open_ring_dwim(self, entry=None, **kwargs): + + value = self.main_input.value or None + if not value: + self.show_snackbar("Must first enter an amount") + self.reset() + return + + amount = self.require_decimal(value) + if amount is False: + self.reset() + return + + def select(uuid): + # session = self.app.make_session() + # user = self.get_current_user(session) + # batch = self.get_current_batch(session, user=user) + # handler = self.get_batch_handler() + + # quantity = 1 + # if self.set_quantity.data is not None: + # quantity = self.set_quantity.data + + # row = handler.add_open_ring(batch, uuid, amount, quantity=quantity, user=user) + # session.commit() + + # self.add_row_item(row, scroll=True) + # self.refresh_totals(batch) + # session.close() + + # dlg.open = False + # self.reset() + + dlg.open = False + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.reset() + + def cancel(e): + dlg.open = False + self.reset(clear_quantity=False) + + dlg = ft.AlertDialog( + modal=True, + title=ft.Text( + f"Department Lookup - for {self.app.render_currency(amount)} OPEN RING" + ), + content=WuttaDepartmentLookup( + self.config, on_select=select, on_cancel=cancel + ), + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def cmd_refund_dwim(self, entry=None, **kwargs): + self.show_snackbar("TODO: handle refund") + self.page.update() + + def cmd_refresh_txn(self, entry=None, **kwargs): + session = self.app.make_session() + + txn = self.get_current_transaction(session, create=False) + if txn: + self.load_transaction(txn) + else: + self.page.session.set("txn_display", None) + self.page.session.set("cust_uuid", None) + self.page.session.set("cust_display", None) + self.informed_refresh() + + self.refresh_training() + + # TODO: i think commit() was for when it auto-created the + # batch, so that can go away now..right? + # session.commit() + session.close() + self.show_snackbar("Transaction refreshed", bgcolor="green") + self.page.update() + + def cmd_resume_txn(self, entry=None, **kwargs): + session = self.app.make_session() + txn = self.get_current_transaction(session, create=False) + session.close() + + # can't resume if txn in progress + if txn: + self.show_snackbar("TRANSACTION IN PROGRESS", bgcolor="yellow") + self.reset() + return + + def select(uuid): + # session = self.app.make_session() + # user = self.get_current_user(session) + # handler = self.get_batch_handler() + + # # TODO: this would need to work differently if suspended + # # txns are kept in a central server DB + # batch = session.get(self.app.model.POSBatch, uuid) + + # batch = handler.resume_transaction(batch, user) + # session.commit() + + # session.refresh(batch) + # self.load_batch(batch) + # session.close() + + # dlg.open = False + # self.reset() + + dlg.open = False + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.reset() + + def cancel(e): + dlg.open = False + self.reset() + + # prompt to choose txn + dlg = ft.AlertDialog( + title=ft.Text("Resume Transaction"), + content=WuttaTransactionLookup( + self.config, + page=self.page, + mode="resume", + on_select=select, + on_cancel=cancel, + ), + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def cmd_scroll_down(self, entry=None, **kwargs): + + # select next item, if selection in progress + if self.selected_item: + i = self.items.controls.index(self.selected_item) + if (i + 1) < len(self.items.controls): + self.items.scroll_to(delta=50, duration=100) + self.select_txn_item(self.items.controls[i + 1]) + return + + self.items.scroll_to(delta=50, duration=100) + self.page.update() + + def cmd_scroll_down_page(self, entry=None, **kwargs): + self.items.scroll_to(delta=500, duration=100) + self.page.update() + + def cmd_scroll_up(self, entry=None, **kwargs): + + # select previous item, if selection in progress + if self.selected_item: + i = self.items.controls.index(self.selected_item) + if i > 0: + self.items.scroll_to(delta=-50, duration=100) + self.select_txn_item(self.items.controls[i - 1]) + return + + self.items.scroll_to(delta=-50, duration=100) + self.page.update() + + def cmd_scroll_up_page(self, entry=None, **kwargs): + self.items.scroll_to(delta=-500, duration=100) + self.page.update() + + def cmd_suspend_txn(self, entry=None, **kwargs): + + session = self.app.make_session() + txn = self.get_current_transaction(session, create=False) + session.close() + + # nothing to suspend if no txn + if not txn: + self.show_snackbar("NO TRANSACTION", bgcolor="yellow") + self.reset() + return + + def confirm(e): + dlg.open = False + self.page.update() + + # nb. do this just in case we must show login dialog + # cf. https://github.com/flet-dev/flet/issues/1670 + time.sleep(0.1) + + self.authorized_action( + "pos.suspend", self.suspend_transaction, message="Suspend Transaction" + ) + + def cancel(e): + dlg.open = False + self.reset() + + # prompt to suspend + dlg = ft.AlertDialog( + title=ft.Text("Confirm SUSPEND"), + content=ft.Text("Really SUSPEND transaction?"), + actions=[ + self.make_button( + f"Yes, SUSPEND", + font_size=self.default_font_size, + height=self.default_button_size, + width=self.default_button_size * 3, + bgcolor="yellow", + on_click=confirm, + ), + self.make_button( + "Cancel", + font_size=self.default_font_size, + height=self.default_button_size, + width=self.default_button_size * 2.5, + on_click=cancel, + ), + ], + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update() + + def cmd_tender(self, entry=None, **kwargs): + # model = self.app.model + # session = self.app.make_session() + # handler = self.get_batch_handler() + # user = self.get_current_user(session) + # batch = self.get_current_batch(session, user=user, create=False) + + # tender = kwargs.get('tender') + # if isinstance(tender, model.Tender): + # code = tender.code + # elif tender: + # code = tender['code'] + # elif entry: + # code = entry + # if not code: + # raise ValueError("must specify tender code") + + self.show_snackbar("TODO: not implemented", bgcolor="yellow") + self.reset() + + # # nothing to do if no transaction + # if not batch: + # session.close() + # self.show_snackbar("NO TRANSACTION", bgcolor='yellow') + # self.reset() + # return + + # # nothing to do if zero sales + # if not batch.get_balance(): + # session.close() + # self.show_snackbar("NO SALES", bgcolor='yellow') + # self.reset() + # return + + # # nothing to do if no amount provided + # if not self.main_input.value: + # session.close() + # self.show_snackbar("MUST SPECIFY AMOUNT", bgcolor='yellow') + # self.reset() + # return + + # # nothing to do if amount not valid + # amount = self.require_decimal(self.main_input.value) + # if amount is False: + # session.close() + # self.reset() + # return + + # # do nothing if @ quantity present + # if self.set_quantity.data: + # session.close() + # self.show_snackbar(f"QUANTITY NOT ALLOWED FOR TENDER: {self.set_quantity.value}", + # bgcolor='yellow') + # self.reset() + # return + + # # tender / execute batch + # try: + + # # apply tender amount to batch + # # nb. this *may* execute the batch! + # # nb. we negate the amount supplied by user + # rows = handler.apply_tender(batch, user, tender, -amount) + + # except Exception as error: + # session.rollback() + # log.exception("failed to apply tender '%s' for %s in batch %s", + # code, amount, batch.id_str) + # self.show_snackbar(f"ERROR: {error}", bgcolor='red') + + # else: + # session.commit() + + # # update screen to reflect new items/balance + # for row in rows: + # self.add_row_item(row, scroll=True) + # self.refresh_totals(batch) + + # # executed batch means txn was finalized + # if batch.executed: + + # # look for "change back" row, if found then show alert + # last_row = rows[-1] + # if last_row.row_type == self.enum.POS_ROW_TYPE_CHANGE_BACK: + + # def close_bs(e): + # # user dismissed the change back alert; clear screen + # bs.open = False + # bs.update() + # self.clear_all() + # self.reset() + + # bs = ft.BottomSheet( + # ft.Container( + # ft.Column( + # [ + # ft.Text("Change Due", size=24, + # weight=ft.FontWeight.BOLD), + # ft.Divider(), + # ft.Text("Please give customer their change:", + # size=20), + # ft.Text(self.app.render_currency(last_row.tender_total), + # size=32, weight=ft.FontWeight.BOLD), + # ft.Container( + # content=self.make_button("Dismiss", on_click=close_bs, + # height=80, width=120), + # alignment=ft.alignment.center, + # expand=1, + # ), + # ], + # ), + # bgcolor='green', + # padding=20, + # ), + # open=True, + # dismissible=False, + # ) + + # # show change back alert + # # nb. we do *not* clear screen yet + # self.page.overlay.append(bs) + + # else: + # # txn finalized but no change back; clear screen + # self.clear_all() + + # # kick drawer if accepting any tender which requires + # # that, or if we are giving change back + # first_row = rows[0] + # if ((first_row.tender and first_row.tender.kick_drawer) + # or last_row.row_type == self.enum.POS_ROW_TYPE_CHANGE_BACK): + # self.kick_drawer() + + # finally: + # session.close() + + # self.reset() + + def cmd_void_dwim(self, entry=None, **kwargs): + + session = self.app.make_session() + txn = self.get_current_transaction(session, create=False) + session.close() + + # nothing to void if no txn + if not txn: + self.show_snackbar("NO TRANSACTION", bgcolor="yellow") + self.reset() + return + + def confirm(e): + dlg.open = False + self.page.update() + + if self.selected_item: + + session = self.app.make_session() + handler = self.get_batch_handler() + user = self.get_current_user(session) + batch = self.get_current_batch(session, user=user) + + # void line + row = self.selected_item.data["row"] + if row.void: + # cannot void an already void line + self.show_snackbar("LINE ALREADY VOID", bgcolor="yellow") + + elif row.row_type not in ( + self.enum.POS_ROW_TYPE_SELL, + self.enum.POS_ROW_TYPE_OPEN_RING, + ): + # cannot void line unless of type 'sell' + self.show_snackbar("LINE DOES NOT ALLOW VOID", bgcolor="yellow") + + else: + # okay, void the line + row = session.get(row.__class__, row.uuid) + handler.void_row(row, user) + session.commit() + + # refresh display + self.selected_item.data["row"] = row + self.selected_item.content.row = row + self.selected_item.content.refresh() + self.clear_item_selection() + self.refresh_totals(batch) + + session.close() + self.reset() + + else: # void txn + + # nb. do this just in case we must show login dialog + # cf. https://github.com/flet-dev/flet/issues/1670 + time.sleep(0.1) + + self.authorized_action( + "pos.void_txn", self.void_transaction, message="Void Transaction" + ) + + def cancel(e): + dlg.open = False + self.reset() + + # prompt to void something + target = "LINE" if self.selected_item else "TXN" + dlg = ft.AlertDialog( + title=ft.Text("Confirm VOID"), + content=ft.Text(f"Really VOID {target}?"), + actions=[ + self.make_button( + f"VOID {target}", + font_size=self.default_font_size, + height=self.default_button_size, + width=self.default_button_size * 2.5, + bgcolor="red", + on_click=confirm, + ), + self.make_button( + "Cancel", + font_size=self.default_font_size, + height=self.default_button_size, + width=self.default_button_size * 2.5, + on_click=cancel, + ), + ], + ) + + # self.page.open(dlg) + + self.page.dialog = dlg + dlg.open = True + self.page.update()