From 5e69092415047fb27c180416c5181e3d9b45ff61 Mon Sep 17 00:00:00 2001 From: Carlos Date: Fri, 31 May 2024 12:06:24 +0200 Subject: [PATCH 1/5] update customer data app --- .../alembic/versions/277cad49d2b0_.py | 34 ++ .../alembic/versions/7aaec6b87d88_.py | 36 ++ .../customer_data_app/backend/__init__.py | 0 .../customer_data_app/backend/backend.py | 202 +++++++++ .../customer_data_app/components/__init__.py | 0 .../components/form_field.py | 26 ++ .../components/stats_cards.py | 110 +++++ .../components/status_badges.py | 14 + .../customer_data_app/customer_data_app.py | 387 +----------------- .../customer_data_app/views/__init__.py | 0 .../customer_data_app/views/main.py | 366 +++++++++++++++++ .../customer_data_app/views/navbar.py | 28 ++ customer_data_app/requirements.txt | 2 +- 13 files changed, 830 insertions(+), 375 deletions(-) create mode 100644 customer_data_app/alembic/versions/277cad49d2b0_.py create mode 100644 customer_data_app/alembic/versions/7aaec6b87d88_.py create mode 100644 customer_data_app/customer_data_app/backend/__init__.py create mode 100644 customer_data_app/customer_data_app/backend/backend.py create mode 100644 customer_data_app/customer_data_app/components/__init__.py create mode 100644 customer_data_app/customer_data_app/components/form_field.py create mode 100644 customer_data_app/customer_data_app/components/stats_cards.py create mode 100644 customer_data_app/customer_data_app/components/status_badges.py create mode 100644 customer_data_app/customer_data_app/views/__init__.py create mode 100644 customer_data_app/customer_data_app/views/main.py create mode 100644 customer_data_app/customer_data_app/views/navbar.py diff --git a/customer_data_app/alembic/versions/277cad49d2b0_.py b/customer_data_app/alembic/versions/277cad49d2b0_.py new file mode 100644 index 00000000..c691ab98 --- /dev/null +++ b/customer_data_app/alembic/versions/277cad49d2b0_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 277cad49d2b0 +Revises: 7aaec6b87d88 +Create Date: 2024-05-30 10:58:18.235598 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = '277cad49d2b0' +down_revision: Union[str, None] = '7aaec6b87d88' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('customer', schema=None) as batch_op: + batch_op.add_column(sa.Column('date', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('customer', schema=None) as batch_op: + batch_op.drop_column('date') + + # ### end Alembic commands ### diff --git a/customer_data_app/alembic/versions/7aaec6b87d88_.py b/customer_data_app/alembic/versions/7aaec6b87d88_.py new file mode 100644 index 00000000..5e92c928 --- /dev/null +++ b/customer_data_app/alembic/versions/7aaec6b87d88_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 7aaec6b87d88 +Revises: e565fdc23e6c +Create Date: 2024-05-30 09:50:05.149862 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = '7aaec6b87d88' +down_revision: Union[str, None] = 'e565fdc23e6c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('customer', schema=None) as batch_op: + batch_op.add_column(sa.Column('payments', sa.Float(), nullable=False)) + batch_op.add_column(sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('customer', schema=None) as batch_op: + batch_op.drop_column('status') + batch_op.drop_column('payments') + + # ### end Alembic commands ### diff --git a/customer_data_app/customer_data_app/backend/__init__.py b/customer_data_app/customer_data_app/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/customer_data_app/customer_data_app/backend/backend.py b/customer_data_app/customer_data_app/backend/backend.py new file mode 100644 index 00000000..89b2f2ca --- /dev/null +++ b/customer_data_app/customer_data_app/backend/backend.py @@ -0,0 +1,202 @@ +import reflex as rx +from typing import Literal, Union +from sqlmodel import select +from datetime import datetime, timedelta + +LiteralStatus = Literal["Delivered", "Pending", "Cancelled"] + + +def _get_percentage_change(value: Union[int, float], prev_value: Union[int, float]) -> float: + percentage_change = ( + round(((value - prev_value) / prev_value) * 100, 2) + if prev_value != 0 + else 0 + if value == 0 + else + float("inf") + ) + return percentage_change + +class Customer(rx.Model, table=True): + """The customer model.""" + + name: str + email: str + phone: str + address: str + date: str + payments: float + status: str + + +class MonthValues(rx.Base): + """Values for a month.""" + + num_customers: int = 0 + total_payments: float = 0.0 + num_delivers: int = 0 + + + +class State(rx.State): + """The app state.""" + + id: int + name: str = "" + email: str = "" + phone: str = "" + address: str = "" + date: str = "" # In 'YYYY-MM-DD HH:MM:SS' format + payments: float = 0.0 + status: LiteralStatus = "Pending" + users: list[Customer] = [] + sort_value: str = "" + sort_reverse: bool = False + # Values for current and previous month + current_month_values: MonthValues = MonthValues() + previous_month_values: MonthValues = MonthValues() + + def load_entries(self) -> list[Customer]: + """Get all users from the database.""" + with rx.session() as session: + self.users = session.exec(select(Customer)).all() + if self.sort_value: + if self.sort_value == "payments": + self.users = sorted( + self.users, key=lambda user: user.payments, reverse=self.sort_reverse + ) + else: + self.users = sorted( + self.users, key=lambda user: str(getattr( + user, self.sort_value)).lower(), reverse=self.sort_reverse + ) + self.get_current_month_values() + self.get_previous_month_values() + + def get_current_month_values(self): + """Calculate current month's values.""" + now = datetime.now() + start_of_month = datetime(now.year, now.month, 1) + + current_month_users = [ + user for user in self.users if datetime.strptime(user.date, '%Y-%m-%d %H:%M:%S') >= start_of_month + ] + num_customers = len(current_month_users) + total_payments = sum(user.payments for user in current_month_users) + num_delivers = len([user for user in current_month_users if user.status == "Delivered"]) + self.current_month_values = MonthValues(num_customers=num_customers, total_payments=total_payments, num_delivers=num_delivers) + + def get_previous_month_values(self): + """Calculate previous month's values.""" + now = datetime.now() + first_day_of_current_month = datetime(now.year, now.month, 1) + last_day_of_last_month = first_day_of_current_month - timedelta(days=1) + start_of_last_month = datetime(last_day_of_last_month.year, last_day_of_last_month.month, 1) + + previous_month_users = [ + user for user in self.users + if start_of_last_month <= datetime.strptime(user.date, '%Y-%m-%d %H:%M:%S') <= last_day_of_last_month + ] + # We add some dummy values to simulate growth/decline. Remove them in production. + num_customers = len(previous_month_users) + 3 + total_payments = sum(user.payments for user in previous_month_users) + 240 + num_delivers = len([user for user in previous_month_users if user.status == "Delivered"]) + 5 + + self.previous_month_values = MonthValues(num_customers=num_customers, total_payments=total_payments, num_delivers=num_delivers) + + def sort_values(self, sort_value: str): + self.sort_value = sort_value + self.load_entries() + + def toggle_sort(self): + self.sort_reverse = not self.sort_reverse + self.load_entries() + + def set_user_vars(self, user: Customer): + self.id = user["id"] + self.name = user["name"] + self.email = user["email"] + self.phone = user["phone"] + self.address = user["address"] + self.payments = user["payments"] + self.status = user["status"] + self.date = user["date"] + + def add_customer(self, form_data: dict): + self.name = form_data.get("name") + self.email = form_data.get("email") + self.phone = form_data.get("phone") + self.address = form_data.get("address") + self.payments = form_data.get("payments") + self.status = form_data.get("status", "Pending") + self.date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + """Add a customer to the database.""" + with rx.session() as session: + if session.exec( + select(Customer).where(Customer.email == self.email) + ).first(): + return rx._x.toast.info("User already exists", variant="outline", position="bottom-right") + session.add( + Customer( + name=self.name, + email=self.email, + phone=self.phone, + address=self.address, + payments=self.payments, + status=self.status, + date=self.date, + ) + ) + session.commit() + self.load_entries() + return rx._x.toast.info(f"User {self.name} has been added.", variant="outline", position="bottom-right") + + def update_customer(self, form_data: dict): + self.name = form_data.get("name") + self.email = form_data.get("email") + self.phone = form_data.get("phone") + self.address = form_data.get("address") + self.payments = form_data.get("payments") + self.status = form_data.get("status", "Pending") + + """Update a customer in the database.""" + with rx.session() as session: + customer = session.exec( + select(Customer).where(Customer.id == self.id) + ).first() + customer.name = self.name + customer.email = self.email + customer.phone = self.phone + customer.address = self.address + customer.payments = self.payments + customer.status = self.status + session.add(customer) + session.commit() + self.load_entries() + return rx._x.toast.info(f"User {self.name} has been modified.", variant="outline", position="bottom-right") + + def delete_customer(self, email: str): + """Delete a customer from the database.""" + with rx.session() as session: + customer = session.exec( + select(Customer).where(Customer.email == email) + ).first() + session.delete(customer) + session.commit() + self.load_entries() + return rx._x.toast.info(f"User {email} has been deleted.", variant="outline", position="bottom-right") + + def on_load(self): + self.load_entries() + + @rx.var + def payments_change(self) -> float: + return _get_percentage_change(self.current_month_values.total_payments, self.previous_month_values.total_payments) + + @rx.var + def customers_change(self) -> float: + return _get_percentage_change(self.current_month_values.num_customers, self.previous_month_values.num_customers) + + @rx.var + def delivers_change(self) -> float: + return _get_percentage_change(self.current_month_values.num_delivers, self.previous_month_values.num_delivers) \ No newline at end of file diff --git a/customer_data_app/customer_data_app/components/__init__.py b/customer_data_app/customer_data_app/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/customer_data_app/customer_data_app/components/form_field.py b/customer_data_app/customer_data_app/components/form_field.py new file mode 100644 index 00000000..df16408b --- /dev/null +++ b/customer_data_app/customer_data_app/components/form_field.py @@ -0,0 +1,26 @@ +import reflex as rx + + +def form_field( + label: str, placeholder: str, type: str, name: str, icon: str, default_value: str = "" +) -> rx.Component: + return rx.form.field( + rx.flex( + rx.hstack( + rx.icon(icon, size=16, stroke_width=1.5), + rx.form.label(label), + align="center", + spacing="2", + ), + rx.form.control( + rx.input( + placeholder=placeholder, type=type, default_value=default_value + ), + as_child=True, + ), + direction="column", + spacing="1", + ), + name=name, + width="100%", + ) \ No newline at end of file diff --git a/customer_data_app/customer_data_app/components/stats_cards.py b/customer_data_app/customer_data_app/components/stats_cards.py new file mode 100644 index 00000000..ced5f7e9 --- /dev/null +++ b/customer_data_app/customer_data_app/components/stats_cards.py @@ -0,0 +1,110 @@ +import reflex as rx +from reflex.components.radix.themes.base import ( + LiteralAccentColor, +) + +from ..backend.backend import State + + +def _arrow_badge(arrow_icon: str, percentage_change: float, arrow_color: str): + return rx.badge( + rx.icon( + tag=arrow_icon, + color=rx.color(arrow_color, 9), + ), + rx.text( + f"{percentage_change}%", + size="2", + color=rx.color(arrow_color, 9), + weight="medium", + ), + color_scheme=arrow_color, + radius="large", + align_items="center", + ) + +def stats_card(stat_name: str, + value: int, + prev_value: int, + percentage_change: float, + icon: str, + icon_color: LiteralAccentColor, + extra_char: str = "") -> rx.Component: + return rx.card( + rx.hstack( + rx.vstack( + rx.hstack( + rx.hstack( + rx.icon( + tag=icon, + size=22, + color=rx.color(icon_color, 11), + ), + rx.text( + stat_name, + size="4", + weight="medium", + color=rx.color("gray", 11), + ), + spacing="2", + align="center", + ), + rx.cond( + value > prev_value, + _arrow_badge("trending-up", percentage_change, "grass"), + _arrow_badge("trending-down", percentage_change, "tomato"), + ), + justify="between", + width="100%", + ), + rx.hstack( + rx.heading( + f"{extra_char}{value:,}", + size="7", + weight="bold", + ), + rx.text( + f"from {extra_char}{prev_value:,}", + size="3", + color=rx.color("gray", 10), + ), + spacing="2", + align_items="end", + ), + align_items="start", + justify="between", + width="100%", + ), + align_items="start", + width="100%", + justify="between", + ), + size="3", + width="100%", + max_width="22rem", + ) + + +def stats_cards_group() -> rx.Component: + return rx.flex( + stats_card("Total Customers", + State.current_month_values.num_customers, + State.previous_month_values.num_customers, + State.customers_change, + "users", "blue"), + stats_card("Total Payments", + State.current_month_values.total_payments, + State.previous_month_values.total_payments, + State.payments_change, + "dollar-sign", "orange", + "$"), + stats_card("Total Delivers", + State.current_month_values.num_delivers, + State.previous_month_values.num_delivers, + State.delivers_change, + "truck", "ruby"), + spacing="5", + width="100%", + wrap="wrap", + display=["none", "none", "flex"], + ) diff --git a/customer_data_app/customer_data_app/components/status_badges.py b/customer_data_app/customer_data_app/components/status_badges.py new file mode 100644 index 00000000..20161830 --- /dev/null +++ b/customer_data_app/customer_data_app/components/status_badges.py @@ -0,0 +1,14 @@ +import reflex as rx + + +def _badge(icon: str, text: str, color_scheme: str): + return rx.badge(rx.icon(icon, size=16), text, color_scheme=color_scheme, radius="full", variant="soft", size="3") + +def status_badge(status: str): + badge_mapping = { + "Delivered": ("check", "Delivered", "green"), + "Pending": ("loader", "Pending", "yellow"), + "Cancelled": ("ban", "Cancelled", "red") + } + return _badge(*badge_mapping.get(status, ("loader", "Pending", "yellow"))) + diff --git a/customer_data_app/customer_data_app/customer_data_app.py b/customer_data_app/customer_data_app/customer_data_app.py index b82eb46c..43c03818 100644 --- a/customer_data_app/customer_data_app/customer_data_app.py +++ b/customer_data_app/customer_data_app/customer_data_app.py @@ -1,394 +1,33 @@ """Welcome to Reflex! This file outlines the steps to create a basic app.""" -from sqlmodel import select - import reflex as rx - - -class Customer(rx.Model, table=True): - """The customer model.""" - - name: str - email: str - phone: str - address: str - - -class State(rx.State): - """The app state.""" - - id: int - name: str = "" - email: str = "" - phone: str = "" - address: str = "" - users: list[Customer] = [] - sort_value: str = "" - num_customers: int - - def load_entries(self) -> list[Customer]: - """Get all users from the database.""" - with rx.session() as session: - self.users = session.exec(select(Customer)).all() - self.num_customers = len(self.users) - - if self.sort_value: - self.users = sorted( - self.users, key=lambda user: getattr(user, self.sort_value).lower() - ) - - def sort_values(self, sort_value: str): - self.sort_value = sort_value - self.load_entries() - - def set_user_vars(self, user: Customer): - print(user) - self.id = user["id"] - self.name = user["name"] - self.email = user["email"] - self.phone = user["phone"] - self.address = user["address"] - - def add_customer(self): - """Add a customer to the database.""" - with rx.session() as session: - if session.exec( - select(Customer).where(Customer.email == self.email) - ).first(): - return rx.window_alert("User already exists") - session.add( - Customer( - name=self.name, - email=self.email, - phone=self.phone, - address=self.address, - ) - ) - session.commit() - self.load_entries() - return rx.window_alert(f"User {self.name} has been added.") - - def update_customer(self): - """Update a customer in the database.""" - with rx.session() as session: - customer = session.exec( - select(Customer).where(Customer.id == self.id) - ).first() - customer.name = self.name - customer.email = self.email - customer.phone = self.phone - customer.address = self.address - print(customer) - session.add(customer) - session.commit() - self.load_entries() - - def delete_customer(self, email: str): - """Delete a customer from the database.""" - with rx.session() as session: - customer = session.exec( - select(Customer).where(Customer.email == email) - ).first() - session.delete(customer) - session.commit() - self.load_entries() - - def on_load(self): - self.load_entries() - - -def show_customer(user: Customer): - """Show a customer in a table row.""" - return rx.table.row( - rx.table.cell(rx.avatar(fallback="DA")), - rx.table.cell(user.name), - rx.table.cell(user.email), - rx.table.cell(user.phone), - rx.table.cell(user.address), - rx.table.cell( - update_customer(user), - ), - rx.table.cell( - rx.button( - "Delete", - on_click=lambda: State.delete_customer(user.email), - bg="red", - color="white", - ), - ), - ) - - -def add_customer(): - return rx.dialog.root( - rx.dialog.trigger( - rx.button( - rx.flex( - "Add New Customer", - rx.icon(tag="plus", width=24, height=24), - spacing="3", - ), - size="4", - radius="full", - ), - ), - rx.dialog.content( - rx.dialog.title( - "Customer Details", - font_family="Inter", - ), - rx.dialog.description( - "Add your customer profile details.", - size="2", - mb="4", - padding_bottom="1em", - ), - rx.flex( - rx.text( - "Name", - as_="div", - size="2", - mb="1", - weight="bold", - ), - rx.input(placeholder="Customer Name", on_blur=State.set_name), - rx.text( - "Email", - as_="div", - size="2", - mb="1", - weight="bold", - ), - rx.input(placeholder="Customer Email", on_blur=State.set_email), - rx.text( - "Customer Phone", - as_="div", - size="2", - mb="1", - weight="bold", - ), - rx.input(placeholder="Input Phone", on_blur=State.set_phone), - rx.text( - "Customer Address", - as_="div", - size="2", - mb="1", - weight="bold", - ), - rx.input(placeholder="Input Address", on_blur=State.set_address), - direction="column", - spacing="3", - ), - rx.flex( - rx.dialog.close( - rx.button( - "Cancel", - variant="soft", - color_scheme="gray", - ), - ), - rx.dialog.close( - rx.button( - "Submit Customer", - on_click=State.add_customer, - variant="solid", - ), - ), - padding_top="1em", - spacing="3", - mt="4", - justify="end", - ), - style={"max_width": 450}, - box_shadow="lg", - padding="1em", - border_radius="25px", - font_family="Inter", - ), - ) - - -def update_customer(user): - return rx.dialog.root( - rx.dialog.trigger( - rx.button( - rx.icon("square_pen", width=24, height=24), - bg="red", - color="white", - on_click=lambda: State.set_user_vars(user), - ), - ), - rx.dialog.content( - rx.dialog.title("Customer Details"), - rx.dialog.description( - "Update your customer profile details.", - size="2", - mb="4", - padding_bottom="1em", - ), - rx.flex( - rx.text( - "Name", - as_="div", - size="2", - mb="1", - weight="bold", - ), - rx.input( - placeholder=user.name, - default_value=user.name, - on_blur=State.set_name, - ), - rx.text( - "Email", - as_="div", - size="2", - mb="1", - weight="bold", - ), - rx.input( - placeholder=user.email, - default_value=user.email, - on_blur=State.set_email, - ), - rx.text( - "Customer Phone", - as_="div", - size="2", - mb="1", - weight="bold", - ), - rx.input( - placeholder=user.phone, - default_value=user.phone, - on_blur=State.set_phone, - ), - rx.text( - "Customer Address", - as_="div", - size="2", - mb="1", - weight="bold", - ), - rx.input( - placeholder=user.address, - default_value=user.address, - on_blur=State.set_address, - ), - direction="column", - spacing="3", - ), - rx.flex( - rx.dialog.close( - rx.button( - "Cancel", - variant="soft", - color_scheme="gray", - ), - ), - rx.dialog.close( - rx.button( - "Submit Customer", - on_click=State.update_customer, - variant="solid", - ), - ), - padding_top="1em", - spacing="3", - mt="4", - justify="end", - ), - style={"max_width": 450}, - box_shadow="lg", - padding="1em", - border_radius="25px", - ), - ) - - -def navbar(): - return rx.hstack( - rx.vstack( - rx.heading("Customer Data App", size="7", font_family="Inter"), - ), - rx.spacer(), - add_customer(), - rx.avatar(fallback="TG", size="4"), - rx.color_mode.button(rx.color_mode.icon(), size="3", float="right"), - position="fixed", - width="100%", - top="0px", - z_index="1000", - padding_x="4em", - padding_top="2em", - padding_bottom="1em", - backdrop_filter="blur(10px)", - ) - - -def content(): - return rx.fragment( - rx.vstack( - rx.divider(), - rx.hstack( - rx.heading( - f"Total: {State.num_customers} Customers", - size="5", - font_family="Inter", - ), - rx.spacer(), - rx.select( - ["name", "email", "phone", "address"], - placeholder="Sort By: Name", - size="3", - on_change=lambda sort_value: State.sort_values(sort_value), - font_family="Inter", - ), - width="100%", - padding_x="2em", - padding_top="2em", - padding_bottom="1em", - ), - rx.table.root( - rx.table.header( - rx.table.row( - rx.table.column_header_cell("Icon"), - rx.table.column_header_cell("Name"), - rx.table.column_header_cell("Email"), - rx.table.column_header_cell("Phone"), - rx.table.column_header_cell("Address"), - rx.table.column_header_cell("Edit"), - rx.table.column_header_cell("Delete"), - ), - ), - rx.table.body(rx.foreach(State.users, show_customer)), - # variant="surface", - size="3", - width="100%", - ), - ), - ) +from .backend.backend import State +from .components.stats_cards import stats_cards_group +from .views.navbar import navbar +from .views.main import main_table def index() -> rx.Component: - return rx.box( + return rx.vstack( navbar(), + stats_cards_group(), rx.box( - content(), - margin_top="calc(50px + 2em)", - padding="4em", + main_table(), + width="100%", ), - font_family="Inter", + width="100%", + spacing="6", + padding_x=["1.5em", "1.5em", "3em"], ) # Create app instance and add index page. app = rx.App( theme=rx.theme( - appearance="light", has_background=True, radius="large", accent_color="grass" + appearance="dark", has_background=True, radius="large", accent_color="grass" ), - stylesheets=["https://fonts.googleapis.com/css?family=Inter"], ) + app.add_page( index, on_load=State.on_load, diff --git a/customer_data_app/customer_data_app/views/__init__.py b/customer_data_app/customer_data_app/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/customer_data_app/customer_data_app/views/main.py b/customer_data_app/customer_data_app/views/main.py new file mode 100644 index 00000000..2543239d --- /dev/null +++ b/customer_data_app/customer_data_app/views/main.py @@ -0,0 +1,366 @@ +import reflex as rx +from ..backend.backend import State, Customer +from ..components.form_field import form_field +from ..components.status_badges import status_badge + +def show_customer(user: Customer): + """Show a customer in a table row.""" + + return rx.table.row( + rx.table.cell(user.name), + rx.table.cell(user.email), + rx.table.cell(user.phone), + rx.table.cell(user.address), + rx.table.cell(f"${user.payments:,}"), + rx.table.cell(user.date), + rx.table.cell(rx.match( + user.status, + ("Delivered", status_badge("Delivered")), + ("Pending", status_badge("Pending")), + ("Cancelled", status_badge("Cancelled")), + status_badge("Pending") + )), + rx.table.cell( + rx.hstack( + update_customer_dialog(user), + rx.icon_button( + rx.icon("trash-2", size=22), + on_click=lambda: State.delete_customer(user.email), + size="2", + variant="solid", + color_scheme="red", + ), + ) + ), + style={"_hover": {"bg": rx.color("gray", 3)}}, + align="center", + ) + + +def add_customer_button() -> rx.Component: + return rx.dialog.root( + rx.dialog.trigger( + rx.button( + rx.icon("plus", size=26), + rx.text("Add Customer", size="4", display=[ + "none", "none", "block"]), + size="3", + ), + ), + rx.dialog.content( + rx.hstack( + rx.badge( + rx.icon(tag="users", size=34), + color_scheme="grass", + radius="full", + padding="0.65rem", + ), + rx.vstack( + rx.dialog.title( + "Add New Customer", + weight="bold", + margin="0", + ), + rx.dialog.description( + "Fill the form with the customer's info", + ), + spacing="1", + height="100%", + align_items="start", + ), + height="100%", + spacing="4", + margin_bottom="1.5em", + align_items="center", + width="100%", + ), + rx.flex( + rx.form.root( + rx.flex( + # Name + form_field( + "Name", + "Customer Name", + "text", + "name", + "user", + ), + # Email + form_field( + "Email", "user@reflex.dev", "email", "email", "mail" + ), + # Phone + form_field( + "Phone", + "Customer Phone", + "tel", + "phone", + "phone" + ), + # Address + form_field( + "Address", + "Customer Address", + "text", + "address", + "home" + ), + # Payments + form_field( + "Payment ($)", + "Customer Payment", + "number", + "payments", + "dollar-sign" + ), + # Status + rx.vstack( + rx.hstack( + rx.icon("truck", size=16, stroke_width=1.5), + rx.text("Status"), + align="center", + spacing="2", + ), + rx.radio( + ["Delivered", "Pending", "Cancelled"], + name="status", + direction="row", + as_child=True, + required=True, + ), + ), + direction="column", + spacing="3", + ), + rx.flex( + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ), + ), + rx.form.submit( + rx.dialog.close( + rx.button("Submit Customer"), + ), + as_child=True, + ), + padding_top="2em", + spacing="3", + mt="4", + justify="end", + ), + on_submit=State.add_customer, + reset_on_submit=False, + ), + width="100%", + direction="column", + spacing="4", + ), + style={"max_width": 450}, + box_shadow="lg", + padding="1.5em", + border=f"2px solid {rx.color('accent', 7)}", + border_radius="25px", + ), + ) + + +def update_customer_dialog(user): + return rx.dialog.root( + rx.dialog.trigger( + rx.button( + rx.icon("square-pen", size=22), + rx.text("Edit", size="3"), + color_scheme="blue", + size="2", + variant="solid", + on_click=lambda: State.set_user_vars(user), + ), + ), + rx.dialog.content( + rx.hstack( + rx.badge( + rx.icon(tag="square-pen", size=34), + color_scheme="green", + radius="full", + padding="0.65rem", + ), + rx.vstack( + rx.dialog.title( + "Edit Customer", + weight="bold", + margin="0", + ), + rx.dialog.description( + "Edit the customer's info", + ), + spacing="1", + height="100%", + align_items="start", + ), + height="100%", + spacing="4", + margin_bottom="1.5em", + align_items="center", + width="100%", + ), + rx.flex( + rx.form.root( + rx.flex( + # Name + form_field( + "Name", + "Customer Name", + "text", + "name", + "user", + user.name, + ), + # Email + form_field( + "Email", + "user@reflex.dev", + "email", + "email", + "mail", + user.email + ), + # Phone + form_field( + "Phone", + "Customer Phone", + "tel", + "phone", + "phone", + user.phone + ), + # Address + form_field( + "Address", + "Customer Address", + "text", + "address", + "home", + user.address + ), + # Payments + form_field( + "Payment ($)", + "Customer Payment", + "number", + "payments", + "dollar-sign", + user.payments.to(str) + ), + # Status + rx.vstack( + rx.hstack( + rx.icon("truck", size=16, stroke_width=1.5), + rx.text("Status"), + align="center", + spacing="2", + ), + rx.radio( + ["Delivered", "Pending", "Cancelled"], + default_value=user.status, + name="status", + direction="row", + as_child=True, + required=True, + ), + ), + direction="column", + spacing="3", + ), + rx.flex( + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ), + ), + rx.form.submit( + rx.dialog.close( + rx.button("Update Customer"), + ), + as_child=True, + ), + padding_top="2em", + spacing="3", + mt="4", + justify="end", + ), + on_submit=State.update_customer, + reset_on_submit=False, + ), + width="100%", + direction="column", + spacing="4", + ), + style={"max_width": 450}, + box_shadow="lg", + padding="1.5em", + border=f"2px solid {rx.color('accent', 7)}", + border_radius="25px", + ), + ) + + +def _header_cell(text: str, icon: str): + return rx.table.column_header_cell( + rx.hstack( + rx.icon(icon, size=18), + rx.text(text), + align="center", + spacing="2", + ), + ) + + +def main_table(): + return rx.fragment( + rx.flex( + add_customer_button(), + rx.spacer(), + rx.hstack( + rx.cond( + State.sort_reverse, + rx.icon("arrow-down-z-a", size=28, stroke_width=1.5, cursor="pointer", on_click=State.toggle_sort), + rx.icon("arrow-down-a-z", size=28, stroke_width=1.5, cursor="pointer", on_click=State.toggle_sort), + ), + rx.select( + ["name", "email", "phone", "address", "payments", "date", "status"], + placeholder="Sort By: Name", + size="3", + on_change=lambda sort_value: State.sort_values(sort_value), + ), + spacing="3", + align="center", + ), + spacing="3", + wrap="wrap", + width="100%", + padding_bottom="1em", + ), + rx.table.root( + rx.table.header( + rx.table.row( + _header_cell("Name", "user"), + _header_cell("Email", "mail"), + _header_cell("Phone", "phone"), + _header_cell("Address", "home"), + _header_cell("Payments", "dollar-sign"), + _header_cell("Date", "calendar"), + _header_cell("Status", "truck"), + _header_cell("Actions", "cog"), + ), + ), + rx.table.body(rx.foreach(State.users, show_customer)), + variant="surface", + size="3", + width="100%", + ), + ) diff --git a/customer_data_app/customer_data_app/views/navbar.py b/customer_data_app/customer_data_app/views/navbar.py new file mode 100644 index 00000000..9e90f977 --- /dev/null +++ b/customer_data_app/customer_data_app/views/navbar.py @@ -0,0 +1,28 @@ +import reflex as rx + + +def navbar(): + return rx.flex( + rx.badge( + rx.icon(tag="table-2", size=28), + rx.heading("Customer Data App", size="6"), + color_scheme="green", + radius="large", + align="center", + variant="surface", + padding="0.65rem", + ), + rx.spacer(), + rx.hstack( + rx.logo(), + rx.color_mode.button(), + align="center", + spacing="3", + ), + spacing="2", + flex_direction=["column", "column", "row"], + align="center", + width="100%", + top="0px", + padding_top="2em", + ) diff --git a/customer_data_app/requirements.txt b/customer_data_app/requirements.txt index 42d412f9..261538e3 100644 --- a/customer_data_app/requirements.txt +++ b/customer_data_app/requirements.txt @@ -1,2 +1,2 @@ -reflex>=0.4.5 +reflex>=0.5.2 psycopg2-binary \ No newline at end of file From 33097a16808f1d6af6aed4d49a1c10c58dd121ec Mon Sep 17 00:00:00 2001 From: Tom Gotsman Date: Fri, 31 May 2024 13:12:33 -0700 Subject: [PATCH 2/5] change code to remove all state vars and use model fields directly in form --- .../customer_data_app/backend/backend.py | 118 +++++++----------- .../customer_data_app/customer_data_app.py | 1 - .../customer_data_app/views/main.py | 15 +-- 3 files changed, 51 insertions(+), 83 deletions(-) diff --git a/customer_data_app/customer_data_app/backend/backend.py b/customer_data_app/customer_data_app/backend/backend.py index 89b2f2ca..ce83bef9 100644 --- a/customer_data_app/customer_data_app/backend/backend.py +++ b/customer_data_app/customer_data_app/backend/backend.py @@ -1,6 +1,6 @@ import reflex as rx from typing import Literal, Union -from sqlmodel import select +from sqlmodel import select, asc, desc, or_, func from datetime import datetime, timedelta LiteralStatus = Literal["Delivered", "Pending", "Cancelled"] @@ -41,38 +41,34 @@ class MonthValues(rx.Base): class State(rx.State): """The app state.""" - id: int - name: str = "" - email: str = "" - phone: str = "" - address: str = "" - date: str = "" # In 'YYYY-MM-DD HH:MM:SS' format - payments: float = 0.0 - status: LiteralStatus = "Pending" users: list[Customer] = [] sort_value: str = "" sort_reverse: bool = False + current_user: Customer = Customer() # Values for current and previous month current_month_values: MonthValues = MonthValues() previous_month_values: MonthValues = MonthValues() + def load_entries(self) -> list[Customer]: """Get all users from the database.""" with rx.session() as session: - self.users = session.exec(select(Customer)).all() + query = select(Customer) if self.sort_value: + sort_column = getattr(Customer, self.sort_value) if self.sort_value == "payments": - self.users = sorted( - self.users, key=lambda user: user.payments, reverse=self.sort_reverse - ) + order = desc(sort_column) if self.sort_reverse else asc(sort_column) else: - self.users = sorted( - self.users, key=lambda user: str(getattr( - user, self.sort_value)).lower(), reverse=self.sort_reverse - ) + order = desc(func.lower(sort_column)) if self.sort_reverse else asc(func.lower(sort_column)) + query = query.order_by(order) + + ###### come back to add search functioanloty later + self.users = session.exec(query).all() + self.get_current_month_values() self.get_previous_month_values() + def get_current_month_values(self): """Calculate current month's values.""" now = datetime.now() @@ -86,6 +82,7 @@ def get_current_month_values(self): num_delivers = len([user for user in current_month_users if user.status == "Delivered"]) self.current_month_values = MonthValues(num_customers=num_customers, total_payments=total_payments, num_delivers=num_delivers) + def get_previous_month_values(self): """Calculate previous month's values.""" now = datetime.now() @@ -104,90 +101,61 @@ def get_previous_month_values(self): self.previous_month_values = MonthValues(num_customers=num_customers, total_payments=total_payments, num_delivers=num_delivers) + def sort_values(self, sort_value: str): self.sort_value = sort_value self.load_entries() + def toggle_sort(self): self.sort_reverse = not self.sort_reverse self.load_entries() - def set_user_vars(self, user: Customer): - self.id = user["id"] - self.name = user["name"] - self.email = user["email"] - self.phone = user["phone"] - self.address = user["address"] - self.payments = user["payments"] - self.status = user["status"] - self.date = user["date"] - - def add_customer(self, form_data: dict): - self.name = form_data.get("name") - self.email = form_data.get("email") - self.phone = form_data.get("phone") - self.address = form_data.get("address") - self.payments = form_data.get("payments") - self.status = form_data.get("status", "Pending") - self.date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - """Add a customer to the database.""" + + def get_user(self, user: Customer): + self.current_user = user + + + def add_customer_to_db(self, form_data: dict): + self.current_user = form_data + self.current_user["date"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with rx.session() as session: if session.exec( - select(Customer).where(Customer.email == self.email) + select(Customer).where(Customer.email == self.current_user["email"]) ).first(): - return rx._x.toast.info("User already exists", variant="outline", position="bottom-right") - session.add( - Customer( - name=self.name, - email=self.email, - phone=self.phone, - address=self.address, - payments=self.payments, - status=self.status, - date=self.date, - ) - ) + return rx.window_alert("User with this email already exists") + session.add(Customer(**self.current_user)) session.commit() self.load_entries() - return rx._x.toast.info(f"User {self.name} has been added.", variant="outline", position="bottom-right") - - def update_customer(self, form_data: dict): - self.name = form_data.get("name") - self.email = form_data.get("email") - self.phone = form_data.get("phone") - self.address = form_data.get("address") - self.payments = form_data.get("payments") - self.status = form_data.get("status", "Pending") + return rx._x.toast.info(f"User {self.current_user["name"]} has been added.", variant="outline", position="bottom-right") + - """Update a customer in the database.""" + def update_customer_to_db(self, form_data: dict): + self.current_user.update(form_data) + print(self.current_user) with rx.session() as session: customer = session.exec( - select(Customer).where(Customer.id == self.id) + select(Customer).where(Customer.id == self.current_user["id"]) ).first() - customer.name = self.name - customer.email = self.email - customer.phone = self.phone - customer.address = self.address - customer.payments = self.payments - customer.status = self.status + for field in Customer.get_fields(): + if field != "id": + setattr(customer, field, self.current_user[field]) session.add(customer) session.commit() self.load_entries() - return rx._x.toast.info(f"User {self.name} has been modified.", variant="outline", position="bottom-right") + return rx._x.toast.info(f"User {self.current_user["name"]} has been modified.", variant="outline", position="bottom-right") - def delete_customer(self, email: str): + + def delete_customer(self, id: int): """Delete a customer from the database.""" with rx.session() as session: - customer = session.exec( - select(Customer).where(Customer.email == email) - ).first() + customer = session.exec(select(Customer).where(Customer.id == id)).first() session.delete(customer) session.commit() self.load_entries() - return rx._x.toast.info(f"User {email} has been deleted.", variant="outline", position="bottom-right") - - def on_load(self): - self.load_entries() + return rx._x.toast.info(f"User {customer.name} has been deleted.", variant="outline", position="bottom-right") + @rx.var def payments_change(self) -> float: diff --git a/customer_data_app/customer_data_app/customer_data_app.py b/customer_data_app/customer_data_app/customer_data_app.py index 43c03818..e0e80a32 100644 --- a/customer_data_app/customer_data_app/customer_data_app.py +++ b/customer_data_app/customer_data_app/customer_data_app.py @@ -30,7 +30,6 @@ def index() -> rx.Component: app.add_page( index, - on_load=State.on_load, title="Customer Data App", description="A simple app to manage customer data.", ) diff --git a/customer_data_app/customer_data_app/views/main.py b/customer_data_app/customer_data_app/views/main.py index 2543239d..5734b6b3 100644 --- a/customer_data_app/customer_data_app/views/main.py +++ b/customer_data_app/customer_data_app/views/main.py @@ -25,7 +25,7 @@ def show_customer(user: Customer): update_customer_dialog(user), rx.icon_button( rx.icon("trash-2", size=22), - on_click=lambda: State.delete_customer(user.email), + on_click=lambda: State.delete_customer(getattr(user, "id")), size="2", variant="solid", color_scheme="red", @@ -151,7 +151,7 @@ def add_customer_button() -> rx.Component: mt="4", justify="end", ), - on_submit=State.add_customer, + on_submit=State.add_customer_to_db, reset_on_submit=False, ), width="100%", @@ -176,7 +176,7 @@ def update_customer_dialog(user): color_scheme="blue", size="2", variant="solid", - on_click=lambda: State.set_user_vars(user), + on_click=lambda: State.get_user(user), ), ), rx.dialog.content( @@ -225,7 +225,7 @@ def update_customer_dialog(user): "email", "email", "mail", - user.email + user.email, ), # Phone form_field( @@ -234,7 +234,7 @@ def update_customer_dialog(user): "tel", "phone", "phone", - user.phone + user.phone, ), # Address form_field( @@ -243,7 +243,7 @@ def update_customer_dialog(user): "text", "address", "home", - user.address + user.address, ), # Payments form_field( @@ -293,7 +293,7 @@ def update_customer_dialog(user): mt="4", justify="end", ), - on_submit=State.update_customer, + on_submit=State.update_customer_to_db, reset_on_submit=False, ), width="100%", @@ -362,5 +362,6 @@ def main_table(): variant="surface", size="3", width="100%", + on_mount=State.load_entries, ), ) From 5e24f8e0076564312b3431194a763770db4543a4 Mon Sep 17 00:00:00 2001 From: Tom Gotsman Date: Fri, 31 May 2024 18:14:50 -0700 Subject: [PATCH 3/5] add search --- .../customer_data_app/backend/backend.py | 19 ++++++++++++++++--- .../customer_data_app/views/main.py | 5 +++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/customer_data_app/customer_data_app/backend/backend.py b/customer_data_app/customer_data_app/backend/backend.py index ce83bef9..6a36f131 100644 --- a/customer_data_app/customer_data_app/backend/backend.py +++ b/customer_data_app/customer_data_app/backend/backend.py @@ -3,7 +3,7 @@ from sqlmodel import select, asc, desc, or_, func from datetime import datetime, timedelta -LiteralStatus = Literal["Delivered", "Pending", "Cancelled"] +#LiteralStatus = Literal["Delivered", "Pending", "Cancelled"] def _get_percentage_change(value: Union[int, float], prev_value: Union[int, float]) -> float: @@ -44,6 +44,7 @@ class State(rx.State): users: list[Customer] = [] sort_value: str = "" sort_reverse: bool = False + search_value: str = "" current_user: Customer = Customer() # Values for current and previous month current_month_values: MonthValues = MonthValues() @@ -54,6 +55,17 @@ def load_entries(self) -> list[Customer]: """Get all users from the database.""" with rx.session() as session: query = select(Customer) + if self.search_value: + search_value = f"%{str(self.search_value).lower()}%" + query = query.where( + or_( + *[ + getattr(Customer, field).ilike(search_value) + for field in Customer.get_fields() + ], + ) + ) + if self.sort_value: sort_column = getattr(Customer, self.sort_value) if self.sort_value == "payments": @@ -62,7 +74,6 @@ def load_entries(self) -> list[Customer]: order = desc(func.lower(sort_column)) if self.sort_reverse else asc(func.lower(sort_column)) query = query.order_by(order) - ###### come back to add search functioanloty later self.users = session.exec(query).all() self.get_current_month_values() @@ -111,6 +122,9 @@ def toggle_sort(self): self.sort_reverse = not self.sort_reverse self.load_entries() + def filter_values(self, search_value): + self.search_value = search_value + self.load_entries() def get_user(self, user: Customer): self.current_user = user @@ -133,7 +147,6 @@ def add_customer_to_db(self, form_data: dict): def update_customer_to_db(self, form_data: dict): self.current_user.update(form_data) - print(self.current_user) with rx.session() as session: customer = session.exec( select(Customer).where(Customer.id == self.current_user["id"]) diff --git a/customer_data_app/customer_data_app/views/main.py b/customer_data_app/customer_data_app/views/main.py index 5734b6b3..03854cd9 100644 --- a/customer_data_app/customer_data_app/views/main.py +++ b/customer_data_app/customer_data_app/views/main.py @@ -337,6 +337,11 @@ def main_table(): size="3", on_change=lambda sort_value: State.sort_values(sort_value), ), + rx.input( + placeholder="Search here...", + size="3", + on_change=lambda value: State.filter_values(value), + ), spacing="3", align="center", ), From 7923436eb9bdde16562669e82bd968b3619746cb Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 3 Jun 2024 16:55:33 +0200 Subject: [PATCH 4/5] update sales app --- nba/.gitignore | 2 +- nba/assets/ball.svg | 1 + nba/nba/components/__init__.py | 0 nba/nba/nba.py | 205 ++++++++------------------------- nba/nba/views/__init__.py | 0 nba/nba/views/navbar.py | 30 +++++ nba/nba/views/stats.py | 97 ++++++++++++++++ nba/nba/views/table.py | 81 +++++++++++++ 8 files changed, 258 insertions(+), 158 deletions(-) create mode 100644 nba/assets/ball.svg create mode 100644 nba/nba/components/__init__.py create mode 100644 nba/nba/views/__init__.py create mode 100644 nba/nba/views/navbar.py create mode 100644 nba/nba/views/stats.py create mode 100644 nba/nba/views/table.py diff --git a/nba/.gitignore b/nba/.gitignore index eab0d4b0..e97bb370 100644 --- a/nba/.gitignore +++ b/nba/.gitignore @@ -1,4 +1,4 @@ *.db *.py[cod] .web -__pycache__/ \ No newline at end of file +__pycache__/ diff --git a/nba/assets/ball.svg b/nba/assets/ball.svg new file mode 100644 index 00000000..db42fe6a --- /dev/null +++ b/nba/assets/ball.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nba/nba/components/__init__.py b/nba/nba/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nba/nba/nba.py b/nba/nba/nba.py index 2d65db06..db6ad739 100644 --- a/nba/nba/nba.py +++ b/nba/nba/nba.py @@ -1,171 +1,62 @@ """Welcome to Reflex! This file outlines the steps to create a basic app.""" +from .views.navbar import navbar +from .views.table import table +from .views.stats import stats import reflex as rx -import pandas as pd -import plotly.express as px -import plotly.graph_objects as go -from .helpers import navbar -from reflex.components.radix.themes import theme -nba_overview = "https://media.geeksforgeeks.org/wp-content/uploads/nba.csv" -nba_data = pd.read_csv(nba_overview) -college = ["All"] + sorted(nba_data["College"].unique().astype(str)) - -class State(rx.State): - """The app state.""" - - # Filters to apply to the data. - position: str = "All" - college: str = "All" - age: tuple[int, int] = (18, 50) - salary: tuple[int, int] = (0, 25000000) - - @rx.var - def df(self) -> pd.DataFrame: - """The data.""" - nba = nba_data[ - (nba_data["Age"] > int(self.age[0])) - & (nba_data["Age"] < int(self.age[1])) - & (nba_data["Salary"] > int(self.salary[0])) - & (nba_data["Salary"] < int(self.salary[1])) - ] - - if self.college and self.college != "All": - nba = nba[nba["College"] == self.college] - - if self.position and self.position != "All": - nba = nba[nba["Position"] == self.position] - - if nba.empty: - return pd.DataFrame() - else: - return nba.fillna("") - - @rx.var - def scat_fig(self) -> go.Figure: - """The scatter figure.""" - nba = self.df - - if nba.empty: - return go.Figure() - else: - return px.scatter( - nba, - x="Age", - y="Salary", - title="NBA Age/Salary plot", - color=nba["Position"], - hover_data=["Name"], - symbol=nba["Position"], - trendline="lowess", - trendline_scope="overall", - ) - - @rx.var - def hist_fig(self) -> go.Figure: - """The histogram figure.""" - nba = self.df - - if nba.empty: - return go.Figure() - else: - return px.histogram( - nba, x="Age", y="Salary", title="Age/Salary Distribution" - ) - - -def selection(): +def index() -> rx.Component: return rx.vstack( - rx.hstack( - rx.vstack( - rx.select( - ["All", "C", "PF", "SF", "PG", "SG"], - placeholder="Select a position.", - default="All", - on_change=State.set_position, - width="15em", - size="3", - ), - rx.select( - college, - placeholder="Select a college.", - default="All", - on_change=State.set_college, - width="15em", - size="3", - ), - ), - rx.vstack( - rx.vstack( - rx.hstack( - rx.badge("Min Age: ", State.age[0]), - rx.divider(orientation="vertical"), - rx.badge("Max Age: ", State.age[1]), - ), - rx.slider( - default_value=[18, 50], - min=18, - max=50, - on_value_commit=State.set_age, - ), - align_items="left", - width="100%", - ), - rx.vstack( - rx.hstack( - rx.badge("Min Sal: ", State.salary[0] // 1000000, "M"), - rx.divider(orientation="vertical"), - rx.badge("Max Sal: ", State.salary[1] // 1000000, "M"), - ), - rx.slider( - default_value=[0, 25000000], - min=0, - max=25000000, - on_value_commit=State.set_salary, - ), - align_items="left", - width="100%", - ), + navbar(), + rx.flex( + rx.box( + table(), + width=["100%", "100%", "100%", "50%"] ), - spacing="4", + stats(), + spacing="9", + width="100%", + flex_direction=["column", "column", "column", "row"], ), - align="center", width="100%", + spacing="6", + padding_x=["1.5em", "1.5em", "3em"], + padding_y=["1em", "1em", "2em"] ) -def index(): - """The main view.""" - return rx.center( - rx.vstack( - navbar(), - selection(), - rx.divider(width="100%"), - rx.plotly(data=State.scat_fig, layout={"width": "1000", "height": "600"}), - rx.plotly(data=State.hist_fig, layout={"width": "1000", "height": "600"}), - rx.data_table( - data=nba_data, - pagination=True, - search=True, - sort=True, - resizable=True, - ), - rx.divider(width="100%"), - align="center", - padding_top="6em", - width="100%", - ) - ) - - +# base_stylesheets = [ +# "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" +# ] + +# font_default = "Inter" + +# base_style = { +# "font_family": font_default, +# rx.text: { +# "font_family": font_default, +# }, +# rx.heading: { +# "font_family": font_default, +# }, +# rx.link: { +# "font_family": font_default, +# }, +# rx.input: { +# "font_family": font_default, +# }, +# rx.button: { +# "font_family": font_default, +# }, +# } app = rx.App( - theme=theme( - appearance="light", - has_background=True, - radius="large", - accent_color="blue", - gray_color="sand", - ) + #style=base_style, stylesheets=base_stylesheets, + theme=rx.theme( + appearance="light", has_background=True, radius="large", accent_color="orange" + ), ) -app.add_page(index, title="NBA App") +app.add_page(index, + title="Sales App", + description="Generate personalized sales emails.", + ) diff --git a/nba/nba/views/__init__.py b/nba/nba/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nba/nba/views/navbar.py b/nba/nba/views/navbar.py new file mode 100644 index 00000000..410ebd28 --- /dev/null +++ b/nba/nba/views/navbar.py @@ -0,0 +1,30 @@ +import reflex as rx + + +def navbar(): + return rx.flex( + rx.hstack( + rx.image(src="/ball.svg",height="40px"), + rx.heading("NBA Data", size="7"), + rx.badge( + "2015-2016 season", + radius="full", + align="center", + color_scheme="orange", + variant="surface", + ), + align="center", + ), + rx.spacer(), + rx.hstack( + rx.logo(), + rx.color_mode.button(), + align="center", + spacing="3", + ), + spacing="2", + flex_direction=["column", "column", "row"], + align="center", + width="100%", + top="0px", + ) diff --git a/nba/nba/views/stats.py b/nba/nba/views/stats.py new file mode 100644 index 00000000..920779c0 --- /dev/null +++ b/nba/nba/views/stats.py @@ -0,0 +1,97 @@ +import reflex as rx +from .table import State +from .table import college + + +def selection(): + return rx.flex( + rx.vstack( + rx.hstack( + rx.icon("person-standing", size=24), + rx.select( + ["All", "C", "PF", "SF", "PG", "SG"], + placeholder="Select a position.", + default="All", + on_change=State.set_position, + size="3", + variant="soft" + ), + justify="end", + spacing="2", + align="center", + width="100%", + ), + rx.vstack( + rx.slider( + default_value=[18, 50], + min=18, + variant="soft", + max=50, + on_value_commit=State.set_age, + ), + rx.hstack( + rx.badge("Min Age: ", State.age[0]), + rx.spacer(), + rx.badge("Max Age: ", State.age[1]), + width="100%", + ), + width="100%", + ), + width="100%", + spacing="4", + ), + rx.spacer(), + rx.vstack( + rx.hstack( + rx.icon("university", size=24), + rx.select( + college, + placeholder="Select a college.", + default="All", + variant="soft", + on_change=State.set_college, + size="3", + ), + justify="end", + spacing="2", + align="center", + width="100%", + ), + rx.vstack( + rx.slider( + default_value=[0, 25000000], + min=0, + max=25000000, + variant="soft", + on_value_commit=State.set_salary, + ), + rx.hstack( + rx.badge("Min Sal: ", State.salary[0] // 1000000, "M"), + rx.spacer(), + rx.badge("Max Sal: ", State.salary[1] // 1000000, "M"), + width="100%", + ), + width="100%", + spacing="4", + ), + width="100%", + spacing="4", + ), + flex_direction=["column", "column", "row"], + spacing="4", + width="100%", + ) + + +def stats(): + return rx.vstack( + selection(), + rx.divider(), + rx.box( + rx.plotly(data=State.scat_fig, width="100%", use_resize_handler=True), + rx.plotly(data=State.hist_fig, width="100%", use_resize_handler=True), + width="100%", + ), + width=["100%", "100%", "100%", "50%"], + spacing="4", + ) diff --git a/nba/nba/views/table.py b/nba/nba/views/table.py new file mode 100644 index 00000000..bb67844b --- /dev/null +++ b/nba/nba/views/table.py @@ -0,0 +1,81 @@ +import reflex as rx +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go + +nba_overview = "https://media.geeksforgeeks.org/wp-content/uploads/nba.csv" +nba_data = pd.read_csv(nba_overview) +college = ["All"] + sorted(nba_data["College"].unique().astype(str)) + + +class State(rx.State): + """The app state.""" + + # Filters to apply to the data. + position: str = "All" + college: str = "All" + age: tuple[int, int] = (18, 50) + salary: tuple[int, int] = (0, 25000000) + + @rx.var + def df(self) -> pd.DataFrame: + """The data.""" + nba = nba_data[ + (nba_data["Age"] > int(self.age[0])) + & (nba_data["Age"] < int(self.age[1])) + & (nba_data["Salary"] > int(self.salary[0])) + & (nba_data["Salary"] < int(self.salary[1])) + ] + + if self.college and self.college != "All": + nba = nba[nba["College"] == self.college] + + if self.position and self.position != "All": + nba = nba[nba["Position"] == self.position] + + if nba.empty: + return pd.DataFrame() + else: + return nba.fillna("") + + @rx.var + def scat_fig(self) -> go.Figure: + """The scatter figure.""" + nba = self.df + + if nba.empty: + return go.Figure() + else: + return px.scatter( + nba, + x="Age", + y="Salary", + title="NBA Age/Salary plot", + color=nba["Position"], + hover_data=["Name"], + symbol=nba["Position"], + trendline="lowess", + trendline_scope="overall", + ) + + @rx.var + def hist_fig(self) -> go.Figure: + """The histogram figure.""" + nba = self.df + + if nba.empty: + return go.Figure() + else: + return px.histogram( + nba, x="Age", y="Salary", title="Age/Salary Distribution" + ) + + +def table() -> rx.Component: + return rx.data_table( + data=nba_data, + pagination=True, + search=True, + sort=True, + resizable=True, + ), From 279d4925e9ddd98a0279a3b062b8f8fb6fbff3f7 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 3 Jun 2024 17:04:38 +0200 Subject: [PATCH 5/5] update sales app + wip nba --- sales/alembic.ini | 116 +++++++ sales/alembic/README | 1 + sales/alembic/env.py | 78 +++++ sales/alembic/script.py.mako | 26 ++ sales/alembic/versions/4cf1c551c988_.py | 40 +++ sales/sales/backend/__init__.py | 0 sales/sales/backend/backend.py | 184 ++++++++++ sales/sales/{ => backend}/models.py | 0 sales/sales/components/__init__.py | 0 sales/sales/components/form_field.py | 26 ++ sales/sales/components/gender_badges.py | 14 + sales/sales/sales.py | 391 ++-------------------- sales/sales/views/__init__.py | 0 sales/sales/views/email.py | 72 ++++ sales/sales/views/main.py | 427 ++++++++++++++++++++++++ sales/sales/views/navbar.py | 27 ++ 16 files changed, 1039 insertions(+), 363 deletions(-) create mode 100644 sales/alembic.ini create mode 100644 sales/alembic/README create mode 100644 sales/alembic/env.py create mode 100644 sales/alembic/script.py.mako create mode 100644 sales/alembic/versions/4cf1c551c988_.py create mode 100644 sales/sales/backend/__init__.py create mode 100644 sales/sales/backend/backend.py rename sales/sales/{ => backend}/models.py (100%) create mode 100644 sales/sales/components/__init__.py create mode 100644 sales/sales/components/form_field.py create mode 100644 sales/sales/components/gender_badges.py create mode 100644 sales/sales/views/__init__.py create mode 100644 sales/sales/views/email.py create mode 100644 sales/sales/views/main.py create mode 100644 sales/sales/views/navbar.py diff --git a/sales/alembic.ini b/sales/alembic.ini new file mode 100644 index 00000000..c10d4ca0 --- /dev/null +++ b/sales/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/sales/alembic/README b/sales/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/sales/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/sales/alembic/env.py b/sales/alembic/env.py new file mode 100644 index 00000000..36112a3c --- /dev/null +++ b/sales/alembic/env.py @@ -0,0 +1,78 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/sales/alembic/script.py.mako b/sales/alembic/script.py.mako new file mode 100644 index 00000000..fbc4b07d --- /dev/null +++ b/sales/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/sales/alembic/versions/4cf1c551c988_.py b/sales/alembic/versions/4cf1c551c988_.py new file mode 100644 index 00000000..78bd0119 --- /dev/null +++ b/sales/alembic/versions/4cf1c551c988_.py @@ -0,0 +1,40 @@ +"""empty message + +Revision ID: 4cf1c551c988 +Revises: +Create Date: 2024-05-31 12:56:30.507083 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = '4cf1c551c988' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('customer', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('customer_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('age', sa.Integer(), nullable=False), + sa.Column('gender', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('location', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('job', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('salary', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('customer') + # ### end Alembic commands ### diff --git a/sales/sales/backend/__init__.py b/sales/sales/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sales/sales/backend/backend.py b/sales/sales/backend/backend.py new file mode 100644 index 00000000..5d3b9a7d --- /dev/null +++ b/sales/sales/backend/backend.py @@ -0,0 +1,184 @@ +import os +import openai + +import reflex as rx +from sqlmodel import select, asc, desc, or_, func + +from .models import Customer + + +products: dict[str, dict] = { + "T-shirt": { + "description": "A plain white t-shirt made of 100% cotton.", + "price": 10.99, + }, + "Jeans": { + "description": "A pair of blue denim jeans with a straight leg fit.", + "price": 24.99, + }, + "Hoodie": { + "description": "A black hoodie made of a cotton and polyester blend.", + "price": 34.99, + }, + "Cardigan": { + "description": "A grey cardigan with a V-neck and long sleeves.", + "price": 36.99, + }, + "Joggers": { + "description": "A pair of black joggers made of a cotton and polyester blend.", + "price": 44.99, + }, + "Dress": {"description": "A black dress made of 100% polyester.", "price": 49.99}, + "Jacket": { + "description": "A navy blue jacket made of 100% cotton.", + "price": 55.99, + }, + "Skirt": { + "description": "A brown skirt made of a cotton and polyester blend.", + "price": 29.99, + }, + "Shorts": { + "description": "A pair of black shorts made of a cotton and polyester blend.", + "price": 19.99, + }, + "Sweater": { + "description": "A white sweater with a crew neck and long sleeves.", + "price": 39.99, + }, +} + +_client = None + +def get_openai_client(): + global _client + if _client is None: + _client = openai.OpenAI(api_key=os.environ["OPENAI_API_KEY"]) + + return _client + + +class State(rx.State): + """The app state.""" + + current_user: Customer = Customer() + users: list[Customer] = [] + products: dict[str, str] = {} + email_content_data: str = "Click 'Generate Email' to generate a personalized sales email." + gen_response = False + tone: str = "Formal" + length: str = "1000" + search_value: str = "" + sort_value: str = "" + sort_reverse: bool = False + + def load_entries(self) -> list[Customer]: + """Get all users from the database.""" + with rx.session() as session: + query = select(Customer) + if self.search_value: + search_value = f"%{str(self.search_value).lower()}%" + query = query.where( + or_( + *[ + getattr(Customer, field).ilike(search_value) + for field in Customer.get_fields() + ], + ) + ) + + if self.sort_value: + sort_column = getattr(Customer, self.sort_value) + if self.sort_value == "salary": + order = desc(sort_column) if self.sort_reverse else asc( + sort_column) + else: + order = desc(func.lower(sort_column)) if self.sort_reverse else asc( + func.lower(sort_column)) + query = query.order_by(order) + + self.users = session.exec(query).all() + + def sort_values(self, sort_value: str): + self.sort_value = sort_value + self.load_entries() + + def toggle_sort(self): + self.sort_reverse = not self.sort_reverse + self.load_entries() + + def filter_values(self, search_value): + self.search_value = search_value + self.load_entries() + + def get_user(self, user: Customer): + self.current_user = user + + def add_customer_to_db(self, form_data: dict): + self.current_user = form_data + + with rx.session() as session: + if session.exec( + select(Customer).where( + Customer.email == self.current_user["email"]) + ).first(): + return rx.window_alert("User with this email already exists") + session.add(Customer(**self.current_user)) + session.commit() + self.load_entries() + return rx._x.toast.info(f"User {self.current_user["customer_name"]} has been added.", variant="outline", position="bottom-right") + + def update_customer_to_db(self, form_data: dict): + self.current_user.update(form_data) + with rx.session() as session: + customer = session.exec( + select(Customer).where(Customer.id == self.current_user["id"]) + ).first() + for field in Customer.get_fields(): + if field != "id": + setattr(customer, field, self.current_user[field]) + session.add(customer) + session.commit() + self.load_entries() + return rx._x.toast.info(f"User {self.current_user["customer_name"]} has been modified.", variant="outline", position="bottom-right") + + def delete_customer(self, id: int): + """Delete a customer from the database.""" + with rx.session() as session: + customer = session.exec( + select(Customer).where(Customer.id == id)).first() + session.delete(customer) + session.commit() + self.load_entries() + return rx._x.toast.info(f"User {customer.customer_name} has been deleted.", variant="outline", position="bottom-right") + + @rx.background + async def call_openai(self): + session = get_openai_client().chat.completions.create( + user=self.router.session.client_token, + stream=True, + model="gpt-3.5-turbo", + messages=[ + {"role": "system", "content": f"You are a salesperson at Reflex, a company that sells clothing. You have a list of products and customer data. Your task is to write a sales email to a customer recommending one of the products. The email should be personalized and include a recommendation based on the customer's data. The email should be {self.tone} and {self.length} characters long."}, + {"role": "user", "content": f"Based on these {products} write a sales email to {self.current_user.customer_name} and email {self.current_user.email} who is {self.current_user.age} years old and a {self.current_user.gender} gender. {self.current_user.customer_name} lives in {self.current_user.location} and works as a { + self.current_user.job} and earns {self.current_user.salary} per year. Make sure the email recommends one product only and is personalized to {self.current_user.customer_name}. The company is named Reflex its website is https://reflex.dev."}, + ] + ) + for item in session: + if hasattr(item.choices[0].delta, "content"): + response_text = item.choices[0].delta.content + async with self: + if response_text is not None: + self.email_content_data += response_text + else: + response_text = "" + self.email_content_data += response_text + yield + + async with self: + self.gen_response = False + + def generate_email(self, user: Customer): + self.current_user = Customer(**user) + self.gen_response = True + self.email_content_data = "" + return State.call_openai diff --git a/sales/sales/models.py b/sales/sales/backend/models.py similarity index 100% rename from sales/sales/models.py rename to sales/sales/backend/models.py diff --git a/sales/sales/components/__init__.py b/sales/sales/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sales/sales/components/form_field.py b/sales/sales/components/form_field.py new file mode 100644 index 00000000..df16408b --- /dev/null +++ b/sales/sales/components/form_field.py @@ -0,0 +1,26 @@ +import reflex as rx + + +def form_field( + label: str, placeholder: str, type: str, name: str, icon: str, default_value: str = "" +) -> rx.Component: + return rx.form.field( + rx.flex( + rx.hstack( + rx.icon(icon, size=16, stroke_width=1.5), + rx.form.label(label), + align="center", + spacing="2", + ), + rx.form.control( + rx.input( + placeholder=placeholder, type=type, default_value=default_value + ), + as_child=True, + ), + direction="column", + spacing="1", + ), + name=name, + width="100%", + ) \ No newline at end of file diff --git a/sales/sales/components/gender_badges.py b/sales/sales/components/gender_badges.py new file mode 100644 index 00000000..1fff9145 --- /dev/null +++ b/sales/sales/components/gender_badges.py @@ -0,0 +1,14 @@ +import reflex as rx + + +def _badge(text: str, color_scheme: str): + return rx.badge(text, color_scheme=color_scheme, radius="full", variant="soft", size="3") + +def gender_badge(gender: str): + badge_mapping = { + "Male": ("♂️ Male", "blue"), + "Female": ("♀️ Female", "pink"), + "Other": ("Other", "gray") + } + return _badge(*badge_mapping.get(gender, ("Other", "gray"))) + diff --git a/sales/sales/sales.py b/sales/sales/sales.py index b25b72dc..05c146d3 100644 --- a/sales/sales/sales.py +++ b/sales/sales/sales.py @@ -1,373 +1,38 @@ -from openai import OpenAI - import reflex as rx -from sqlmodel import select - -from .models import Customer - - -products: dict[str, dict] = { - "T-shirt": { - "description": "A plain white t-shirt made of 100% cotton.", - "price": 10.99, - }, - "Jeans": { - "description": "A pair of blue denim jeans with a straight leg fit.", - "price": 24.99, - }, - "Hoodie": { - "description": "A black hoodie made of a cotton and polyester blend.", - "price": 34.99, - }, - "Cardigan": { - "description": "A grey cardigan with a V-neck and long sleeves.", - "price": 36.99, - }, - "Joggers": { - "description": "A pair of black joggers made of a cotton and polyester blend.", - "price": 44.99, - }, - "Dress": {"description": "A black dress made of 100% polyester.", "price": 49.99}, - "Jacket": { - "description": "A navy blue jacket made of 100% cotton.", - "price": 55.99, - }, - "Skirt": { - "description": "A brown skirt made of a cotton and polyester blend.", - "price": 29.99, - }, - "Shorts": { - "description": "A pair of black shorts made of a cotton and polyester blend.", - "price": 19.99, - }, - "Sweater": { - "description": "A white sweater with a crew neck and long sleeves.", - "price": 39.99, - }, -} - - -class State(rx.State): - """The app state.""" - - customer_name: str = "" - email: str = "" - age: int = 0 - gender: str = "Other" - location: str = "" - job: str = "" - salary: int = 0 - users: list[Customer] = [] - products: dict[str, str] = {} - email_content_data: str = "" - gen_response = False - users: list[Customer] = [] - - def add_customer(self): - """Add a customer to the database.""" - with rx.session() as session: - if session.exec( - select(Customer).where(Customer.email == self.email) - ).first(): - return rx.window_alert("User already exists") - session.add( - Customer( - customer_name=self.customer_name, - email=self.email, - age=self.age, - gender=self.gender, - location=self.location, - job=self.job, - salary=self.salary, - ) - ) - session.commit() - self.get_users() - return rx.window_alert(f"User {self.customer_name} has been added.") - - def customer_page(self): - """The customer page.""" - return rx.redirect("/") - - def onboarding_page(self): - """The onboarding page.""" - return rx.redirect("/onboarding") - - def delete_customer(self, email: str): - """Delete a customer from the database.""" - with rx.session() as session: - customer = session.exec( - select(Customer).where(Customer.email == email) - ).first() - session.delete(customer) - session.commit() - self.get_users() - - generate_email_data: dict = {} - - async def call_openai(self): - name: str = self.generate_email_data["name"] - email: str = self.generate_email_data["email"] - age: int = self.generate_email_data["age"] - gender: str = self.generate_email_data["gender"] - location: str = self.generate_email_data["location"] - job: str = self.generate_email_data["job"] - salary: int = self.generate_email_data["salary"] - response = OpenAI().completions.create( - model="gpt-3.5-turbo-instruct", - prompt=f"Based on these {products} write a sales email to {name} and email {email} who is {age} years old and a {gender} gender. {name} lives in {location} and works as a {job} and earns {salary} per year. Make sure the email recommends one product only and is personalized to {name}. The company is named Reflex its website is https://reflex.dev", - temperature=0.7, - max_tokens=2250, - top_p=1, - frequency_penalty=0, - presence_penalty=0, - ) - self.gen_response = False - # save the data related to email_content - self.email_content_data = response.choices[0].text - # update layout of email_content manually - return rx.set_value("email_content", self.email_content_data) - - def generate_email( - self, - name: str, - email: str, - age: int, - gender: str, - location: str, - job: str, - salary: int, - ): - self.generate_email_data["name"] = name - self.generate_email_data["email"] = email - self.generate_email_data["age"] = age - self.generate_email_data["gender"] = gender - self.generate_email_data["location"] = location - self.generate_email_data["job"] = job - self.generate_email_data["salary"] = salary - self.text_area_disabled = True - self.gen_response = True - return State.call_openai - - def get_users(self): - """Get all users from the database.""" - with rx.session() as session: - self.users = session.exec(select(Customer)).all() - - def open_text_area(self): - self.text_area_disabled = False - - def close_text_area(self): - self.text_area_disabled = True - - -def navbar(): - """The navbar for the top of the page.""" - return rx.box( - rx.hstack( - rx.link( - rx.hstack( - rx.image(src="/logo.jpg", width="50px"), - rx.heading("Reflex | Personalized Sales", size="8"), - align="center", - ), - href="/", - ), - rx.spacer(width="100%"), - rx.menu.root( - rx.menu.trigger( - rx.button("Menu", size="3"), - radius="md", - ), - rx.menu.content( - rx.menu.item( - rx.link( - rx.hstack("Customers", rx.icon(tag="menu")), - href="/", - ), - ), - rx.menu.separator(), - rx.menu.item( - rx.link( - rx.hstack("Onboarding", rx.icon(tag="plus")), - href="/onboarding", - ), - ), - align="start", - ), - ), - align="center", - justify="center", - border_bottom="0.2em solid #F0F0F0", - padding_x="2em", - padding_y="1em", - bg="rgba(255,255,255, 0.97)", +from .views.navbar import navbar +from .views.email import email_gen_ui +from .views.main import main_table +from .backend.backend import State + + +def index() -> rx.Component: + return rx.vstack( + navbar(), + rx.flex( + rx.box( + main_table(), + width=["100%", "100%", "100%", "60%"] + ), + email_gen_ui(), + spacing="6", + width="100%", + flex_direction=["column", "column", "column", "row"], ), - position="fixed", width="100%", - top="0px", - z_index="500", - ) - - -def show_customer(user: Customer): - """Show a customer in a table row.""" - return rx.table.row( - rx.table.row_header_cell(user.customer_name), - rx.table.cell(user.email), - rx.table.cell(user.age), - rx.table.cell(user.gender), - rx.table.cell(user.location), - rx.table.cell(user.job), - rx.table.cell(user.salary), - rx.table.cell( - rx.button( - "Delete", - on_click=lambda: State.delete_customer(user.email), # type: ignore - bg="red", - ) - ), - rx.table.cell( - rx.button( - "Generate Email", - on_click=State.generate_email( - user.customer_name, - user.email, - user.age, - user.gender, - user.location, - user.job, - user.salary, - ), # type: ignore - bg="blue", - ) - ), - align="center", - ) - - -def add_customer(): - """Add a customer to the database.""" - return rx.center( - rx.vstack( - navbar(), - rx.heading("Customer Onboarding"), - rx.hstack( - rx.vstack( - rx.input(placeholder="Input Name", on_blur=State.set_customer_name), # type: ignore - rx.input(placeholder="Input Email", on_blur=State.set_email), # type: ignore - ), - rx.vstack( - rx.input(placeholder="Input Location", on_blur=State.set_location), # type: ignore - rx.input(placeholder="Input Job", on_blur=State.set_job), # type: ignore - ), - ), - rx.select( - ["male", "female", "other"], - placeholder="Select Gender", - on_change=State.set_gender, # type: ignore - width="100%", - ), - rx.input.root( - rx.input.input(on_change=State.set_age, placeholder="Age"), # type: ignore - width="100%", - ), - rx.input.root( - rx.input.input(on_change=State.set_salary, placeholder="Salary"), # type: ignore - width="100%", - ), - rx.hstack( - rx.button("Submit Customer", on_click=State.add_customer), - rx.button(rx.icon(tag="menu"), on_click=State.customer_page), - spacing="3", - ), - align="center", - class_name="shadow-lg", - bg="#F7FAFC ", - padding="1em", - border="1px solid #ddd", - border_radius="25px", - spacing="3", - ), - padding_top="10em", - ) - - -def index(): - """The main page.""" - return rx.cond( - State.is_hydrated, - rx.center( - navbar(), - rx.vstack( - rx.vstack( - rx.hstack( - rx.heading("Customers", size="8"), - rx.button( - rx.icon(tag="plus"), - on_click=State.onboarding_page, - size="3", - ), - align="center", - ), - rx.table.root( - rx.table.header( - rx.table.row( - rx.table.column_header_cell("Name"), - rx.table.column_header_cell("Email"), - rx.table.column_header_cell("Age"), - rx.table.column_header_cell("Gender"), - rx.table.column_header_cell("Location"), - rx.table.column_header_cell("Job"), - rx.table.column_header_cell("Salary"), - rx.table.column_header_cell("Delete"), - rx.table.column_header_cell("Generate Email"), - ) - ), - rx.table.body(rx.foreach(State.users, show_customer)), # type: ignore - variant="surface", - bg="#F7FAFC ", - border="1px solid #ddd", - border_radius="10px", - ), - align_items="left", - padding_top="7em", - ), - rx.vstack( - rx.heading("Generated Email", size="8"), - rx.cond( - State.gen_response, - rx.chakra.progress( - is_indeterminate=True, color="blue", width="100%" - ), - rx.chakra.progress(value=0, width="100%"), - ), - rx.text_area( - id="email_content", - is_disabled=State.gen_response, - on_blur=State.set_email_content_data, # type: ignore - width="100%", - height="100%", - bg="white", - placeholder="Response", - min_height="20em", - ), - align_items="left", - width="100%", - padding_top="2em", - ), - padding_top="4em", - ), - padding="1em", - ), + spacing="6", + padding_x=["1.5em", "1.5em", "3em"], + padding_y=["1em", "1em", "2em"] ) app = rx.App( - admin_dash=rx.AdminDash(models=[Customer]), + # admin_dash=rx.AdminDash(models=[Customer]), theme=rx.theme( - appearance="light", has_background=True, radius="large", accent_color="gray" + appearance="light", has_background=True, radius="large", accent_color="blue" ), ) -app.add_page(index, on_load=State.get_users) -app.add_page(add_customer, "/onboarding") +app.add_page(index, + on_load=State.load_entries, + title="Sales App", + description="Generate personalized sales emails.", + ) diff --git a/sales/sales/views/__init__.py b/sales/sales/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sales/sales/views/email.py b/sales/sales/views/email.py new file mode 100644 index 00000000..76c7b446 --- /dev/null +++ b/sales/sales/views/email.py @@ -0,0 +1,72 @@ +import reflex as rx +from ..backend.backend import State + +def email_box(): + return rx.box( + rx.scroll_area( + rx.icon_button( + rx.icon("copy"), + variant="soft", + color_scheme="gray", + size="2", + on_click=[rx.set_clipboard(State.email_content_data), rx._x.toast.info( + "Copied to clipboard")], + cursor="pointer", + position="absolute", + bottom="1px", + right="1px", + z_index="10", + ), + rx.text(State.email_content_data, line_height="1.75"), + type="auto", + scrollbars="vertical", + height="100%", + position="relative", + ), + width="100%", + height=["400px", "400px", "550px"] + ) + + +def options(): + return rx.vstack( + rx.vstack( + rx.heading(f"Length limit: {State.length}", size="5"), + rx.slider( + min=500, + max=1500, + default_value=1000, + step=100, + size="2", + on_change=State.set_length, + ), + width="100%", + ), + rx.vstack( + rx.heading("Tone", size="5"), + rx.select( + items=["Formal", "Casual", "Friendly", "Convincing", "Humble", "Urgent", "Humorous"], + default_value="Formal", + size="3", + on_change=State.set_tone, + ), + width="100%", + ), + spacing="5", + width="100%", + ) + +def email_gen_ui(): + return rx.card( + rx.flex( + email_box(), + rx.divider(), + options(), + flex_direction=["column-reverse", "column-reverse", "column-reverse", "column"], + padding=["0.5em", "0.5em", "1em"], + spacing="5", + ), + width=["100%", "100%", "100%", "40%"], + ), + + diff --git a/sales/sales/views/main.py b/sales/sales/views/main.py new file mode 100644 index 00000000..93c70e5f --- /dev/null +++ b/sales/sales/views/main.py @@ -0,0 +1,427 @@ +import reflex as rx +from ..backend.backend import State, Customer +from ..components.form_field import form_field +from ..components.gender_badges import gender_badge + + +def _header_cell(text: str, icon: str): + return rx.table.column_header_cell( + rx.hstack( + rx.icon(icon, size=18), + rx.text(text), + align="center", + spacing="2", + ), + ) + + +def show_customer(user: Customer): + """Show a customer in a table row.""" + return rx.table.row( + rx.table.row_header_cell(user.customer_name), + rx.table.cell(user.email), + rx.table.cell(user.age), + rx.table.cell(rx.match( + user.gender, + ("Male", gender_badge("Male")), + ("Female", gender_badge("Female")), + ("Other", gender_badge("Other")), + gender_badge("Other") + )), + rx.table.cell(user.location), + rx.table.cell(user.job), + rx.table.cell(user.salary), + rx.table.cell( + rx.hstack( + rx.button( + rx.icon("mail-plus", size=22), + rx.text("Generate Email", size="3"), + color_scheme="blue", + on_click=State.generate_email(user), + loading=State.gen_response + ), + update_customer_dialog(user), + rx.icon_button( + rx.icon("trash-2", size=22), + on_click=lambda: State.delete_customer(getattr(user, "id")), + size="2", + variant="solid", + color_scheme="red", + ), + ) + ), + style={"_hover": {"bg": rx.color("gray", 3)}}, + align="center", + ) + + +def add_customer_button() -> rx.Component: + return rx.dialog.root( + rx.dialog.trigger( + rx.button( + rx.icon("plus", size=26), + rx.text("Add Customer", size="4", display=[ + "none", "none", "block"]), + size="3", + ), + ), + rx.dialog.content( + rx.hstack( + rx.badge( + rx.icon(tag="users", size=34), + color_scheme="blue", + radius="full", + padding="0.65rem", + ), + rx.vstack( + rx.dialog.title( + "Customer Onboarding", + weight="bold", + margin="0", + ), + rx.dialog.description( + "Fill the form with the customer's info", + ), + spacing="1", + height="100%", + align_items="start", + ), + height="100%", + spacing="4", + margin_bottom="1.5em", + align_items="center", + width="100%", + ), + rx.flex( + rx.form.root( + rx.flex( + rx.hstack( + # Name + form_field( + "Name", + "Customer Name", + "text", + "customer_name", + "user", + ), + # Location + form_field( + "Location", + "Customer Location", + "text", + "location", + "map-pinned" + ), + spacing="3", + width="100%", + ), + rx.hstack( + # Email + form_field( + "Email", "user@reflex.dev", "email", "email", "mail" + ), + # Job + form_field( + "Job", + "Customer Job", + "text", + "job", + "briefcase" + ), + spacing="3", + width="100%", + ), + # Genre + rx.vstack( + rx.hstack( + rx.icon("user-round", size=16, stroke_width=1.5), + rx.text("Gender"), + align="center", + spacing="2", + ), + rx.select( + ["Male", "Female", "Other"], + placeholder="Select Gender", + name="gender", + direction="row", + as_child=True, + required=True, + width="100%", + ), + width="100%", + ), + rx.hstack( + # Age + form_field( + "Age", + "Customer Age", + "number", + "age", + "person-standing", + ), + # Salary + form_field( + "Salary", + "Customer Salary", + "number", + "salary", + "dollar-sign" + ), + spacing="3", + width="100%", + ), + width="100%", + direction="column", + spacing="3", + ), + rx.flex( + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ), + ), + rx.form.submit( + rx.dialog.close( + rx.button("Submit Customer"), + ), + as_child=True, + ), + padding_top="2em", + spacing="3", + mt="4", + justify="end", + ), + on_submit=State.add_customer_to_db, + reset_on_submit=False, + ), + width="100%", + direction="column", + spacing="4", + ), + style={"max_width": 450}, + box_shadow="lg", + padding="1.5em", + border=f"2.5px solid {rx.color('accent', 7)}", + border_radius="25px", + ), + ) + + +def update_customer_dialog(user): + return rx.dialog.root( + rx.dialog.trigger( + rx.icon_button( + rx.icon("square-pen", size=22), + color_scheme="green", + size="2", + variant="solid", + on_click=lambda: State.get_user(user), + ), + ), + rx.dialog.content( + rx.hstack( + rx.badge( + rx.icon(tag="square-pen", size=34), + color_scheme="blue", + radius="full", + padding="0.65rem", + ), + rx.vstack( + rx.dialog.title( + "Edit Customer", + weight="bold", + margin="0", + ), + rx.dialog.description( + "Edit the customer's info", + ), + spacing="1", + height="100%", + align_items="start", + ), + height="100%", + spacing="4", + margin_bottom="1.5em", + align_items="center", + width="100%", + ), + rx.flex( + rx.form.root( + rx.flex( + rx.hstack( + # Name + form_field( + "Name", + "Customer Name", + "text", + "customer_name", + "user", + user.customer_name, + ), + # Location + form_field( + "Location", + "Customer Location", + "text", + "location", + "map-pinned", + user.location, + ), + spacing="3", + width="100%", + ), + rx.hstack( + # Email + form_field( + "Email", + "user@reflex.dev", + "email", + "email", + "mail", + user.email, + ), + # Job + form_field( + "Job", + "Customer Job", + "text", + "job", + "briefcase", + user.job, + ), + spacing="3", + width="100%", + ), + # Gender + rx.vstack( + rx.hstack( + rx.icon("user-round", size=16, + stroke_width=1.5), + rx.text("Gender"), + align="center", + spacing="2", + ), + rx.select( + ["Male", "Female", "Other"], + default_value=user.gender, + #placeholder="Select Gender", + name="gender", + direction="row", + as_child=True, + required=True, + width="100%", + ), + width="100%", + ), + rx.hstack( + # Age + form_field( + "Age", + "Customer Age", + "number", + "age", + "person-standing", + user.age.to(str), + ), + # Salary + form_field( + "Salary", + "Customer Salary", + "number", + "salary", + "dollar-sign", + user.salary.to(str), + ), + spacing="3", + width="100%", + ), + direction="column", + spacing="3", + ), + rx.flex( + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ), + ), + rx.form.submit( + rx.dialog.close( + rx.button("Update Customer"), + ), + as_child=True, + ), + padding_top="2em", + spacing="3", + mt="4", + justify="end", + ), + on_submit=State.update_customer_to_db, + reset_on_submit=False, + ), + width="100%", + direction="column", + spacing="4", + ), + style={"max_width": 450}, + box_shadow="lg", + padding="1.5em", + border=f"2px solid {rx.color('accent', 7)}", + border_radius="25px", + ), + ) + + +def main_table(): + return rx.fragment( + rx.flex( + add_customer_button(), + rx.spacer(), + rx.hstack( + rx.cond( + State.sort_reverse, + rx.icon("arrow-down-z-a", size=28, stroke_width=1.5, cursor="pointer", on_click=State.toggle_sort), + rx.icon("arrow-down-a-z", size=28, stroke_width=1.5, cursor="pointer", on_click=State.toggle_sort), + ), + rx.select( + ["customer_name", "email", "age", "gender", + "location", "job", "salary"], + placeholder="Sort By: Name", + size="3", + on_change=lambda sort_value: State.sort_values(sort_value), + ), + rx.input( + placeholder="Search here...", + size="3", + on_change=lambda value: State.filter_values(value), + ), + spacing="3", + align="center", + ), + spacing="3", + wrap="wrap", + width="100%", + padding_bottom="1em", + ), + rx.table.root( + rx.table.header( + rx.table.row( + _header_cell("Name", "square-user-round"), + _header_cell("Email", "mail"), + _header_cell("Age", "person-standing"), + _header_cell("Gender", "user-round"), + _header_cell("Location", "map-pinned"), + _header_cell("Job", "briefcase"), + _header_cell("Salary", "dollar-sign"), + _header_cell("Actions", "cog"), + ), + ), + rx.table.body(rx.foreach(State.users, show_customer)), + variant="surface", + size="3", + width="100%", + ), + ) diff --git a/sales/sales/views/navbar.py b/sales/sales/views/navbar.py new file mode 100644 index 00000000..69639570 --- /dev/null +++ b/sales/sales/views/navbar.py @@ -0,0 +1,27 @@ +import reflex as rx + + +def navbar(): + return rx.flex( + rx.badge( + rx.icon(tag="mails", size=28), + rx.heading("Personalized Sales", size="6"), + radius="large", + align="center", + color_scheme="blue", + variant="surface", + padding="0.65rem", + ), + rx.spacer(), + rx.hstack( + rx.logo(), + rx.color_mode.button(), + align="center", + spacing="3", + ), + spacing="2", + flex_direction=["column", "column", "row"], + align="center", + width="100%", + top="0px", + )