diff --git a/counter/models.py b/counter/models.py index 48bb841f..b290d0a0 100644 --- a/counter/models.py +++ b/counter/models.py @@ -327,6 +327,8 @@ class ProductType(OrderedModel): class Product(models.Model): """A product, with all its related information.""" + QUANTITY_FOR_TRAY_PRICE = 6 + name = models.CharField(_("name"), max_length=64) description = models.TextField(_("description"), default="") product_type = models.ForeignKey( @@ -426,6 +428,13 @@ class Product(models.Model): def profit(self): return self.selling_price - self.purchase_price + def get_actual_price(self, counter: Counter, customer: Customer): + """Return the price of the article taking into account if the customer has a special price + or not in the counter it's being purchased on""" + if counter.customer_is_barman(customer): + return self.special_selling_price + return self.selling_price + class CounterQuerySet(models.QuerySet): def annotate_has_barman(self, user: User) -> Self: diff --git a/counter/static/bundled/counter/components/counter-product-select-index.ts b/counter/static/bundled/counter/components/counter-product-select-index.ts index 20aa23a5..aecdaa03 100644 --- a/counter/static/bundled/counter/components/counter-product-select-index.ts +++ b/counter/static/bundled/counter/components/counter-product-select-index.ts @@ -15,6 +15,10 @@ export class CounterProductSelect extends AutoCompleteSelectBase { return ["FIN", "ANN"]; } + public getSelectedProduct(): [number, string] { + return parseProduct(this.widget.getValue() as string); + } + protected attachBehaviors(): void { this.allowMultipleProducts(); } diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index ef8ba2db..bd82f6ca 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -1,24 +1,47 @@ import { exportToHtml } from "#core:utils/globals"; -import type TomSelect from "tom-select"; + +const quantityForTrayPrice = 6; interface CounterConfig { csrfToken: string; clickApiUrl: string; - sessionBasket: Record; customerBalance: number; customerId: number; + products: Record; } -interface BasketItem { - // biome-ignore lint/style/useNamingConvention: talking with python - bonus_qty: number; + +interface Product { + code: string; + name: string; price: number; - qty: number; + hasTrayPrice: boolean; +} + +class BasketItem { + quantity: number; + product: Product; + + constructor(product: Product, quantity: number) { + this.quantity = quantity; + this.product = product; + } + + getBonusQuantity(): number { + if (!this.product.hasTrayPrice) { + return 0; + } + return Math.floor(this.quantity / quantityForTrayPrice); + } + + sum(): number { + return (this.quantity - this.getBonusQuantity()) * this.product.price; + } } exportToHtml("loadCounter", (config: CounterConfig) => { document.addEventListener("alpine:init", () => { Alpine.data("counter", () => ({ - basket: config.sessionBasket, + basket: {} as Record, errors: [], customerBalance: config.customerBalance, codeField: undefined, @@ -26,17 +49,58 @@ exportToHtml("loadCounter", (config: CounterConfig) => { init() { this.codeField = this.$refs.codeField; this.codeField.widget.focus(); + // It's quite tricky to manually apply attributes to the management part + // of a formset so we dynamically apply it here + this.$refs.basketManagementForm + .querySelector("#id_form-TOTAL_FORMS") + .setAttribute(":value", "getBasketSize()"); + }, + + getItemIdFromCode(code: string): string { + return Object.keys(config.products).find( + (key) => config.products[key].code === code, + ); + }, + + removeFromBasket(code: string) { + delete this.basket[this.getItemIdFromCode(code)]; + }, + + addToBasket(code: string, quantity: number): [boolean, string] { + const id = this.getItemIdFromCode(code); + const item: BasketItem = + this.basket[id] || new BasketItem(config.products[id], 0); + + const oldQty = item.quantity; + item.quantity += quantity; + + if (item.quantity <= 0) { + delete this.basket[id]; + return [true, ""]; + } + + if (item.sum() > this.customerBalance) { + item.quantity = oldQty; + return [false, gettext("Not enough money")]; + } + + this.basket[id] = item; + return [true, ""]; + }, + + getBasketSize() { + return Object.keys(this.basket).length; }, sumBasket() { - if (!this.basket || Object.keys(this.basket).length === 0) { + if (this.getBasketSize() === 0) { return 0; } const total = Object.values(this.basket).reduce( - (acc: number, cur: BasketItem) => acc + cur.qty * cur.price, + (acc: number, cur: BasketItem) => acc + cur.sum(), 0, ) as number; - return total / 100; + return total; }, onRefillingSuccess(event: CustomEvent) { @@ -50,33 +114,36 @@ exportToHtml("loadCounter", (config: CounterConfig) => { this.codeField.widget.focus(); }, - async handleCode(event: SubmitEvent) { - const widget: TomSelect = this.codeField.widget; - const code = (widget.getValue() as string).toUpperCase(); - if (this.codeField.getOperationCodes().includes(code)) { - $(event.target).submit(); - } else { - await this.handleAction(event); - } - widget.clear(); - widget.focus(); + finish() { + this.$refs.basketForm.submit(); }, - async handleAction(event: SubmitEvent) { - const payload = $(event.target).serialize(); - const request = new Request(config.clickApiUrl, { - method: "POST", - body: payload, - headers: { - // biome-ignore lint/style/useNamingConvention: this goes into http headers - Accept: "application/json", - "X-CSRFToken": config.csrfToken, - }, + cancel() { + this.basket = new Object({}); + // We need to wait for the templated form to be removed before sending + this.$nextTick(() => { + this.finish(); }); - const response = await fetch(request); - const json = await response.json(); - this.basket = json.basket; - this.errors = json.errors; + }, + + handleCode(event: SubmitEvent) { + const [quantity, code] = this.codeField.getSelectedProduct() as [ + number, + string, + ]; + + if (this.codeField.getOperationCodes().includes(code.toUpperCase())) { + if (code === "ANN") { + this.cancel(); + } + if (code === "FIN") { + this.finish(); + } + } else { + this.addToBasket(code, quantity); + } + this.codeField.widget.clear(); + this.codeField.widget.focus(); }, })); }); diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index 01b38be1..ced83eec 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -34,7 +34,12 @@
{% trans %}Customer{% endtrans %}
{{ user_mini_profile(customer.user) }} {{ user_subscription(customer.user) }} -

{% trans %}Amount: {% endtrans %}

+

{% trans %}Amount: {% endtrans %} € + + + € + +

@@ -72,53 +77,37 @@

{% trans %}Basket: {% endtrans %}

+
+ {% csrf_token %} + {{ form.errors }} + {{ form.non_form_errors() }} +
+ {{ form.management_form }} +
+