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: + + % 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 0000000..96fae96 Binary files /dev/null and b/src/wuttapos/terminal/assets/header_logo.png differ diff --git a/src/wuttapos/terminal/assets/testing.png b/src/wuttapos/terminal/assets/testing.png new file mode 100644 index 0000000..7228b33 Binary files /dev/null and b/src/wuttapos/terminal/assets/testing.png differ 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()