diff --git a/pyproject.toml b/pyproject.toml
index a1584f1..a7f6141 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,7 +42,8 @@ dependencies = [
[project.optional-dependencies]
server = ["WuttaWeb[continuum]"]
-terminal = ["flet[all]<0.80.0"]
+# terminal = ["flet[all]<0.80.0"]
+terminal = ["flet[all]<0.21"]
[project.scripts]
diff --git a/src/wuttapos/app.py b/src/wuttapos/app.py
index 4e0859c..7ede529 100644
--- a/src/wuttapos/app.py
+++ b/src/wuttapos/app.py
@@ -31,3 +31,20 @@ class WuttaPosAppProvider(base.AppProvider):
"""
Custom :term:`app provider` for WuttaPOS.
"""
+
+ email_templates = ["wuttapos:email-templates"]
+
+ def get_transaction_handler(self):
+ """
+ Get the configured :term:`transaction handler`.
+
+ :rtype: :class:`~wuttapos.handler.TransactionHandler`
+ """
+ if "transaction" not in self.app.handlers:
+ spec = self.config.get(
+ "wuttapos.transaction_handler",
+ default="wuttapos.handler:TransactionHandler",
+ )
+ factory = self.app.load_object(spec)
+ self.app.handlers["transaction"] = factory(self.config)
+ return self.app.handlers["transaction"]
diff --git a/src/wuttapos/cli/__init__.py b/src/wuttapos/cli/__init__.py
index 3dc0190..e9b1fec 100644
--- a/src/wuttapos/cli/__init__.py
+++ b/src/wuttapos/cli/__init__.py
@@ -28,3 +28,5 @@ from .base import wuttapos_typer
# nb. must bring in all modules for discovery to work
from . import install
+from . import run
+from . import serve
diff --git a/src/wuttapos/cli/run.py b/src/wuttapos/cli/run.py
new file mode 100644
index 0000000..867be54
--- /dev/null
+++ b/src/wuttapos/cli/run.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+See also: :ref:`wuttapos-run`
+"""
+
+import typer
+
+from wuttapos.cli import wuttapos_typer
+from wuttapos.terminal.app import run_app
+
+
+@wuttapos_typer.command()
+def run(ctx: typer.Context):
+ """
+ Run the WuttaPOS GUI app
+ """
+ config = ctx.parent.wutta_config
+ run_app(config)
diff --git a/src/wuttapos/cli/serve.py b/src/wuttapos/cli/serve.py
new file mode 100644
index 0000000..36d35f3
--- /dev/null
+++ b/src/wuttapos/cli/serve.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+See also: :ref:`wuttapos-serve`
+"""
+
+import logging
+
+import typer
+
+from wuttapos.cli import wuttapos_typer
+from wuttjamaican.util import resource_path
+
+
+log = logging.getLogger(__name__)
+
+
+@wuttapos_typer.command()
+def serve(ctx: typer.Context):
+ """
+ Run the WuttaPOS web service
+ """
+ import flet as ft
+ from wuttapos.terminal.app import main
+
+ config = ctx.parent.wutta_config
+ kw = {}
+
+ host = config.get("wuttapos.serve.host", default="0.0.0.0")
+ kw["host"] = host
+
+ port = config.get_int("wuttapos.serve.port", default=8332)
+ kw["port"] = port
+
+ # TODO: we technically "support" this, in that we do pass the
+ # value on to Flet, but in practice it does not work right
+ path = config.get("wuttapos.serve.path", default="")
+ if path:
+ path = path.strip("/") + "/"
+ kw["name"] = path
+ # kw['route_url_strategy'] = 'hash'
+
+ log.info(f"will serve WuttaPOS on http://{host}:{port}/{path}")
+ ft.app(
+ target=main,
+ view=None,
+ assets_dir=resource_path("wuttapos.terminal:assets"),
+ **kw,
+ )
diff --git a/src/wuttapos/email-templates/pos_feedback.html.mako b/src/wuttapos/email-templates/pos_feedback.html.mako
new file mode 100644
index 0000000..0fe52e4
--- /dev/null
+++ b/src/wuttapos/email-templates/pos_feedback.html.mako
@@ -0,0 +1,31 @@
+## -*- coding: utf-8; -*-
+
+
+
+
+
+ User feedback from POS
+
+
+ ${user_name}
+
+
+ ${referrer}
+
+
+ ${message}
+
+
+
diff --git a/src/wuttapos/email-templates/pos_feedback.txt.mako b/src/wuttapos/email-templates/pos_feedback.txt.mako
new file mode 100644
index 0000000..a6f1c9d
--- /dev/null
+++ b/src/wuttapos/email-templates/pos_feedback.txt.mako
@@ -0,0 +1,15 @@
+## -*- coding: utf-8; -*-
+
+# User feedback from POS
+
+**User Name**
+
+${user_name}
+
+**Referring View**
+
+${referrer}
+
+**Message**
+
+${message}
diff --git a/src/wuttapos/email-templates/uncaught_exception.html.mako b/src/wuttapos/email-templates/uncaught_exception.html.mako
new file mode 100644
index 0000000..34c09d8
--- /dev/null
+++ b/src/wuttapos/email-templates/uncaught_exception.html.mako
@@ -0,0 +1,36 @@
+## -*- coding: utf-8; -*-
+
+
+ Uncaught Exception
+
+
+ The following error was not handled properly. Please investigate and fix ASAP.
+
+
+ Context
+
+ % if extra_context is not Undefined and extra_context:
+
+ % for key, value in extra_context.items():
+ -
+ ${key}:
+ ${value}
+
+ % endfor
+
+ % else:
+ N/A
+ % endif
+
+ Error
+
+
+ ${error}
+
+
+ Traceback
+
+ ${traceback}
+
+
+
diff --git a/src/wuttapos/handler.py b/src/wuttapos/handler.py
new file mode 100644
index 0000000..9f7f01f
--- /dev/null
+++ b/src/wuttapos/handler.py
@@ -0,0 +1,100 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+Transaction Handler
+"""
+
+import decimal
+
+import sqlalchemy as sa
+
+from wuttjamaican.app import GenericHandler
+
+
+class TransactionHandler(GenericHandler):
+ """
+ Base class and default implementation for the :term:`transaction
+ handler`.
+
+ This is responsible for business logic while a transaction is
+ being rang up.
+ """
+
+ def get_terminal_id(self):
+ """
+ Returns the ID string for current POS terminal.
+ """
+ return self.config.get("wuttapos.terminal_id")
+
+ def get_screen_txn_display(self, txn, **kwargs):
+ """
+ Should return the text to be used for displaying transaction
+ identifier within the header of POS screen.
+ """
+ return "-".join([txn["terminal_id"], txn["cashier_id"], txn["transaction_id"]])
+
+ def get_screen_cust_display(self, txn=None, customer=None, **kwargs):
+ """
+ Should return the text to be used for displaying customer
+ identifier / name etc. within the header of POS screen.
+ """
+
+ if not customer and txn:
+ return txn["customer_id"]
+
+ if not customer:
+ return
+
+ # TODO: what about person_number
+ return str(customer.card_number)
+
+ # TODO: should also filter this by terminal?
+ def get_current_transaction(
+ self,
+ user,
+ # terminal_id=None,
+ training_mode=False,
+ # create=True,
+ return_created=False,
+ **kwargs,
+ ):
+ """
+ Get the "current" POS transaction for the given user, creating
+ it as needed.
+
+ :param training_mode: Flag indicating whether the transaction
+ should be in training mode. The lookup will be restricted
+ according to the value of this flag. If a new batch is
+ created, it will be assigned this flag value.
+ """
+ if not user:
+ raise ValueError("must specify a user")
+
+ # TODO
+ created = False
+ lines = []
+ if not lines:
+ # if not create:
+ if return_created:
+ return None, False
+ return None
diff --git a/src/wuttapos/server/views/__init__.py b/src/wuttapos/server/views/__init__.py
index 90e9371..c70be9d 100644
--- a/src/wuttapos/server/views/__init__.py
+++ b/src/wuttapos/server/views/__init__.py
@@ -35,3 +35,27 @@ def includeme(config):
config.include("wuttapos.server.views.products")
config.include("wuttapos.server.views.inventory_adjustments")
config.include("wuttapos.server.views.customers")
+
+ # TODO: these should probably live elsewhere?
+ config.add_wutta_permission_group("pos", "POS", overwrite=False)
+
+ config.add_wutta_permission(
+ "pos", "pos.test_error", "Force error to test error handling"
+ )
+ config.add_wutta_permission(
+ "pos", "pos.ring_sales", "Make transactions (ring sales)"
+ )
+ config.add_wutta_permission(
+ "pos", "pos.override_price", "Override price for any item"
+ )
+ config.add_wutta_permission(
+ "pos", "pos.del_customer", "Remove customer from current transaction"
+ )
+ # config.add_wutta_permission('pos', 'pos.resume',
+ # "Resume previously-suspended transaction")
+ config.add_wutta_permission("pos", "pos.toggle_training", "Start/end training mode")
+ config.add_wutta_permission("pos", "pos.suspend", "Suspend current transaction")
+ config.add_wutta_permission(
+ "pos", "pos.swap_customer", "Swap customer for current transaction"
+ )
+ config.add_wutta_permission("pos", "pos.void_txn", "Void current transaction")
diff --git a/src/wuttapos/terminal/__init__.py b/src/wuttapos/terminal/__init__.py
new file mode 100644
index 0000000..94479f9
--- /dev/null
+++ b/src/wuttapos/terminal/__init__.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+WuttaPOS Terminal
+"""
diff --git a/src/wuttapos/terminal/app.py b/src/wuttapos/terminal/app.py
new file mode 100644
index 0000000..0fafd5c
--- /dev/null
+++ b/src/wuttapos/terminal/app.py
@@ -0,0 +1,292 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# WuttaPOS is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+WuttaPOS app
+"""
+
+import logging
+import socket
+import sys
+import threading
+from collections import OrderedDict
+from traceback import format_exception
+
+from wuttjamaican.conf import make_config
+from wuttjamaican.util import resource_path
+
+import flet as ft
+
+import wuttapos
+from wuttapos.terminal.controls.buttons import make_button
+
+
+log = logging.getLogger(__name__)
+
+
+def main(page: ft.Page):
+ config = make_config()
+ app = config.get_app()
+ model = app.model
+ handler = app.get_transaction_handler()
+
+ # nb. as of python 3.10 the original hook is accessible, we needn't save the ref
+ # cf. https://docs.python.org/3/library/threading.html#threading.__excepthook__
+ orig_thread_hook = threading.excepthook
+
+ hostname = socket.gethostname()
+ email_context = OrderedDict(
+ [
+ ("hostname", hostname),
+ ("ipaddress", socket.gethostbyname(hostname)),
+ ("terminal", handler.get_terminal_id() or "??"),
+ ]
+ )
+
+ def handle_error(exc_type, exc_value, exc_traceback):
+
+ log.exception("unhandled error in POS")
+
+ # nb. ignore this particular error; it is benign
+ if exc_type is RuntimeError and str(exc_value) == "Event loop is closed":
+ log.debug("ignoring error for closed event loop", exc_info=True)
+ return
+
+ extra_context = OrderedDict(email_context)
+ traceback = "".join(
+ format_exception(exc_type, exc_value, exc_traceback)
+ ).strip()
+
+ try:
+ uuid = page.session.get("user_uuid")
+ if uuid:
+ session = app.make_session()
+ user = session.get(model.User, uuid)
+ extra_context["username"] = user.username
+ # TODO
+ # batch = handler.get_current_batch(user, create=False)
+ # if batch:
+ # extra_context['batchid'] = batch.id_str
+ session.close()
+ else:
+ extra_context["username"] = "n/a"
+
+ app.send_email(
+ "uncaught_exception",
+ {
+ "extra_context": extra_context,
+ "error": app.render_error(exc_value),
+ "traceback": traceback,
+ },
+ )
+
+ except:
+ log.exception("failed to send error email")
+
+ try:
+
+ def close_bs(e):
+ bs.open = False
+ bs.update()
+
+ bs = ft.BottomSheet(
+ ft.Container(
+ ft.Column(
+ [
+ ft.Text(
+ "Unexpected Error", size=24, weight=ft.FontWeight.BOLD
+ ),
+ ft.Divider(),
+ ft.Text(
+ "Please be advised, something unexpected has gone wrong.\n"
+ "The state of your transaction may be questionable.\n\n"
+ "If possible you should consult the IT administrator.\n"
+ "(They may have already received an email about this.)",
+ size=20,
+ ),
+ ft.Container(
+ content=make_button(
+ "Dismiss", on_click=close_bs, height=80, width=120
+ ),
+ alignment=ft.alignment.center,
+ expand=1,
+ ),
+ ],
+ ),
+ bgcolor="yellow",
+ padding=20,
+ ),
+ open=True,
+ dismissible=False,
+ )
+
+ page.overlay.append(bs)
+ page.update()
+
+ except:
+ log.exception("failed to show error bottomsheet")
+
+ def sys_exc_hook(exc_type, exc_value, exc_traceback):
+ handle_error(exc_type, exc_value, exc_traceback)
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
+
+ def thread_exc_hook(args):
+ handle_error(args.exc_type, args.exc_value, args.exc_traceback)
+ # nb. as of python 3.10 could just call threading.__excepthook__ instead
+ # cf. https://docs.python.org/3/library/threading.html#threading.__excepthook__
+ orig_thread_hook(args)
+
+ # custom exception hook for main process
+ sys.excepthook = sys_exc_hook
+
+ # custom exception hook for threads (requires python 3.8)
+ # cf. https://docs.python.org/3/library/threading.html#threading.excepthook
+ v = sys.version_info
+ if v.major >= 3 and v.minor >= 8:
+ threading.excepthook = thread_exc_hook
+
+ page.title = f"WuttaPOS v{wuttapos.__version__}"
+ if hasattr(page, "window"):
+ page.window.full_screen = True
+ else:
+ page.window_full_screen = True
+
+ # global defaults for button/text styles etc.
+ page.data = {
+ "default_button_height_pos": 100,
+ "default_button_height_dlg": 80,
+ }
+
+ def clean_exit():
+ # TODO: this doesn't do anything interesting now, but it could
+ if hasattr(page, "window"):
+ page.window.destroy()
+ else:
+ page.window_destroy()
+
+ def keyboard(e):
+ # exit on ctrl+Q
+ if e.ctrl and e.key == "Q":
+ if not e.shift and not e.alt and not e.meta:
+ clean_exit()
+
+ page.on_keyboard_event = keyboard
+
+ def window_event(e):
+ if e.data == "close":
+ clean_exit()
+
+ # cf. https://flet.dev/docs/controls/page/#window_destroy
+ if hasattr(page, "window"):
+ page.window.prevent_close = True
+ page.window.on_event = window_event
+ else:
+ page.window_prevent_close = True
+ page.window_on_event = window_event
+
+ # TODO: probably these should be auto-loaded from spec
+ from wuttapos.terminal.views.pos import POSView
+ from wuttapos.terminal.views.login import LoginView
+
+ # cf .https://flet.dev/docs/guides/python/navigation-and-routing#building-views-on-route-change
+
+ def route_change(e):
+ page.views.clear()
+
+ redirect = None
+ user_uuid = page.session.get("user_uuid")
+ if page.route == "/login" and user_uuid:
+ redirect = "/pos"
+ other = "/pos"
+ elif page.route == "/pos" and not user_uuid:
+ redirect = "/login"
+ other = "/login"
+ else:
+ redirect = "/pos" if user_uuid else "/login"
+
+ if redirect and page.route != redirect:
+ page.go(redirect)
+ return
+
+ if page.route == "/pos":
+ page.views.append(POSView(config, "/pos"))
+
+ elif page.route == "/login":
+ page.views.append(LoginView(config, "/login"))
+
+ if hasattr(page, "window"):
+ page.window.full_screen = True
+ else:
+ page.window_full_screen = True
+
+ page.update()
+
+ # TODO: this was in example docs but not sure what it's for?
+ # def view_pop(view):
+ # page.views.pop()
+ # top_view = page.views[-1]
+ # page.go(top_view.route)
+
+ page.on_route_change = route_change
+ # page.on_view_pop = view_pop
+
+ # TODO: this may be too hacky but is useful for now/dev
+ if not config.production():
+
+ training = page.client_storage.get("training")
+ page.session.set("training", training)
+
+ user = None
+ uuid = page.client_storage.get("user_uuid")
+ if uuid:
+ session = app.make_session()
+ user = session.get(model.User, uuid)
+ if user:
+ page.session.set("user_uuid", user.uuid.hex)
+ page.session.set("user_display", str(user))
+
+ txn = handler.get_current_transaction(user, create=False)
+ if txn:
+ page.session.set("txn_display", handler.get_screen_txn_display(txn))
+ if txn["customer_id"]:
+ page.session.set("cust_uuid", txn["customer_id"])
+ page.session.set(
+ "cust_display", handler.get_screen_cust_display(txn=txn)
+ )
+
+ session.close()
+ page.go("/pos")
+ return
+
+ session.close()
+
+ page.go("/login")
+
+
+# TODO: can we inject config to the main() via ft.app() kwargs somehow?
+# pretty sure the `wuttapos open` command is trying to anyway..
+def run_app(config=None):
+ ft.app(target=main, assets_dir=resource_path("wuttapos.terminal:assets"))
+
+
+if __name__ == "__main__":
+ run_app()
diff --git a/src/wuttapos/terminal/assets/header_logo.png b/src/wuttapos/terminal/assets/header_logo.png
new file mode 100644
index 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()