wuttapos/wuttapos/views/pos.py
Lance Edgar 0767b81d0b Prevent sending empty feedback
technically can still send just a bunch of spaces, but whatever
2023-09-25 22:43:01 -05:00

981 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8; -*-
################################################################################
#
# WuttaPOS -- Pythonic Point of Sale System
# Copyright © 2023 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttaPOS - POS view
"""
import decimal
import logging
import time
import flet as ft
from .base import WuttaView
from wuttapos.controls.custlookup import WuttaCustomerLookup
from wuttapos.util import get_pos_batch_handler
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 get_batch_handler(self):
return get_pos_batch_handler(self.config)
def set_customer(self, customer, batch=None):
session = self.app.get_session(customer)
if not batch:
batch = self.get_current_batch(session)
handler = self.get_batch_handler()
handler.set_customer(batch, customer)
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.page.snack_bar = ft.SnackBar(ft.Text(f"CUSTOMER SET: {customer}",
color='black',
weight=ft.FontWeight.BOLD),
bgcolor='green',
duration=1500)
self.page.snack_bar.open = True
def customer_lookup(self, value=None):
def select_customer(uuid):
session = self.app.make_session()
customer = session.get(self.model.Customer, uuid)
self.set_customer(customer)
session.commit()
session.close()
dlg.open = False
self.main_input.focus()
self.page.update()
def cancel(e):
dlg.open = False
self.main_input.focus()
self.page.update()
dlg = ft.AlertDialog(
modal=True,
title=ft.Text("Customer Lookup"),
content=WuttaCustomerLookup(self.config, initial_search=value,
on_customer=select_customer, on_cancel=cancel),
)
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.page.snack_bar = ft.SnackBar(ft.Text(f"CUSTOMER NOT FOUND: {entry}",
color='black',
weight=ft.FontWeight.BOLD),
bgcolor='yellow',
duration=1500)
self.page.snack_bar.open = True
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()
def close(e):
dlg.open = False
self.main_input.value = ''
self.main_input.focus()
self.page.update()
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.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.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()
# cf. https://github.com/flet-dev/flet/issues/1670
time.sleep(0.1)
entry = self.main_input.value
if entry:
if not self.attempt_set_customer(entry):
self.customer_lookup(entry)
else:
self.customer_lookup()
def cancel(e):
dlg.open = False
self.main_input.value = ''
self.main_input.focus()
self.page.update()
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.dialog = dlg
dlg.open = True
self.page.update()
def remove_customer_prompt(self):
def remove(e):
session = self.app.make_session()
handler = self.get_batch_handler()
batch = self.get_current_batch(session)
handler.set_customer(batch, None)
session.commit()
session.close()
self.page.session.set('cust_uuid', None)
self.page.session.set('cust_display', None)
self.informed_refresh()
dlg.open = False
self.main_input.value = ''
self.main_input.focus()
self.page.snack_bar = ft.SnackBar(ft.Text("CUSTOMER REMOVED",
color='black',
weight=ft.FontWeight.BOLD),
bgcolor='yellow',
duration=1500)
self.page.snack_bar.open = True
self.page.update()
def cancel(e):
dlg.open = False
self.main_input.value = ''
self.main_input.focus()
self.page.update()
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("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.dialog = dlg
dlg.open = True
self.page.update()
def attempt_set_customer(self, entry=None):
session = self.app.make_session()
customer = self.app.get_clientele_handler().locate_customer_for_entry(session, entry)
if customer:
self.set_customer(customer)
self.main_input.value = ''
self.main_input.focus()
self.page.update()
else: # customer not found
self.page.snack_bar = ft.SnackBar(ft.Text(f"CUSTOMER NOT FOUND: {entry}",
color='black',
weight=ft.FontWeight.BOLD),
bgcolor='yellow',
duration=1500)
self.page.snack_bar.open = True
self.main_input.focus()
self.page.update()
session.commit()
session.close()
return bool(customer)
def customer_click(self, e):
# 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 build_controls(self):
self.main_input = ft.TextField(on_submit=self.main_submit,
text_size=24,
text_style=ft.TextStyle(weight=ft.FontWeight.BOLD),
autofocus=True)
self.items = ft.ListView(
height=800,
)
self.txn_total = ft.Text("", size=40)
self.items_column = ft.Column(
controls=[
ft.Container(
content=self.items,
padding=ft.padding.only(10, 0, 10, 0)),
ft.Row([self.txn_total],
alignment=ft.MainAxisAlignment.END),
],
expand=1,
)
def feedback_click(e):
message = ft.TextField(label="Message",
multiline=True, min_lines=5,
autofocus=True)
def send_feedback(e):
if message.value:
self.app.send_email('pos_feedback', data={
'user_name': self.page.session.get('user_display'),
'message': message.value,
})
dlg.open = False
self.page.snack_bar = ft.SnackBar(ft.Text(f"MESSAGE WAS SENT",
color='black',
weight=ft.FontWeight.BOLD),
bgcolor='green',
duration=1500)
self.page.snack_bar.open = True
self.main_input.focus()
else:
message.focus()
self.page.update()
def cancel(e):
dlg.open = False
self.main_input.focus()
self.page.update()
button_height = self.default_button_size * 0.8
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(),
message,
],
expand=True,
),
height=500,
),
actions=[
ft.Row(
[
ft.Container(content=ft.Text("Send Message",
size=self.default_font_size,
color='black',
weight=ft.FontWeight.BOLD),
height=button_height,
width=self.default_button_size * 3,
alignment=ft.alignment.center,
bgcolor='blue',
border=ft.border.all(1, 'black'),
border_radius=ft.border_radius.all(5),
on_click=send_feedback),
ft.Container(content=ft.Text("Cancel",
size=self.default_font_size,
weight=ft.FontWeight.BOLD),
height=button_height,
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),
],
alignment=ft.MainAxisAlignment.CENTER,
),
],
)
self.page.dialog = dlg
dlg.open = True
self.page.update()
def tenkey_click(e):
value = e.control.content.value
if value == 'ENTER':
self.main_submit()
elif value == '': # backspace
if self.main_input.value:
self.main_input.value = self.main_input.value[:-1]
self.main_input.focus()
self.page.update()
elif value == 'CE': # clear entry
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.main_input.focus()
self.page.update()
elif value == '@':
quantity = self.main_input.value
valid = False
if self.set_quantity.data is not None:
quantity = self.set_quantity.data
self.page.snack_bar = ft.SnackBar(ft.Text(f"QUANTITY ALREADY SET: {quantity}",
color='black',
weight=ft.FontWeight.BOLD),
bgcolor='yellow',
duration=1500)
self.page.snack_bar.open = True
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.main_input.value = ""
self.main_input.focus()
else:
self.page.snack_bar = ft.SnackBar(ft.Text(f"INVALID @ QUANTITY: {quantity}",
color='black',
weight=ft.FontWeight.BOLD),
bgcolor='yellow',
duration=1500)
self.page.snack_bar.open = True
self.page.update()
elif value == '':
self.items.scroll_to(delta=-50, duration=250)
self.page.update()
elif value == '':
self.items.scroll_to(delta=50, duration=250)
self.page.update()
else:
self.main_input.value = f"{self.main_input.value or ''}{value}"
self.main_input.focus()
self.page.update()
def up_long_press(e):
self.items.scroll_to(delta=-500, duration=250)
self.page.update()
def down_long_press(e):
self.items.scroll_to(delta=500, duration=250)
self.page.update()
tenkey_button_size = self.default_button_size
tenkey_font_size = self.default_font_size
def tenkey_button(text,
bgcolor='green',
height=tenkey_button_size,
width=tenkey_button_size,
on_click=tenkey_click,
on_long_press=None,
):
return ft.Container(content=ft.Text(text, size=tenkey_font_size,
weight=ft.FontWeight.BOLD),
height=height,
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.tenkey_menu = ft.Container(
content=ft.Column(
[
ft.Row(
[
tenkey_button("1"),
tenkey_button("2"),
tenkey_button("3"),
tenkey_button("@"),
],
spacing=0,
),
ft.Row(
[
tenkey_button("4"),
tenkey_button("5"),
tenkey_button("6"),
tenkey_button("", on_long_press=up_long_press),
],
spacing=0,
),
ft.Row(
[
tenkey_button("7"),
tenkey_button("8"),
tenkey_button("9"),
tenkey_button("", on_long_press=down_long_press),
],
spacing=0,
),
ft.Row(
[
tenkey_button("0"),
# tenkey_button("00"),
tenkey_button("."),
tenkey_button("ENTER", width=tenkey_button_size * 2),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
spacing=0,
),
],
spacing=0,
),
expand=0)
meta_button_height = tenkey_button_size
meta_button_width = meta_button_height * 2
meta_font_size = tenkey_font_size
def meta_button(text, on_click=None, bgcolor='blue'):
return ft.Container(content=ft.Text(text, size=meta_font_size,
weight=ft.FontWeight.BOLD),
height=meta_button_height,
width=meta_button_width,
on_click=on_click,
alignment=ft.alignment.center,
border=ft.border.all(1, 'black'),
border_radius=ft.border_radius.all(5),
bgcolor=bgcolor)
self.meta_menu = ft.Container(
content=ft.Column(
[
ft.Row(
[
meta_button("MGR", bgcolor='blue', on_click=self.not_supported),
meta_button("VOID", bgcolor='red', on_click=self.void_click),
],
spacing=0,
),
ft.Row(
[
meta_button("ITEM", bgcolor='blue', on_click=self.not_supported),
meta_button("CUST", bgcolor='blue', on_click=self.customer_click),
],
spacing=0,
),
ft.Row(
[
meta_button("TODO", bgcolor='blue', on_click=self.not_supported),
meta_button("TODO", bgcolor='blue', on_click=self.not_supported),
],
spacing=0,
),
ft.Row(
[
meta_button("TODO", bgcolor='blue', on_click=self.not_supported),
meta_button("TODO", bgcolor='blue', on_click=self.not_supported),
],
spacing=0,
),
],
spacing=0,
),
expand=0)
context_button_height = tenkey_button_size
context_button_width = context_button_height * 2
context_font_size = tenkey_font_size
def context_button(text, on_click=None):
return ft.Container(content=ft.Text(text, size=context_font_size,
weight=ft.FontWeight.BOLD),
height=context_button_height,
width=context_button_width,
on_click=on_click,
alignment=ft.alignment.center,
border=ft.border.all(1, 'black'),
border_radius=ft.border_radius.all(5),
bgcolor='orange')
self.context_menu = ft.Container(
content=ft.Column(
[
ft.Row(
[
context_button("CASH", on_click=self.tender_click),
context_button("CHECK", on_click=self.tender_click),
],
spacing=0,
),
],
spacing=0,
),
expand=0)
self.set_quantity = ft.Text(value=None, data=None, weight=ft.FontWeight.BOLD, size=40)
return [
self.build_header(),
ft.Row(
[
tenkey_button("Feedback", height=70, width=200,
bgcolor='blue', on_click=feedback_click),
ft.Row(
[
self.set_quantity,
self.main_input,
tenkey_button("", height=70, width=70),
tenkey_button("CE", height=70, width=70),
],
alignment=ft.MainAxisAlignment.CENTER,
expand=True,
),
],
),
ft.Row(),
ft.Row(),
ft.Row(),
ft.Row(
[
self.items_column,
ft.Column(
[
ft.Row(
[
self.tenkey_menu,
self.meta_menu,
],
),
self.context_menu,
],
),
],
vertical_alignment=ft.CrossAxisAlignment.START,
),
]
def make_text(self, *args, **kwargs):
kwargs.setdefault('weight', ft.FontWeight.BOLD)
kwargs.setdefault('size', 24)
return ft.Text(*args, **kwargs)
def get_current_batch(self, session, user=None, create=True):
if not user:
user = session.get(self.model.User, self.page.session.get('user_uuid'))
handler = self.get_batch_handler()
return handler.get_current_batch(user, create=create)
def did_mount(self):
session = self.app.make_session()
batch = self.get_current_batch(session, create=False)
if batch:
handler = self.get_batch_handler()
self.page.session.set('txn_display', handler.get_screen_txn_display(batch))
self.page.session.set('cust_uuid', batch.customer_uuid)
self.page.session.set('cust_display', handler.get_screen_cust_display(batch=batch))
self.items.controls.clear()
for row in batch.active_rows():
self.add_row_item(row)
self.items.scroll_to(offset=-1, duration=100)
self.txn_total.value = self.app.render_currency(batch.sales_total)
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()
session.commit()
session.close()
self.page.update()
def not_supported(self, e=None, feature=None):
text = "NOT YET SUPPORTED"
if not feature and e:
feature = e.control.content.value
if feature:
text += f": {feature}"
self.page.snack_bar = ft.SnackBar(ft.Text(text, color='black',
weight=ft.FontWeight.BOLD),
bgcolor='yellow',
duration=1500)
self.page.snack_bar.open = True
self.page.update()
def add_row_item(self, row):
quantity = self.app.render_quantity(row.quantity)
pretty_price = self.app.render_currency(row.txn_price)
self.items.controls.append(
ft.Container(
content=ft.Row(
[
ft.Row([
self.make_text(f"{row.description}"),
self.make_text(f"× {quantity} @ {pretty_price}",
weight=None, italic=True, size=20),
]),
self.make_text(self.app.render_currency(row.sales_total)),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
border=ft.border.only(bottom=ft.border.BorderSide(1, 'gray')),
padding=ft.padding.only(0, 5, 0, 5),
))
def void_click(self, e):
def confirm(e):
dlg.open = False
model = self.model
session = self.app.make_session()
handler = self.get_batch_handler()
user = session.get(model.User, self.page.session.get('user_uuid'))
batch = handler.get_current_batch(user, create=True)
# void current batch
handler.void_batch(batch, user)
session.commit()
session.close()
self.clear_all()
self.main_input.value = ''
self.main_input.focus()
self.page.update()
def cancel(e):
dlg.open = False
self.main_input.focus()
self.page.update()
session = self.app.make_session()
batch = self.get_current_batch(session, create=False)
session.close()
if batch:
dlg = ft.AlertDialog(
title=ft.Text("Confirm VOID"),
content=ft.Text("Really VOID this transaction?"),
actions=[
ft.Container(content=ft.Text("Yes, VOID",
size=self.default_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='red',
border=ft.border.all(1, 'black'),
border_radius=ft.border_radius.all(5),
on_click=confirm),
ft.Container(content=ft.Text("Cancel",
size=self.default_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),
])
else: # nothing to void
dlg = ft.AlertDialog(
title=ft.Text("No Transaction"),
content=ft.Text("You do not have a transaction open currently.", size=20),
actions=[
ft.Container(content=ft.Text("Close",
size=self.default_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.dialog = dlg
dlg.open = True
self.page.update()
def tender_click(self, e):
model = self.model
session = self.app.make_session()
handler = self.get_batch_handler()
user = session.get(model.User, self.page.session.get('user_uuid'))
batch = handler.get_current_batch(user)
# tender / execute batch
tender = e.control.content.value
handler.tender_and_execute(batch, user, tender)
session.commit()
session.close()
self.clear_all()
self.main_input.focus()
self.page.update()
def clear_all(self):
self.items.controls.clear()
self.txn_total.value = 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):
value = self.main_input.value
if not value:
self.main_input.focus()
self.items.scroll_to(offset=-1, duration=250)
self.page.update()
return
handler = self.get_batch_handler()
session = self.app.make_session()
model = self.model
user = session.get(model.User, self.page.session.get('user_uuid'))
batch, created = handler.get_current_batch(user, return_created=True)
if created:
self.page.session.set('txn_display', handler.get_screen_txn_display(batch))
self.informed_refresh()
kwargs = {}
if self.set_quantity.data is not None:
kwargs['quantity'] = self.set_quantity.data
row = handler.process_entry(batch, value, **kwargs)
if row:
self.add_row_item(row)
self.items.scroll_to(offset=-1, duration=250)
self.txn_total.value = self.app.render_currency(batch.sales_total)
else:
self.page.snack_bar = ft.SnackBar(ft.Text(f"UNRECOGNIZED: {value}",
color='black',
weight=ft.FontWeight.BOLD),
bgcolor='yellow',
duration=1500)
self.page.snack_bar.open = True
session.commit()
session.close()
self.main_input.value = ""
self.main_input.focus()
self.set_quantity.data = None
self.set_quantity.value = None
self.page.update()