From d930122ca69180a3a7adefea4dc015a2de598480 Mon Sep 17 00:00:00 2001 From: MacPapo Date: Thu, 26 Mar 2026 22:14:27 +0100 Subject: [PATCH 01/34] Big UI update --- .../members/searches_controller.rb | 7 + app/controllers/sales_controller.rb | 50 ++- app/javascript/application.js | 1 - .../controllers/autocomplete_controller.js | 62 +++ .../controllers/autosubmit_controller.js | 57 +++ .../controllers/language_controller.js | 1 + .../controllers/modal_controller.js | 17 - .../controllers/sales_form_controller.js | 112 ----- .../controllers/search_form_controller.js | 20 - .../subscription_edit_controller.js | 1 + .../controllers/theme_controller.js | 1 + app/javascript/morph-fix.js | 17 - app/javascript/utils/debounce.js | 7 + app/models/concerns/subscription_issuer.rb | 39 +- app/models/sale.rb | 67 ++- app/views/layouts/pos.html.erb | 36 ++ app/views/members/searches/index.html.erb | 25 ++ app/views/sales/_form.html.erb | 401 +++++++----------- app/views/sales/new.html.erb | 17 +- config/importmap.rb | 3 +- config/routes.rb | 8 + 21 files changed, 469 insertions(+), 480 deletions(-) create mode 100644 app/controllers/members/searches_controller.rb create mode 100644 app/javascript/controllers/autocomplete_controller.js create mode 100644 app/javascript/controllers/autosubmit_controller.js delete mode 100644 app/javascript/controllers/modal_controller.js delete mode 100644 app/javascript/controllers/sales_form_controller.js delete mode 100644 app/javascript/controllers/search_form_controller.js delete mode 100644 app/javascript/morph-fix.js create mode 100644 app/javascript/utils/debounce.js create mode 100644 app/views/layouts/pos.html.erb create mode 100644 app/views/members/searches/index.html.erb diff --git a/app/controllers/members/searches_controller.rb b/app/controllers/members/searches_controller.rb new file mode 100644 index 0000000..3ecc0d4 --- /dev/null +++ b/app/controllers/members/searches_controller.rb @@ -0,0 +1,7 @@ +class Members::SearchesController < ApplicationController + def index + @members = params[:query].present? ? Member.search_text(params[:query]).limit(10) : Member.none + + render layout: false + end +end diff --git a/app/controllers/sales_controller.rb b/app/controllers/sales_controller.rb index 4a7c38e..8b88f5f 100644 --- a/app/controllers/sales_controller.rb +++ b/app/controllers/sales_controller.rb @@ -2,10 +2,13 @@ class SalesController < ApplicationController before_action :require_admin, only: [ :index ] before_action :set_sale, only: [ :show, :destroy ] + # 1. IL LAYOUT: Diciamo a Rails di usare il layout "pos" invece di quello standard + layout "pos", only: [ :new, :create ] + def index scope = Sale.kept - .includes(:member, :user) - .order(sold_on: :desc, created_at: :desc) + .includes(:member, :user) + .order(sold_on: :desc, created_at: :desc) @pagy, @sales = pagy(scope) end @@ -23,14 +26,21 @@ def show end def new - @sale = Sale.new(sold_on: Date.current, user: current_user) - @sale.build_subscription(start_date: Date.current) + # 2. Inizializziamo la vendita. sale_params_for_build passa i dati se stiamo + # facendo autosubmit, altrimenti passa un hash vuoto al primo caricamento. + @sale = Sale.new(sale_params_for_build) - if params[:member_id] - @sale.member = Member.find(params[:member_id]) - end + # Valori di default + @sale.user = current_user + @sale.sold_on ||= Date.current + @sale.member_id ||= params[:member_id] - setup_renewal_data if params[:renew_subscription_id] + # 3. LA MAGIA: Chiediamo al modello di autoconfigurarsi. + # Il controller non sa NULLA di come si calcolano prezzi o date. + @sale.prepare_draft( + renew_subscription_id: params[:renew_subscription_id], + manual_start_date: params.dig(:sale, :subscription_attributes, :start_date) + ) end def create @@ -40,7 +50,11 @@ def create if @sale.save redirect_to sale_path(@sale), notice: t(".created", default: "Vendita registrata con successo.") else - @sale.build_subscription(start_date: Date.current) if @sale.subscription.nil? + # Se c'è un errore di validazione, dobbiamo ri-preparare la bozza per + # assicurarci che la UI abbia i calcoli live corretti prima di renderizzare + @sale.prepare_draft( + manual_start_date: params.dig(:sale, :subscription_attributes, :start_date) + ) render :new, status: :unprocessable_entity end end @@ -54,22 +68,16 @@ def destroy end private + def set_sale @sale = Sale.find(params[:id]) end - def setup_renewal_data - return unless @sale.member && params[:renew_subscription_id] - - old_sub = @sale.member.subscriptions.find(params[:renew_subscription_id]) - - @sale.product = old_sub.product - @sale.amount = old_sub.product.price || 0 - - dates = RenewalCalculator.new(@sale.member, @sale.product, Date.current).call - - @sale.subscription.start_date = dates[:start_date] - @sale.subscription.end_date = dates[:end_date] + # Questo ci serve perché la action `new` ora viene chiamata in due modi: + # 1. Link normale (params[:sale] non esiste -> solleverebbe errore con `require`) + # 2. Autosubmit di Turbo (params[:sale] esiste e vogliamo i dati) + def sale_params_for_build + params.has_key?(:sale) ? sale_params : {} end def sale_params diff --git a/app/javascript/application.js b/app/javascript/application.js index 9ef7dcf..0d7b494 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,4 +1,3 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails" import "controllers" -import "morph-fix" diff --git a/app/javascript/controllers/autocomplete_controller.js b/app/javascript/controllers/autocomplete_controller.js new file mode 100644 index 0000000..ccd6944 --- /dev/null +++ b/app/javascript/controllers/autocomplete_controller.js @@ -0,0 +1,62 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "hidden", "frame"] + + connect() { + this.clickOutsideHandler = this.clickOutside.bind(this) + document.addEventListener("click", this.clickOutsideHandler) + } + + disconnect() { + document.removeEventListener("click", this.clickOutsideHandler) + } + + select(event) { + event.preventDefault() + + // 1. Estraiamo i dati dal bottone cliccato + const button = event.currentTarget + const id = button.dataset.id + const name = button.dataset.name + + // 2. Aggiorniamo i campi + this.hiddenTarget.value = id + this.inputTarget.value = name + + // 3. Chiudiamo la tendina (svuotando il frame) + this.closeFrame() + + // 4. UX: Togliamo il focus dall'input (ottimo per nascondere la tastiera su mobile) + this.inputTarget.blur() + + // 5. IL TOCCO MAGICO: Inneschiamo l'autosubmit! + // Lanciamo un evento 'change' sul campo nascosto, che verrà intercettato da autosubmit + this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true })) + } + + clearIfEmpty() { + if (this.inputTarget.value.trim() === "") { + + if (this.hiddenTarget.value !== "") { + this.hiddenTarget.value = "" + this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true })) + } + + this.closeFrame() + } + } + + closeFrame() { + if (this.hasFrameTarget) { + this.frameTarget.innerHTML = "" + this.frameTarget.removeAttribute("src") + } + } + + clickOutside(event) { + if (!this.element.contains(event.target)) { + this.closeFrame() + } + } +} diff --git a/app/javascript/controllers/autosubmit_controller.js b/app/javascript/controllers/autosubmit_controller.js new file mode 100644 index 0000000..8501956 --- /dev/null +++ b/app/javascript/controllers/autosubmit_controller.js @@ -0,0 +1,57 @@ +import { Controller } from "@hotwired/stimulus" +import { debounce } from "utils/debounce" + +// Connects to data-controller="autosubmit" +export default class extends Controller { + connect() { + this.submitHandler = debounce(this.submitHandler.bind(this), 300) + } + + submit(event) { + const clearSelector = event.params?.clear + + if (clearSelector) { + document.querySelectorAll(clearSelector).forEach(element => { + element.value = "" + }) + } + + this.submitHandler() + } + + prevent(event) { + event.preventDefault() + } + + submitHandler() { + if (this.element.tagName === "FORM") { + this.element.requestSubmit() + return + } + + const input = this.element + const frameId = input.dataset.frameId + const urlString = input.dataset.url + const query = input.value.trim() + + if (!frameId || !urlString) return + + const frame = document.getElementById(frameId) + + if (query.length < 2) { + if (frame) { + frame.innerHTML = "" + frame.removeAttribute("src") + } + return + } + + const url = new URL(urlString, window.location.origin) + url.searchParams.set("query", query) + url.searchParams.set("frame_id", frameId) + + if (frame) { + frame.src = url.toString() + } + } +} diff --git a/app/javascript/controllers/language_controller.js b/app/javascript/controllers/language_controller.js index 61c3a45..75e952e 100644 --- a/app/javascript/controllers/language_controller.js +++ b/app/javascript/controllers/language_controller.js @@ -1,3 +1,4 @@ +// TODO delete import { Controller } from "@hotwired/stimulus" // Connects to data-controller="language" diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js deleted file mode 100644 index 1379344..0000000 --- a/app/javascript/controllers/modal_controller.js +++ /dev/null @@ -1,17 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - connect() { - } - - close(event) { - event.preventDefault() - - const modalFrame = document.getElementById("modal") - - if (modalFrame) { - modalFrame.removeAttribute("src") - modalFrame.innerHTML = "" - } - } -} diff --git a/app/javascript/controllers/sales_form_controller.js b/app/javascript/controllers/sales_form_controller.js deleted file mode 100644 index 78dcc92..0000000 --- a/app/javascript/controllers/sales_form_controller.js +++ /dev/null @@ -1,112 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = [ - "productSelect", "memberSelect", "amountInput", "totalDisplay", - "startDateInput", "startDateDisplay", "endDateDisplay", "statusDisplay" - ] - - connect() { - this.updateTotalDisplay() - - if (this.hasMemberSelectTarget && this.memberSelectTarget.value && - this.hasProductSelectTarget && this.productSelectTarget.value) { - - this.refreshData() - } - } - - refreshData(event) { - // Se non ho selezionato nulla, esco - if (!this.productSelectTarget.value || !this.memberSelectTarget.value) return; - - this.updatePrice() - this.fetchRenewalDates(event) - } - - updatePrice() { - const selectedOption = this.productSelectTarget.selectedOptions[0] - if (!selectedOption) return - - const price = selectedOption.dataset.price - if (price) { - this.amountInputTarget.value = parseFloat(price).toFixed(2).replace('.', ',') - this.updateTotalDisplay() - } - } - - updateTotalDisplay() { - const value = this.amountInputTarget.value.replace(',', '.') - const number = parseFloat(value) - this.totalDisplayTarget.textContent = isNaN(number) ? "0,00" : number.toFixed(2).replace('.', ',') - } - - async fetchRenewalDates(event) { - const memberId = this.memberSelectTarget.value - const productId = this.productSelectTarget.value - - // La data che TU hai scritto a mano - const currentInputVal = this.startDateInputTarget.value - - if (!memberId || !productId) return - - // Capiamo se sei stato TU a scatenare l'evento modificando la data - const userChangedDate = (event && event.target === this.startDateInputTarget) - - this.statusDisplayTarget.textContent = "Calcolo..." - this.statusDisplayTarget.className = "text-center text-xs opacity-50 italic" - - let url = `/members/${memberId}/renewal_info?product_id=${productId}` - - // Inviamo sempre la tua data corrente come riferimento - if (currentInputVal) { - url += `&ref_date=${currentInputVal}` - } - - try { - const response = await fetch(url, { headers: { "Accept": "application/json" } }) - if (response.ok) { - const data = await response.json() - - // --- ZONA FIX --- - - if (userChangedDate) { - // 🛑 STOP! L'hai cambiata tu. - // IGNORIAMO data.start_date del server. - // Non tocchiamo this.startDateInputTarget.value - - // Aggiorniamo solo il display testuale per coerenza visiva - if (this.hasStartDateDisplayTarget) { - this.startDateDisplayTarget.textContent = this.formatDateIT(currentInputVal) - } - } else { - // ✅ OK. Hai cambiato prodotto o socio. - // Qui accettiamo il suggerimento del server. - if (this.hasStartDateInputTarget && data.start_date) { - this.startDateInputTarget.value = data.start_date - } - if (this.hasStartDateDisplayTarget) { - this.startDateDisplayTarget.textContent = this.formatDateIT(data.start_date) - } - } - - // La data di FINE invece ci serve sempre dal server (perché calcola la durata) - if (this.hasEndDateDisplayTarget) { - this.endDateDisplayTarget.textContent = this.formatDateIT(data.end_date) - } - - this.statusDisplayTarget.textContent = "Aggiornato" - this.statusDisplayTarget.className = "text-center text-xs text-success font-bold" - } - } catch (e) { - console.error(e) - this.statusDisplayTarget.textContent = "Errore" - } - } - - formatDateIT(dateString) { - if (!dateString) return "---" - const [year, month, day] = dateString.split("-") - return `${day}/${month}/${year}` - } -} diff --git a/app/javascript/controllers/search_form_controller.js b/app/javascript/controllers/search_form_controller.js deleted file mode 100644 index 556e9b7..0000000 --- a/app/javascript/controllers/search_form_controller.js +++ /dev/null @@ -1,20 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="search-form" -export default class extends Controller { - connect() { - console.log("Form Search Connected!") - } - - search() { - clearTimeout(this.timeout) - - this.timeout = setTimeout(() => { - this.element.requestSubmit() - }, 300) - } - - submit() { - this.element.requestSubmit() - } -} diff --git a/app/javascript/controllers/subscription_edit_controller.js b/app/javascript/controllers/subscription_edit_controller.js index e5cc134..b14b2f8 100644 --- a/app/javascript/controllers/subscription_edit_controller.js +++ b/app/javascript/controllers/subscription_edit_controller.js @@ -1,3 +1,4 @@ +// TODO delete import { Controller } from "@hotwired/stimulus" export default class extends Controller { diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index ff5d680..9e93a62 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -1,3 +1,4 @@ +// TODO delete import { Controller } from "@hotwired/stimulus" // Connects to data-controller="theme" diff --git a/app/javascript/morph-fix.js b/app/javascript/morph-fix.js deleted file mode 100644 index db86adc..0000000 --- a/app/javascript/morph-fix.js +++ /dev/null @@ -1,17 +0,0 @@ -document.addEventListener("turbo:frame-missing", (event) => { - if (event.target.id === "modal") { - const response = event.detail.response; - event.preventDefault(); - - if (response.ok && response.status < 400) { - event.detail.visit(response.url, { action: "replace" }); - } else { - event.target.innerHTML = ` -
-

- An error occurred while loading the modal. Please try again later. -

-
`; - } - } -}); diff --git a/app/javascript/utils/debounce.js b/app/javascript/utils/debounce.js new file mode 100644 index 0000000..ef6a328 --- /dev/null +++ b/app/javascript/utils/debounce.js @@ -0,0 +1,7 @@ +export function debounce(func, wait = 300) { + let timeout; + return function(...args) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; +} diff --git a/app/models/concerns/subscription_issuer.rb b/app/models/concerns/subscription_issuer.rb index 5c894ca..931c80d 100644 --- a/app/models/concerns/subscription_issuer.rb +++ b/app/models/concerns/subscription_issuer.rb @@ -1,3 +1,4 @@ +# app/models/concerns/subscription_issuer.rb module SubscriptionIssuer extend ActiveSupport::Concern @@ -10,8 +11,6 @@ module SubscriptionIssuer after_discard :discard_subscription after_undiscard :undiscard_subscription - before_validation :apply_smart_renewal_policy, on: :create - validate :require_active_membership_for_courses, on: :create end @@ -26,40 +25,10 @@ def undiscard_subscription def require_active_membership_for_courses return if product.nil? || product.associative? - return unless subscription # Evita crash se sub non c'è - - # Usiamo la start_date effettiva della subscription - check_date = subscription.start_date - - unless member.membership_valid?(check_date) - errors.add(:base, "Impossibile vendere #{product.name}: Il socio non avrà una Quota Associativa attiva il #{I18n.l(check_date)}.") - end - end + return unless subscription && subscription.start_date - def apply_smart_renewal_policy - return unless subscription.present? - - subscription.member = member - subscription.product = product - - # CASO 1: L'operatore ha forzato una data di inizio (MANUAL OVERRIDE) - if subscription.start_date.present? - # Se manca la fine, la calcoliamo basandoci sulla data manuale - if subscription.end_date.blank? - duration_result = Duration.new(product, subscription.start_date).calculate - subscription.end_date = duration_result[:end_date] - end - # STOP! Non chiamare RenewalCalculator, l'umano ha deciso. - return + unless member.membership_valid?(subscription.start_date) + errors.add(:base, "Impossibile vendere #{product.name}: Il socio non avrà una Quota Associativa attiva il #{I18n.l(subscription.start_date)}.") end - - # CASO 2: Nessuna data inserita -> AUTOMATISMO (Smart Renewal) - ref_date = sold_on || Date.current - - # Qui chiamiamo il "Genio" che decide start e end - result = RenewalCalculator.new(member, product, ref_date).call - - subscription.start_date = result[:start_date] - subscription.end_date = result[:end_date] end end diff --git a/app/models/sale.rb b/app/models/sale.rb index 6a5961e..1df44aa 100644 --- a/app/models/sale.rb +++ b/app/models/sale.rb @@ -1,3 +1,4 @@ +# app/models/sale.rb class Sale < ApplicationRecord include SubscriptionIssuer, FiscalLockable, Monetizable, Trackable, SoftDeletable @@ -7,6 +8,10 @@ class Sale < ApplicationRecord belongs_to :user belongs_to :product + # --- LE DUE RIGHE FONDAMENTALI PER IL FORM --- + has_one :subscription, dependent: :destroy + accepts_nested_attributes_for :subscription + enum :payment_method, { cash: 1, credit_card: 2, @@ -19,10 +24,71 @@ class Sale < ApplicationRecord validates :member, :user, :product, presence: true validates :receipt_sequence, presence: true + # CALLBACKS (L'ordine è importante!) before_validation :snapshot_product_details + before_validation :sync_subscription_data # Sincronizza i dati prima di validare before_validation :assign_receipt_number, on: :create + # --- LOGICA PER IL FORM LIVE (GET /sales/new) --- + def prepare_draft(options = {}) + build_subscription unless subscription + + if options[:renew_subscription_id].present? && member.present? + old_sub = member.subscriptions.find_by(id: options[:renew_subscription_id]) + if old_sub + self.product = old_sub.product + self.amount = product&.price if amount.blank? || amount.zero? + end + end + + if product_id.present? + self.amount = product.price if amount.blank? || amount.zero? + end + + apply_live_dates(options[:manual_start_date]) + end + private + + # --- IL FIX PER IL SALVATAGGIO (POST /sales) --- + def sync_subscription_data + return unless subscription.present? && member.present? && product.present? + + # 1. Passa le associazioni dalla Sale alla Subscription + subscription.member ||= self.member + subscription.product ||= self.product + + # 2. Se dal form arriva solo la start_date, calcoliamo la end_date al volo + if subscription.start_date.present? && subscription.end_date.blank? + # Usiamo la tua classe Duration per calcolare la fine in base al prodotto + result = Duration.new(self.product, subscription.start_date).calculate + subscription.end_date = result[:end_date] if result.present? + end + end + + # --- LOGICA CALCOLO DATE LIVE (Per prepare_draft) --- + def apply_live_dates(manual_start_date) + return unless member && product + + if manual_start_date.present? + parsed_date = Date.parse(manual_start_date) rescue Date.current + subscription.start_date = parsed_date + + result = Duration.new(product, parsed_date).calculate + subscription.end_date = result[:end_date] if result.present? + return + end + + ref_date = sold_on || Date.current + result = RenewalCalculator.new(member, product, ref_date).call + + if result.present? + subscription.start_date = result[:start_date] + subscription.end_date = result[:end_date] + end + end + + # --- SNAPSHOT & FISCALE --- def snapshot_product_details return unless product.present? @@ -37,7 +103,6 @@ def snapshot_product_details def assign_receipt_number return unless cash? - return if receipt_number.present? && receipt_year.present? self.receipt_year ||= sold_on&.year || Date.current.year diff --git a/app/views/layouts/pos.html.erb b/app/views/layouts/pos.html.erb new file mode 100644 index 0000000..a6cc756 --- /dev/null +++ b/app/views/layouts/pos.html.erb @@ -0,0 +1,36 @@ + + + + Cassa | <%= content_for(:title) || "Active Core" %> + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%# I meta tag magici per blindare la history del browser e disattivare la BFCache %> + + + <%= turbo_refreshes_with method: :morph, scroll: :preserve %> + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <%# Navbar minimal (solo logo e pulsante indietro) %> + + + <%= render "shared/flash" %> + + <%# Contenitore principale espanso %> +
+
+ <%= yield %> +
+
+ + diff --git a/app/views/members/searches/index.html.erb b/app/views/members/searches/index.html.erb new file mode 100644 index 0000000..34a97d1 --- /dev/null +++ b/app/views/members/searches/index.html.erb @@ -0,0 +1,25 @@ + + <% if @members.any? %> + + <% else %> +
+ Nessun socio trovato con questa ricerca. +
+ <% end %> +
diff --git a/app/views/sales/_form.html.erb b/app/views/sales/_form.html.erb index a359d95..6b21333 100644 --- a/app/views/sales/_form.html.erb +++ b/app/views/sales/_form.html.erb @@ -1,283 +1,180 @@ -<%# Data Controller: Colleghiamo il form al controller Stimulus 'sales-form' %> +<%# app/views/sales/_form.html.erb %> -<%= form_with(model: sale, - class: "grid grid-cols-1 xl:grid-cols-3 gap-6 items-start", - data: { controller: "sales-form" }) do |f| %> - <%# --- COLONNA SINISTRA (2/3) --- %> -
- <%= render "shared/form_errors", model: sale %> +<%# 1. WRAPPIAMO TUTTO NEL TURBO FRAME PER NASCONDERE L'URL %> + - <%# --- SEZIONE 1: CHI E COSA --- %> -
- - <%= icon("person_add", size: 14) %> Chi e Cosa - - - <%# 1. SELEZIONE SOCIO %> -
- <% if sale.member %> - <%# CASO A: Socio già deciso (es. arrivi dal profilo) %> - <%# Mettiamo il target sull'hidden field così il JS può leggere l'ID se serve %> - <%= f.hidden_field :member_id, data: { sales_form_target: "memberSelect" } %> -
-
-
-
- - <%= sale.member.initials %> - -
-
- -
-
- <%= sale.member.full_name %> -
+ <%= form_with model: sale, + url: new_sale_path, + method: :get, + data: { + controller: "autosubmit" + # Tolto turbo_action: "replace", ora ci pensa il frame! + }, + class: "grid grid-cols-1 lg:grid-cols-3 gap-8" do |f| %> + + <%= hidden_field_tag :renew_subscription_id, params[:renew_subscription_id] if params[:renew_subscription_id] %> + +
+ <%= render "shared/form_errors", model: sale %> + +
+
+

Anagrafica Cliente

+ +
+
+ <%= f.hidden_field :member_id, + data: { + autocomplete_target: "hidden", + action: "change->autosubmit#submit", + # ECCO IL PATTERN DHH: + autosubmit_clear_param: "#sale_subscription_attributes_start_date" + } %> -
- <%= sale.member.fiscal_code %> -
-
+ + +
- - <%= link_to "Cambia", members_path(q: "selection"), class: "btn btn-sm btn-ghost text-error" %>
- <% else %> - <%# CASO B: Selezione Socio manuale %> - <%= f.label :member_id, "Socio Acquirente", class: "label" %> - <%= f.collection_select :member_id, - Member.kept.order(:last_name), :id, :full_name, - { prompt: "Seleziona Socio..." }, - { - class: "select select-bordered w-full", - required: true, - data: { - sales_form_target: "memberSelect", - action: "change->sales-form#refreshData" - } - } %> - <% end %> -
- -
- - <%# 2. SELEZIONE PRODOTTO (RAGGRUPPATO PER DISCIPLINA) %> -
-
- <%= f.label :product_id, "Prodotto / Servizio", class: "label" %> - - <% products = Product.kept.includes(:disciplines).order(:name) - grouped_hash = products.group_by { |p| p.disciplines.first&.name || "Generale" } - sorted_groups = grouped_hash.sort_by { |name, _| name } - grouped_options = sorted_groups.map do |discipline_name, group_products| - [ - discipline_name, - group_products.map { |p| - [ - p.name, - p.id, - { - 'data-price': p.price.to_s, - 'data-duration': p.duration_days.to_s, - } - ] - } - ] - end %> - - <%= f.select :product_id, - grouped_options_for_select(grouped_options, sale.product_id), - { prompt: "Seleziona Prodotto..." }, - { - class: "select select-bordered w-full font-medium", - required: true, - data: { - sales_form_target: "productSelect", - action: "change->sales-form#refreshData" - } - } %> -
- - <%# 3. DATA INIZIO VALIDITÀ (Annidata) %> -
- <%= f.fields_for :subscription do |sub_form| %> - <%= sub_form.label :start_date, class: "label" do %> - Inizio Validità - Auto-calcolata - <% end %> - - <%# Qui il JS scriverà la data suggerita %> - <%= sub_form.date_field :start_date, - class: "input w-full", - data: { - sales_form_target: "startDateInput", - action: "change->sales-form#refreshData" - } %> - <% end %>
-
- - <%# --- SEZIONE 2: ECONOMIA & PAGAMENTO --- %> -
- - <%= icon("payments", size: 14) %> Transazione - -
- <%# IMPORTO %> -
- <%= f.label :amount, "Importo Finale (€)", class: "label" %> +
+
+

Dettagli Acquisto

-
- <%# METODO DI PAGAMENTO %> -
- <%= f.label :payment_method, "Metodo di Pagamento", class: "label" %> +
+ <%= f.label :sold_on, "Data Contabile", class: "label font-semibold" %> + <%# FORZIAMO IL VALORE: %> + <%= f.date_field :sold_on, + value: sale.sold_on, + class: "input input-bordered w-full", + data: { action: "change->autosubmit#submit" } %> +
-
- <% Sale.payment_methods.keys.each_with_index do |method, index| %> -
- <%# NOTE %> -
- - -
- Aggiungi Note (Opzionale) -
- -
- <%= f.text_area :notes, class: "textarea textarea-bordered w-full h-20" %> +
+
+
+

Validità Iscrizione

+
Calcolo Smart
+
+

+ Le date vengono calcolate in base al prodotto e allo storico del socio. +

+ + <%= f.fields_for :subscription do |sub_f| %> +
+
+ <%= sub_f.label :start_date, "Decorrenza", class: "label font-semibold" %> + <%# FORZIAMO IL VALORE: %> + <%= sub_f.date_field :start_date, + value: sale.subscription&.start_date, + class: "input input-bordered w-full", + data: { action: "change->autosubmit#submit" } %> +
+
+ + +
+
+ <% end %>
-
-
- - <%# --- COLONNA DESTRA: RIEPILOGO VISIVO --- %> -
-
-
-

- <%= icon("receipt", size: 20) %> Riepilogo -

+
- <%# TOTALE VISIVO %> -
-
- Totale da Incassare -
+
+
+
+

Scontrino Live

+ +
+
+ Socio: + + <%= sale.member&.full_name_ruby || "Nessuno" %> + +
-
- 0,00 - -
-
+
+ Prodotto: + + <%= sale.product&.name || "Nessuno" %> + +
- <%# --- NUOVO BLOCCO DATE --- %> -
-
- Inizio +
+ Decorrenza: + <%= sale.subscription&.start_date ? I18n.l(sale.subscription.start_date) : "..." %> +
- - --- - -
+
+ Scadenza: + <%= sale.subscription&.end_date ? I18n.l(sale.subscription.end_date) : "..." %> +
-
- Scadenza +
- - --- - +
+ Totale + + € <%= number_with_precision(sale.amount || 0, precision: 2) %> + +
-
- -
- Seleziona un prodotto... +
+ <%# IMPORTANTE: Aggiunto data: { turbo_frame: "_top" } %> + <%= f.button "Conferma e Incassa", + class: "btn btn-primary w-full btn-lg font-bold text-lg", + formmethod: "post", + formaction: sales_path, + data: { turbo_frame: "_top" }, + disabled: sale.product.nil? || sale.member.nil? %>
- - <%# ------------------------ %> - -
- <%= button_tag type: "submit", class: "btn btn-primary w-full shadow-lg h-12 text-lg" do %> - <%= icon("success", size: 22) %> Registra Pagamento - <% end %> - - <%= link_to "Annulla", :back, class: "btn btn-ghost btn-sm w-full text-base-content/60" %> -
-
-<% end %> + <% end %> + + diff --git a/app/views/sales/new.html.erb b/app/views/sales/new.html.erb index 7d4d454..8a4702b 100644 --- a/app/views/sales/new.html.erb +++ b/app/views/sales/new.html.erb @@ -1,3 +1,16 @@ -<%= render layout: "shared/modal", locals: { title: t(".title", default: "Nuova Vendita"), size: :large } do %> +<%# app/views/sales/new.html.erb %> +
+
+
+

Cassa / Nuova Vendita

+ <% if @sale.member %> +

+ Stai operando sul socio: <%= @sale.member.full_name_ruby %> +

+ <% end %> +
+ <%= link_to "Torna indietro", sales_path, class: "btn btn-ghost" %> +
+ <%= render "form", sale: @sale %> -<% end %> +
diff --git a/config/importmap.rb b/config/importmap.rb index 71f010b..0f2de34 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -5,5 +5,4 @@ pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" - -pin "morph-fix", to: "morph-fix.js" +pin_all_from "app/javascript/utils", under: "utils" diff --git a/config/routes.rb b/config/routes.rb index b6fd404..ad3bca0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,14 @@ resource :session resources :passwords, param: :token + concern :searchable do + resources :searches, only: [ :index ] + end + + namespace :members do + concerns :searchable + end + # ============================================================================ # 2. ANAGRAFICA (Registry) # ============================================================================ From 63c0eae4cbbddf85f4efc957c7ce3189ae81a8fa Mon Sep 17 00:00:00 2001 From: Jacopo Costantini Date: Fri, 27 Mar 2026 17:41:31 +0100 Subject: [PATCH 02/34] Updated Gems --- Gemfile.lock | 92 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 74 insertions(+), 18 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2a600f3..5ffc5d2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.17) + action_text-trix (2.1.18) railties actioncable (8.1.3) actionpack (= 8.1.3) @@ -81,7 +81,7 @@ GEM base64 (0.3.0) bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) - bigdecimal (4.0.1) + bigdecimal (3.3.1) bindex (0.8.1) bootsnap (1.23.0) msgpack (~> 1.2) @@ -114,8 +114,13 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) ffi (1.17.4-arm64-darwin) ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) @@ -187,26 +192,37 @@ GEM net-protocol net-ssh (7.3.2) nio4r (2.7.5) + nokogiri (1.19.2-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.2-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.2-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.2-arm-linux-musl) + racc (~> 1.4) nokogiri (1.19.2-arm64-darwin) racc (~> 1.4) nokogiri (1.19.2-x86_64-linux-gnu) racc (~> 1.4) + nokogiri (1.19.2-x86_64-linux-musl) + racc (~> 1.4) ostruct (0.6.3) - pagy (43.4.3) + pagy (43.4.4) json uri yaml parallel (1.27.0) - parser (3.3.11.0) + parser (3.3.11.1) ast (~> 2.4.1) racc - pdf-core (0.9.0) + pdf-core (0.10.0) phonelib (0.10.17) pp (0.6.3) prettyprint - prawn (2.4.0) - pdf-core (~> 0.9.0) - ttfunk (~> 1.7) + prawn (2.5.0) + matrix (~> 0.4) + pdf-core (~> 0.10.0) + ttfunk (~> 1.8) prawn-table (0.2.2) prawn (>= 1.3.0, < 3.0.0) prettyprint (0.2.0) @@ -330,8 +346,13 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) + sqlite3 (2.9.2-aarch64-linux-gnu) + sqlite3 (2.9.2-aarch64-linux-musl) + sqlite3 (2.9.2-arm-linux-gnu) + sqlite3 (2.9.2-arm-linux-musl) sqlite3 (2.9.2-arm64-darwin) sqlite3 (2.9.2-x86_64-linux-gnu) + sqlite3 (2.9.2-x86_64-linux-musl) sshkit (1.25.0) base64 logger @@ -345,14 +366,21 @@ GEM tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.2.1) + tailwindcss-ruby (4.2.1-aarch64-linux-gnu) + tailwindcss-ruby (4.2.1-aarch64-linux-musl) tailwindcss-ruby (4.2.1-arm64-darwin) tailwindcss-ruby (4.2.1-x86_64-linux-gnu) + tailwindcss-ruby (4.2.1-x86_64-linux-musl) thor (1.5.0) + thruster (0.1.20) + thruster (0.1.20-aarch64-linux) thruster (0.1.20-arm64-darwin) thruster (0.1.20-x86_64-linux) timeout (0.6.1) tsort (0.2.0) - ttfunk (1.7.0) + ttfunk (1.8.0) + bigdecimal (~> 3.1) turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) @@ -378,8 +406,15 @@ GEM zeitwerk (2.7.5) PLATFORMS - arm64-darwin-25 + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin-24 x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES bcrypt (~> 3.1.7) @@ -414,7 +449,7 @@ DEPENDENCIES web-console CHECKSUMS - action_text-trix (2.1.17) sha256=b44691639d77e67169dc054ceacd1edc04d44dc3e4c6a427aa155a2beb4cc951 + action_text-trix (2.1.18) sha256=3fdb83f8bff4145d098be283cdd47ac41caf5110bfa6df4695ed7127d7fb3642 actioncable (8.1.3) sha256=e5bc7f75e44e6a22de29c4f43176927c3a9ce4824464b74ed18d8226e75a80f0 actionmailbox (8.1.3) sha256=df7da474eaa0e70df4ed5a6fef66eb3b3b0f2dbf7f14518deee8d77f1b4aae59 actionmailer (8.1.3) sha256=831f724891bb70d0aaa4d76581a6321124b6a752cb655c9346aae5479318448d @@ -431,7 +466,7 @@ CHECKSUMS base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 - bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + bigdecimal (3.3.1) sha256=eaa01e228be54c4f9f53bf3cc34fe3d5e845c31963e7fcc5bedb05a4e7d52218 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.23.0) sha256=c1254f458d58558b58be0f8eb8f6eec2821456785b7cdd1e16248e2020d3f214 brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f @@ -449,8 +484,13 @@ CHECKSUMS erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 @@ -480,16 +520,21 @@ CHECKSUMS net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 net-ssh (7.3.2) sha256=65029e213c380e20e5fd92ece663934ab0a0fe888e0cd7cc6a5b664074362dd4 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.2-aarch64-linux-gnu) sha256=c34d5c8208025587554608e98fd88ab125b29c80f9352b821964e9a5d5cfbd19 + nokogiri (1.19.2-aarch64-linux-musl) sha256=7f6b4b0202d507326841a4f790294bf75098aef50c7173443812e3ac5cb06515 + nokogiri (1.19.2-arm-linux-gnu) sha256=b7fa1139016f3dc850bda1260988f0d749934a939d04ef2da13bec060d7d5081 + nokogiri (1.19.2-arm-linux-musl) sha256=61114d44f6742ff72194a1b3020967201e2eb982814778d130f6471c11f9828c nokogiri (1.19.2-arm64-darwin) sha256=58d8ea2e31a967b843b70487a44c14c8ba1866daa1b9da9be9dbdf1b43dee205 nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f + nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8 ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 - pagy (43.4.3) sha256=0fc7608c5314cbd565d7c5d7eaeb3ef38b9c1721ffbe5c70ee7c2cc186604853 + pagy (43.4.4) sha256=b41a57328a0aabfd222266a89e9de3dc3a735c17bd57f8113829c95fece5bef6 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 - parser (3.3.11.0) sha256=fe28ccb92d9221283ce143cb3c2e4c916e0f589ee0cc2a64867d7716354f19b1 - pdf-core (0.9.0) sha256=4f368b2f12b57ec979872d4bf4bd1a67e8648e0c81ab89801431d2fc89f4e0bb + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + pdf-core (0.10.0) sha256=0a5d101e2063c01e3f941e1ee47cbb97f1adfc1395b58372f4f65f1300f3ce91 phonelib (0.10.17) sha256=8c97b6abc1877a8313ef32f9438cd35e24a943f9a70666af85eb69ab81caf4e3 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 - prawn (2.4.0) sha256=82062744f7126c2d77501da253a154271790254dfa8c309b8e52e79bc5de2abd + prawn (2.5.0) sha256=f4e20e3b4f30bf5b9ae37dad15eb421831594553aa930b2391b0fa0a99c43cb6 prawn-table (0.2.2) sha256=336d46e39e003f77bf973337a958af6a68300b941c85cb22288872dc2b36addb prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 @@ -527,20 +572,31 @@ CHECKSUMS solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a + sqlite3 (2.9.2-aarch64-linux-gnu) sha256=eeb86db55645b85327ba75129e3614658d974bf4da8fdc87018a0d42c59f6e42 + sqlite3 (2.9.2-aarch64-linux-musl) sha256=4feff91fb8c2b13688da34b5627c9d1ed9cedb3ee87a7114ec82209147f07a6d + sqlite3 (2.9.2-arm-linux-gnu) sha256=1ee2eb06b5301aaf5ce343a6e88d99ac932d95202d7b350f0e7b6d8d588580d7 + sqlite3 (2.9.2-arm-linux-musl) sha256=8ca0de6aceede968de0394e22e95d549834c4d8e318f69a92a52f049878a0057 sqlite3 (2.9.2-arm64-darwin) sha256=d15bd9609a05f9d54930babe039585efc8cadd57517c15b64ec7dfa75158a5e9 sqlite3 (2.9.2-x86_64-linux-gnu) sha256=dce83ffcb7e72f9f7aeb6e5404f15d277a45332fe18ccce8a8b3ed51e8d23aee + sqlite3 (2.9.2-x86_64-linux-musl) sha256=e8dd906a613f13b60f6d47ae9dda376384d9de1ab3f7e3f2fdf2fd18a871a2d7 sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 tailwindcss-rails (4.4.0) sha256=efa2961351a52acebe616e645a81a30bb4f27fde46cc06ce7688d1cd1131e916 + tailwindcss-ruby (4.2.1) sha256=95886a1e24b42d76792c787d34e47098b53cb3b5a6363845bca4486f52b2e66a + tailwindcss-ruby (4.2.1-aarch64-linux-gnu) sha256=de457ddfc999c6bbbe1a59fbc11eb2168d619f6e0cb72d8d3334d372b331e36f + tailwindcss-ruby (4.2.1-aarch64-linux-musl) sha256=e6ed27704263201f8366316354aa45f9016cc9378ce8fac46fbbe65fafd4da5e tailwindcss-ruby (4.2.1-arm64-darwin) sha256=bcf222fb8542cf5433925623e5e7b257897fbb8291a2350daae870a32f2eeb91 tailwindcss-ruby (4.2.1-x86_64-linux-gnu) sha256=201d0e5e5d4aba52cae4ee4bd1acd497d2790c83e7f15da964aab8ec93876831 + tailwindcss-ruby (4.2.1-x86_64-linux-musl) sha256=79fa48ad51e533545f9fdbb04227e1342a65a42c2bd1314118b95473d5612007 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.20) sha256=c05f2fbcae527bbe093a6e6d84fb12d9d680617e7c162325d9b97e8e9d4b5201 + thruster (0.1.20-aarch64-linux) sha256=754f1701061235235165dde31e7a3bc87ec88066a02981ff4241fcda0d76d397 thruster (0.1.20-arm64-darwin) sha256=630cf8c273f562063b92ea5ccd7a721d7ba6130cc422c823727f4744f6d0770e thruster (0.1.20-x86_64-linux) sha256=d579f252bf67aee6ba6d957e48f566b72e019d7657ba2f267a5db1e4d91d2479 timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f - ttfunk (1.7.0) sha256=2370ba484b1891c70bdcafd3448cfd82a32dd794802d81d720a64c15d3ef2a96 + ttfunk (1.8.0) sha256=a7cbc7e489cc46e979dde04d34b5b9e4f5c8f1ee5fc6b1a7be39b829919d20ca turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 @@ -556,4 +612,4 @@ CHECKSUMS zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd BUNDLED WITH - 4.0.8 + 4.0.9 From e8f92705943bf0794919fa1f9cf35415d24ca09a Mon Sep 17 00:00:00 2001 From: jcostd Date: Fri, 27 Mar 2026 20:06:02 +0100 Subject: [PATCH 03/34] Updated business logic --- app/controllers/sales_controller.rb | 18 +- .../controllers/autocomplete_controller.js | 78 +++--- .../controllers/autosubmit_controller.js | 50 +--- app/models/concerns/fiscal_lockable.rb | 1 - app/models/concerns/subscription_issuer.rb | 8 +- app/models/renewal_calculator.rb | 44 +--- app/models/sale.rb | 68 +---- app/models/subscription.rb | 46 ++-- app/views/sales/_form.html.erb | 245 ++++++------------ .../concerns/subscription_issuer_test.rb | 31 ++- test/models/duration_test.rb | 67 +---- test/models/renewal_calculator_test.rb | 103 ++++---- test/models/sale_test.rb | 82 +++--- test/models/subscription_test.rb | 20 +- 14 files changed, 305 insertions(+), 556 deletions(-) diff --git a/app/controllers/sales_controller.rb b/app/controllers/sales_controller.rb index 8b88f5f..e3739fe 100644 --- a/app/controllers/sales_controller.rb +++ b/app/controllers/sales_controller.rb @@ -26,21 +26,15 @@ def show end def new - # 2. Inizializziamo la vendita. sale_params_for_build passa i dati se stiamo - # facendo autosubmit, altrimenti passa un hash vuoto al primo caricamento. @sale = Sale.new(sale_params_for_build) - - # Valori di default - @sale.user = current_user @sale.sold_on ||= Date.current - @sale.member_id ||= params[:member_id] - # 3. LA MAGIA: Chiediamo al modello di autoconfigurarsi. - # Il controller non sa NULLA di come si calcolano prezzi o date. - @sale.prepare_draft( - renew_subscription_id: params[:renew_subscription_id], - manual_start_date: params.dig(:sale, :subscription_attributes, :start_date) - ) + if params[:previous_product_id] != @sale.product_id.to_s || params[:previous_member_id] != @sale.member_id.to_s + @sale.amount = nil + @sale.subscription.start_date = nil if @sale.subscription + end + + @sale.prepare_draft end def create diff --git a/app/javascript/controllers/autocomplete_controller.js b/app/javascript/controllers/autocomplete_controller.js index ccd6944..d4d6a10 100644 --- a/app/javascript/controllers/autocomplete_controller.js +++ b/app/javascript/controllers/autocomplete_controller.js @@ -1,62 +1,68 @@ import { Controller } from "@hotwired/stimulus" +import { debounce } from "utils/debounce" export default class extends Controller { static targets = ["input", "hidden", "frame"] connect() { - this.clickOutsideHandler = this.clickOutside.bind(this) - document.addEventListener("click", this.clickOutsideHandler) + this.performSearch = debounce(this.performSearch.bind(this), 300) } - disconnect() { - document.removeEventListener("click", this.clickOutsideHandler) + search() { + this.performSearch() } - select(event) { - event.preventDefault() - - // 1. Estraiamo i dati dal bottone cliccato - const button = event.currentTarget - const id = button.dataset.id - const name = button.dataset.name + performSearch() { + const query = this.inputTarget.value.trim() + const urlString = this.inputTarget.dataset.url - // 2. Aggiorniamo i campi - this.hiddenTarget.value = id - this.inputTarget.value = name + if (!urlString) return - // 3. Chiudiamo la tendina (svuotando il frame) - this.closeFrame() + if (query.length < 2) { + this.closeFrame() + return + } - // 4. UX: Togliamo il focus dall'input (ottimo per nascondere la tastiera su mobile) - this.inputTarget.blur() + const url = new URL(urlString, window.location.origin) + url.searchParams.set("query", query) + url.searchParams.set("frame_id", this.frameTarget.id) - // 5. IL TOCCO MAGICO: Inneschiamo l'autosubmit! - // Lanciamo un evento 'change' sul campo nascosto, che verrà intercettato da autosubmit - this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true })) + this.frameTarget.src = url.toString() } - clearIfEmpty() { - if (this.inputTarget.value.trim() === "") { + select(event) { + event.preventDefault() - if (this.hiddenTarget.value !== "") { - this.hiddenTarget.value = "" - this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true })) - } + const button = event.currentTarget + this.hiddenTarget.value = button.dataset.id + this.inputTarget.value = button.dataset.name - this.closeFrame() - } + this.closeFrame() + this.inputTarget.blur() + + this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true })) + } + + clearIfEmpty() { + if (this.inputTarget.value.trim() === "") { + if (this.hiddenTarget.value !== "") { + this.hiddenTarget.value = "" + this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true })) + } + this.closeFrame() + } } closeFrame() { - if (this.hasFrameTarget) { - this.frameTarget.innerHTML = "" - this.frameTarget.removeAttribute("src") - } + if (this.hasFrameTarget) { + this.frameTarget.innerHTML = "" + this.frameTarget.removeAttribute("src") + } } clickOutside(event) { - if (!this.element.contains(event.target)) { - this.closeFrame() - } + if (!this.element.contains(event.target)) { + this.closeFrame() + } } } diff --git a/app/javascript/controllers/autosubmit_controller.js b/app/javascript/controllers/autosubmit_controller.js index 8501956..812bf08 100644 --- a/app/javascript/controllers/autosubmit_controller.js +++ b/app/javascript/controllers/autosubmit_controller.js @@ -1,57 +1,17 @@ +// app/javascript/controllers/autosubmit_controller.js import { Controller } from "@hotwired/stimulus" import { debounce } from "utils/debounce" -// Connects to data-controller="autosubmit" export default class extends Controller { connect() { - this.submitHandler = debounce(this.submitHandler.bind(this), 300) + this.submitHandler = debounce(this.submitForm.bind(this), 300) } submit(event) { - const clearSelector = event.params?.clear - - if (clearSelector) { - document.querySelectorAll(clearSelector).forEach(element => { - element.value = "" - }) - } - - this.submitHandler() + this.submitHandler() } - prevent(event) { - event.preventDefault() - } - - submitHandler() { - if (this.element.tagName === "FORM") { - this.element.requestSubmit() - return - } - - const input = this.element - const frameId = input.dataset.frameId - const urlString = input.dataset.url - const query = input.value.trim() - - if (!frameId || !urlString) return - - const frame = document.getElementById(frameId) - - if (query.length < 2) { - if (frame) { - frame.innerHTML = "" - frame.removeAttribute("src") - } - return - } - - const url = new URL(urlString, window.location.origin) - url.searchParams.set("query", query) - url.searchParams.set("frame_id", frameId) - - if (frame) { - frame.src = url.toString() - } + submitForm() { + this.element.requestSubmit() } } diff --git a/app/models/concerns/fiscal_lockable.rb b/app/models/concerns/fiscal_lockable.rb index 71a6302..f320250 100644 --- a/app/models/concerns/fiscal_lockable.rb +++ b/app/models/concerns/fiscal_lockable.rb @@ -6,7 +6,6 @@ module FiscalLockable end private - def prevent_fiscal_tampering fiscal_attributes = [ :receipt_number, :receipt_year, :receipt_sequence ] diff --git a/app/models/concerns/subscription_issuer.rb b/app/models/concerns/subscription_issuer.rb index 931c80d..fccbb4d 100644 --- a/app/models/concerns/subscription_issuer.rb +++ b/app/models/concerns/subscription_issuer.rb @@ -1,9 +1,6 @@ -# app/models/concerns/subscription_issuer.rb module SubscriptionIssuer extend ActiveSupport::Concern - RENEWAL_GRACE_PERIOD_DAYS = 30 - included do has_one :subscription, dependent: :destroy, inverse_of: :sale accepts_nested_attributes_for :subscription, allow_destroy: true @@ -15,12 +12,13 @@ module SubscriptionIssuer end private + def discard_subscription - subscription&.discard! + subscription.discard! if subscription.present? && !subscription.discarded? end def undiscard_subscription - subscription&.undiscard! + subscription.undiscard! if subscription.present? && subscription.discarded? end def require_active_membership_for_courses diff --git a/app/models/renewal_calculator.rb b/app/models/renewal_calculator.rb index db23d3b..3b96f46 100644 --- a/app/models/renewal_calculator.rb +++ b/app/models/renewal_calculator.rb @@ -1,54 +1,32 @@ class RenewalCalculator GRACE_PERIOD_DAYS = 30 - # Aggiungiamo options con default vuoto - def initialize(member, product, reference_date = Date.current, options = {}) + def initialize(member, product, reference_date = Date.current) @member = member @product = product - @reference_date = reference_date - @manual_override = options[:manual_override] # <--- NUOVO FLAG + @reference_date = reference_date.to_date end + # Ritorna SOLO una Date (la data di partenza suggerita) def call - return {} unless @member && @product + return @reference_date unless @member && @product - raw_start_date = calculate_start_date - - return {} unless raw_start_date - - # Passiamo raw_start_date alla Duration. - # Se Duration ha logiche di "Snap al 1° del mese", quelle dipendono dalla classe Duration (che non vedo qui), - # ma almeno qui gli passiamo la TUA data. - duration_result = Duration.new(@product, raw_start_date).calculate - duration_result - end - - private - - def calculate_start_date - # 1. SE È UN OVERRIDE MANUALE (dal form), USIAMO SUBITO LA DATA DI RIFERIMENTO - # Ignoriamo completamente la storia degli abbonamenti passati. - return @reference_date if @manual_override - - # --- SOTTO: LOGICA AUTOMATICA (solo se non l'hai forzata tu) --- last_sub = @member.subscriptions.kept - .where(product: @product) - .order(end_date: :desc) - .first + .where(product: @product) + .order(end_date: :desc) + .first + # Se non ha abbonamenti precedenti, parte dalla data contabile (oggi) return @reference_date unless last_sub continuity_date = last_sub.end_date + 1.day gap_days = (@reference_date - continuity_date).to_i - if gap_days < 0 - # Anticipo - continuity_date - elsif gap_days <= GRACE_PERIOD_DAYS - # Recupero (Backdate) + # Se gap_days è negativo (anticipo) o nel periodo di grazia (0..30), uniamo l'abbonamento. + if gap_days <= GRACE_PERIOD_DAYS continuity_date else - # Buco Enorme + # Buco troppo grande, si riparte da zero dalla data contabile @reference_date end end diff --git a/app/models/sale.rb b/app/models/sale.rb index 1df44aa..3dabdbc 100644 --- a/app/models/sale.rb +++ b/app/models/sale.rb @@ -1,4 +1,3 @@ -# app/models/sale.rb class Sale < ApplicationRecord include SubscriptionIssuer, FiscalLockable, Monetizable, Trackable, SoftDeletable @@ -8,29 +7,21 @@ class Sale < ApplicationRecord belongs_to :user belongs_to :product - # --- LE DUE RIGHE FONDAMENTALI PER IL FORM --- - has_one :subscription, dependent: :destroy - accepts_nested_attributes_for :subscription - enum :payment_method, { - cash: 1, - credit_card: 2, - bank_transfer: 3, - other: 4 - }, default: :credit_card, validate: true + cash: 1, credit_card: 2, bank_transfer: 3, other: 4 + }, default: :credit_card, validate: true validates :sold_on, presence: true validates :amount_cents, numericality: { greater_than_or_equal_to: 0 } validates :member, :user, :product, presence: true validates :receipt_sequence, presence: true - # CALLBACKS (L'ordine è importante!) before_validation :snapshot_product_details - before_validation :sync_subscription_data # Sincronizza i dati prima di validare + before_validation :sync_subscription_data before_validation :assign_receipt_number, on: :create - # --- LOGICA PER IL FORM LIVE (GET /sales/new) --- def prepare_draft(options = {}) + self.sold_on ||= Date.current build_subscription unless subscription if options[:renew_subscription_id].present? && member.present? @@ -41,63 +32,30 @@ def prepare_draft(options = {}) end end - if product_id.present? - self.amount = product.price if amount.blank? || amount.zero? + if product_id.present? && (amount.blank? || amount.zero?) + self.amount = product.price end - apply_live_dates(options[:manual_start_date]) + # 1. Passiamo alla Subscription le associazioni necessarie per fargli fare i calcoli + sync_subscription_data + + # 2. Tell, Don't Ask: Diciamo alla Subscription di calcolare le sue date, + # passandole l'eventuale forzatura dell'utente dal form. + subscription.assign_smart_dates(manual_start_date: options[:manual_start_date]) end private - - # --- IL FIX PER IL SALVATAGGIO (POST /sales) --- def sync_subscription_data return unless subscription.present? && member.present? && product.present? - - # 1. Passa le associazioni dalla Sale alla Subscription subscription.member ||= self.member subscription.product ||= self.product - - # 2. Se dal form arriva solo la start_date, calcoliamo la end_date al volo - if subscription.start_date.present? && subscription.end_date.blank? - # Usiamo la tua classe Duration per calcolare la fine in base al prodotto - result = Duration.new(self.product, subscription.start_date).calculate - subscription.end_date = result[:end_date] if result.present? - end end - # --- LOGICA CALCOLO DATE LIVE (Per prepare_draft) --- - def apply_live_dates(manual_start_date) - return unless member && product - - if manual_start_date.present? - parsed_date = Date.parse(manual_start_date) rescue Date.current - subscription.start_date = parsed_date - - result = Duration.new(product, parsed_date).calculate - subscription.end_date = result[:end_date] if result.present? - return - end - - ref_date = sold_on || Date.current - result = RenewalCalculator.new(member, product, ref_date).call - - if result.present? - subscription.start_date = result[:start_date] - subscription.end_date = result[:end_date] - end - end - - # --- SNAPSHOT & FISCALE --- def snapshot_product_details return unless product.present? self.product_name_snapshot = product.name - - if amount_cents.nil? || amount_cents.zero? - self.amount_cents = product.price_cents - end - + self.amount_cents = product.price_cents if amount_cents.nil? || amount_cents.zero? self.receipt_sequence ||= product.accounting_category end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index e4874cb..2079aab 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -7,33 +7,37 @@ class Subscription < ApplicationRecord validates :member, :product, :sale, presence: true - before_validation :calculate_dates_via_duration, on: :create + # Il nuovo cuore automatico: scatta sempre prima di salvare un nuovo record + before_validation :apply_business_rules, on: :create - after_discard :discard_parent_sale - after_undiscard :undiscard_parent_sale + # Lo usiamo in Sale#prepare_draft per pre-compilare il form per la UI + def assign_smart_dates(manual_start_date: nil) + self.start_date = manual_start_date if manual_start_date.present? + apply_business_rules + end private - def calculate_dates_via_duration - return if start_date.present? && end_date.present? - - return unless product.present? + def apply_business_rules + return unless product.present? && member.present? + + # REGOLA 1 (Override Admin): Se la data di fine è già compilata, + # l'Admin ha forzato la data. Il sistema si ferma e non tocca le date. + return if end_date.present? + + # REGOLA 2 (Smart Renewal / Start Date Staff): + # Se non abbiamo una start_date, calcoliamo il rinnovo intelligente. + # Se lo Staff ne ha forzata una (es. 1° mese prossimo), usiamo la sua. + if start_date.blank? + reference_date = sale&.sold_on || Date.current + self.start_date = RenewalCalculator.new(member, product, reference_date).call + end - reference_date = start_date || sale&.sold_on || Date.current - result = Duration.new(product, reference_date).calculate + # REGOLA 3 (Duration & Snap): + # Passiamo la start_date a Duration per calcolare la scadenza e + # applicare lo "snap" al 1° del mese (per i prodotti mensili/trimestrali). + result = Duration.new(product, start_date).calculate self.start_date = result[:start_date] self.end_date = result[:end_date] end - - def discard_parent_sale - if sale.present? && !sale.discarded? - sale.discard! - end - end - - def undiscard_parent_sale - if sale.present? && sale.discarded? - sale.undiscard! - end - end end diff --git a/app/views/sales/_form.html.erb b/app/views/sales/_form.html.erb index 6b21333..86c0908 100644 --- a/app/views/sales/_form.html.erb +++ b/app/views/sales/_form.html.erb @@ -1,180 +1,97 @@ <%# app/views/sales/_form.html.erb %> -<%# 1. WRAPPIAMO TUTTO NEL TURBO FRAME PER NASCONDERE L'URL %> - <%= form_with model: sale, url: new_sale_path, method: :get, - data: { - controller: "autosubmit" - # Tolto turbo_action: "replace", ora ci pensa il frame! - }, - class: "grid grid-cols-1 lg:grid-cols-3 gap-8" do |f| %> - - <%= hidden_field_tag :renew_subscription_id, params[:renew_subscription_id] if params[:renew_subscription_id] %> - -
- <%= render "shared/form_errors", model: sale %> - -
-
-

Anagrafica Cliente

- -
-
- <%= f.hidden_field :member_id, - data: { - autocomplete_target: "hidden", - action: "change->autosubmit#submit", - # ECCO IL PATTERN DHH: - autosubmit_clear_param: "#sale_subscription_attributes_start_date" - } %> - - - - -
-
-
+ data: { controller: "autosubmit" } do |f| %> + + <%= render "shared/form_errors", model: sale %> + + <%= hidden_field_tag :previous_member_id, sale.member_id %> + <%= hidden_field_tag :previous_product_id, sale.product_id %> + <%= hidden_field_tag :renew_subscription_id, params[:renew_subscription_id] %> + +
+ Anagrafica Cliente + +
+ <%= f.hidden_field :member_id, + data: { + autocomplete_target: "hidden", + action: "change->autosubmit#submit" + } %> + + + +
+
-
-
-

Dettagli Acquisto

- -
-
- <%= f.label :product_id, "Seleziona Prodotto", class: "label font-semibold" %> - <%= f.collection_select :product_id, Product.all, :id, :name, - { prompt: "Scegli un prodotto per iniziare..." }, - { class: "select select-bordered w-full text-lg", - data: { - action: "change->autosubmit#submit", - autosubmit_clear_param: "#sale_amount, #sale_subscription_attributes_start_date" - } } %> -
- -
- <%= f.label :sold_on, "Data Contabile", class: "label font-semibold" %> - <%# FORZIAMO IL VALORE: %> - <%= f.date_field :sold_on, - value: sale.sold_on, - class: "input input-bordered w-full", - data: { action: "change->autosubmit#submit" } %> -
- -
- <%= f.label :amount, "Importo Pagato (€)", class: "label font-semibold" %> - <%# FORZIAMO IL VALORE E GESTIAMO IL Nullo: %> - <%= f.number_field :amount, step: "0.01", - value: sale.amount, - class: "input input-bordered w-full", - data: { action: "input->autosubmit#submit" } %> -
- -
- <%= f.label :payment_method, "Metodo di Pagamento", class: "label font-semibold" %> -
- <%= f.select :payment_method, Sale.payment_methods.keys.map { |k| [k.humanize, k] }, {}, class: "select select-bordered w-full max-w-xs" %> -
-
-
-
+
+ Dettagli Acquisto + +
+ <%= f.label :product_id, "Prodotto", class: "label" %> + <%= f.collection_select :product_id, Product.all, :id, :name, + { prompt: "Scegli..." }, + { class: "select", data: { action: "change->autosubmit#submit" } } %>
-
-
-
-

Validità Iscrizione

-
Calcolo Smart
-
-

- Le date vengono calcolate in base al prodotto e allo storico del socio. -

- - <%= f.fields_for :subscription do |sub_f| %> -
-
- <%= sub_f.label :start_date, "Decorrenza", class: "label font-semibold" %> - <%# FORZIAMO IL VALORE: %> - <%= sub_f.date_field :start_date, - value: sale.subscription&.start_date, - class: "input input-bordered w-full", - data: { action: "change->autosubmit#submit" } %> -
-
- - -
-
- <% end %> -
+
+ <%= f.label :sold_on, "Data Contabile", class: "label" %> + <%# VIA IL VALUE OVERRIDE, CI PENSA RAILS %> + <%= f.date_field :sold_on, class: "input", data: { action: "change->autosubmit#submit" } %>
-
-
-
-
-

Scontrino Live

- -
-
- Socio: - - <%= sale.member&.full_name_ruby || "Nessuno" %> - -
- -
- Prodotto: - - <%= sale.product&.name || "Nessuno" %> - -
- -
- Decorrenza: - <%= sale.subscription&.start_date ? I18n.l(sale.subscription.start_date) : "..." %> -
- -
- Scadenza: - <%= sale.subscription&.end_date ? I18n.l(sale.subscription.end_date) : "..." %> -
- -
- -
- Totale - - € <%= number_with_precision(sale.amount || 0, precision: 2) %> - -
-
- -
- <%# IMPORTANTE: Aggiunto data: { turbo_frame: "_top" } %> - <%= f.button "Conferma e Incassa", - class: "btn btn-primary w-full btn-lg font-bold text-lg", - formmethod: "post", - formaction: sales_path, - data: { turbo_frame: "_top" }, - disabled: sale.product.nil? || sale.member.nil? %> -
-
+
+ <%= f.label :amount, "Importo Pagato (€)", class: "label" %> + <%# VIA IL VALUE OVERRIDE, CI PENSA RAILS %> + <%= f.text_field :amount, class: "input", data: { action: "change->autosubmit#submit" } %>
+ +
+ <%= f.label :payment_method, "Metodo", class: "label" %> + <%= f.select :payment_method, Sale.payment_methods.keys, {}, class: "select", data: { action: "change->autosubmit#submit" } %> +
+
+ +
+ Validità Iscrizione (Calcolo Smart) + + <%= f.fields_for :subscription do |sub_f| %> +
+ <%= sub_f.label :start_date, "Decorrenza", class: "label" %> + <%# VIA IL VALUE OVERRIDE %> + <%= sub_f.date_field :start_date, class: "input", data: { action: "change->autosubmit#submit" } %> +
+
+ + <%# QUI SERVE IL VALUE PERCHÉ È UN TAG HTML PURO, MA HO TOLTO LA VIRGOLA PRIMA DI DISABLED %> + +
+ <% end %> +
+ +
+

Scontrino Live

+

Socio: <%= sale.member&.full_name_ruby %>

+

Prodotto: <%= sale.product&.name %>

+

Totale: € <%= sale.amount %>

+ + <%= f.button "Conferma e Incassa", + formmethod: "post", + formaction: sales_path, + data: { turbo_frame: "_top" }, + disabled: sale.product.nil? || sale.member.nil? %>
- <% end %> + <% end %> diff --git a/test/models/concerns/subscription_issuer_test.rb b/test/models/concerns/subscription_issuer_test.rb index 6261a99..78b158b 100644 --- a/test/models/concerns/subscription_issuer_test.rb +++ b/test/models/concerns/subscription_issuer_test.rb @@ -110,8 +110,8 @@ class SubscriptionIssuerTest < ActiveSupport::TestCase end end - test "smart renewal: manual date override respects start but calculates calendar end" do - # Scenario: Operatore forza inizio al 15 Gennaio. + test "smart renewal: staff manual start date snaps to month start for calendar products" do + # Scenario: Operatore (Staff) forza inizio al 15 Gennaio dal form. manual_date = Date.new(2025, 1, 15) sale_params = default_sale_params @@ -119,18 +119,33 @@ class SubscriptionIssuerTest < ActiveSupport::TestCase sale = Sale.create!(sale_params) - # 1. Start Date: L'override manuale VINCE su tutto. Non viene "snappato" se inserito a mano. - # (A meno che tu non abbia modificato anche quella logica, ma da codice precedente vinceva l'umano) - assert_equal manual_date, sale.subscription.start_date + # 1. Start Date: Duration intercetta il 15 Gennaio e applica lo SNAP + # al 1° del mese per coprire i giorni antecedenti non pagati (Regola Palestra). + expected_start = Date.new(2025, 1, 1) - # 2. End Date: Calcolata da Duration. - # Duration prende 15 Gen -> Snappa a 1 Gen -> Calcola fine mese 31 Gen. - # Quindi ci aspettiamo che finisca a fine mese. + # 2. End Date: Calcolata da Duration (31 Gennaio). expected_end = Date.new(2025, 1, 31) + assert_equal expected_start, sale.subscription.start_date assert_equal expected_end, sale.subscription.end_date end + # AGGIUNGI QUESTO NUOVO TEST per blindare il potere dell'Admin + test "admin override: explicitly providing both dates completely bypasses calculation" do + start_override = Date.new(2025, 1, 15) + end_override = Date.new(2025, 3, 10) # Una data totalmente arbitraria + + sale_params = default_sale_params + sale_params[:subscription_attributes][:start_date] = start_override + sale_params[:subscription_attributes][:end_date] = end_override + + sale = Sale.create!(sale_params) + + # Il sistema accetta le date così come sono, senza snappare o ricalcolare nulla + assert_equal start_override, sale.subscription.start_date + assert_equal end_override, sale.subscription.end_date + end + # --- TEST SOFT DELETE --- # Questi rimangono invariati perché testano la logica del DB, non le date. diff --git a/test/models/duration_test.rb b/test/models/duration_test.rb index 557c0af..2907d54 100644 --- a/test/models/duration_test.rb +++ b/test/models/duration_test.rb @@ -2,101 +2,50 @@ class DurationTest < ActiveSupport::TestCase setup do - @course = products(:yoga_monthly) # Istituzionale (30gg di default) - @membership = products(:annual_membership) # Associativo (365gg) + @course = products(:yoga_monthly) + @membership = products(:annual_membership) end - # --- TEST 1: LOGICA MENSILE (Invariata) --- - # Il mensile continua a fare "Snap" al 1° del mese e rispettare l'anno sportivo - # per sicurezza (salvo diversa indicazione). - - test "institutional monthly SNAPS to beginning of month" do - # Scenario: Pago il 20 Gennaio - preference_date = Date.new(2025, 1, 20) - - result = Duration.new(@course, preference_date).calculate - - # Regola: Inizia il 1° del mese - assert_equal Date.new(2025, 1, 1), result[:start_date] - # Regola: Finisce a fine mese - assert_equal Date.new(2025, 1, 31), result[:end_date] - end - - test "institutional monthly CAPS at Sport Year End" do - # Scenario: Corso mensile comprato il 15 Agosto 2025 (Anno sportivo finisce il 31/08) + test "institutional monthly SNAPS to beginning of month and CAPS at Sport Year" do preference_date = Date.new(2025, 8, 15) - result = Duration.new(@course, preference_date).calculate - # Inizia il 1° Agosto assert_equal Date.new(2025, 8, 1), result[:start_date] - # Si ferma al 31 Agosto (Fine anno sportivo) assert_equal Date.new(2025, 8, 31), result[:end_date] end - # --- TEST 2: NUOVA LOGICA TRIMESTRALE (90gg) --- - # Deve fare lo Snap al 1°, ma PUÒ USCIRE dall'anno sportivo. - test "institutional quarterly SNAPS and CROSSES Sport Year boundary" do - # Trasformiamo il prodotto in un Trimestrale - @course.duration_days = 90 - - # Scenario: Acquisto il 15 Luglio 2025. - # Anno sportivo finisce il 31 Agosto. - # Trimestre: Luglio, Agosto, Settembre (sfora Agosto). + @course.update!(duration_days: 90) preference_date = Date.new(2025, 7, 15) result = Duration.new(@course, preference_date).calculate - # Regola: Snap al 1° Luglio assert_equal Date.new(2025, 7, 1), result[:start_date] - - # Regola: 3 mesi interi (Lug, Ago, Set) -> Fine 30 Settembre - # DEVE ignorare il blocco del 31 Agosto assert_equal Date.new(2025, 9, 30), result[:end_date] end - # --- TEST 3: NUOVA LOGICA ANNUALE (365gg) --- - # Rolling puro. Data esatta -> Data esatta. Ignora anno sportivo. - test "institutional annual uses ROLLING logic and IGNORES Sport Year" do - # Trasformiamo il prodotto in un Annuale Istituzionale (es. Sala Pesi Open) - @course.duration_days = 365 - - # Scenario: Acquisto il 14 Maggio 2025 + @course.update!(duration_days: 365) preference_date = Date.new(2025, 5, 14) result = Duration.new(@course, preference_date).calculate - # Regola Rolling: Parte il giorno esatto dell'acquisto (niente snap) assert_equal Date.new(2025, 5, 14), result[:start_date] - - # Regola Rolling: Finisce esattamente un anno dopo (meno un giorno) - # Scavalca tranquillamente il 31 Agosto assert_equal Date.new(2026, 5, 13), result[:end_date] end - # --- TEST 4: LOGICA GIORNI PURI (Custom) --- - - test "institutional custom duration (45 days) uses PURE DAYS logic with Cap" do - @course.duration_days = 45 - - # Se non specificato diversamente in duration.rb, i custom days rispettano ancora il Cap - # Scenario: 10 Gennaio + test "institutional custom duration uses PURE DAYS logic with Cap" do + @course.update!(duration_days: 45) preference_date = Date.new(2025, 1, 10) + result = Duration.new(@course, preference_date).calculate assert_equal Date.new(2025, 1, 10), result[:start_date] - # 10 Gen + 45gg = 23 Feb assert_equal Date.new(2025, 2, 23), result[:end_date] end - # --- TEST 5: LOGICA ASSOCIATIVA (Invariata) --- - test "associative membership ALWAYS CAPS at Sport Year End" do - # La quota associativa deve morire il 31 Agosto, cascasse il mondo. preference_date = Date.new(2025, 5, 15) - result = Duration.new(@membership, preference_date).calculate assert_equal preference_date, result[:start_date] diff --git a/test/models/renewal_calculator_test.rb b/test/models/renewal_calculator_test.rb index a1bab13..ef033ba 100644 --- a/test/models/renewal_calculator_test.rb +++ b/test/models/renewal_calculator_test.rb @@ -1,102 +1,87 @@ require "test_helper" class RenewalCalculatorTest < ActiveSupport::TestCase - # TimeHelpers per fissare "Oggi" durante i test include ActiveSupport::Testing::TimeHelpers setup do @member = members(:alice) - @product = products(:yoga_monthly) # Mensile (30gg) -> Logica Calendario - - # Puliamo eventuali sottoscrizioni esistenti per partire da zero + @product = products(:yoga_monthly) @member.subscriptions.destroy_all end - test "returns dates starting today (snapped) if no history exists" do - # Scenario: Prima iscrizione assoluta il 20 Gennaio + test "returns the reference_date if no history exists" do today = Date.new(2025, 1, 20) travel_to today do - calculator = RenewalCalculator.new(@member, @product) - result = calculator.call - - # LOGICA: - # 1. Raw Start: Oggi (20 Gennaio) - # 2. Duration Snap: 1° Gennaio - assert_equal Date.new(2025, 1, 1), result[:start_date] - assert_equal Date.new(2025, 1, 31), result[:end_date] + calculator = RenewalCalculator.new(@member, @product, today) + suggested_start = calculator.call + + # Senza storico, lo Stratega dice: "Parti dalla data contabile" + assert_equal today, suggested_start end end - test "continuity: anticipated renewal snaps to next month start" do - # Scenario: Oggi 20 Gennaio. Scadenza attuale 31 Gennaio. + test "continuity: anticipated renewal connects to previous end_date" do today = Date.new(2025, 1, 20) current_expiry = Date.new(2025, 1, 31) travel_to today do - create_subscription(end_date: current_expiry) + create_past_subscription(end_date: current_expiry) - calculator = RenewalCalculator.new(@member, @product) - result = calculator.call + calculator = RenewalCalculator.new(@member, @product, today) + suggested_start = calculator.call - # LOGICA: - # 1. Raw Start (Continuità): 1° Febbraio - # 2. Duration Snap: 1° Febbraio (già inizio mese) -> Invariato - assert_equal Date.new(2025, 2, 1), result[:start_date] - assert_equal Date.new(2025, 2, 28), result[:end_date] + # Scade il 31, lo Stratega dice: "Parti dal 1° Febbraio" + assert_equal Date.new(2025, 2, 1), suggested_start end end - test "continuity: small gap (punishment) snaps to GAP month start" do - # Scenario: Oggi 20 Gennaio. Scaduto il 5 Gennaio (Gap 15gg < 30gg Grace). + test "continuity: small gap (grace period) backdates to previous end_date" do today = Date.new(2025, 1, 20) - past_expiry = Date.new(2025, 1, 5) + past_expiry = Date.new(2025, 1, 5) # Gap di 15gg travel_to today do - create_subscription(end_date: past_expiry) + create_past_subscription(end_date: past_expiry) - calculator = RenewalCalculator.new(@member, @product) - result = calculator.call + calculator = RenewalCalculator.new(@member, @product, today) + suggested_start = calculator.call - # LOGICA: - # 1. Raw Start (Continuità punitiva): 6 Gennaio - # 2. Duration Snap: 6 Gennaio appartiene a Gennaio -> 1° Gennaio - # Risultato: Paghi tutto Gennaio anche se rinnovi il 20. - assert_equal Date.new(2025, 1, 1), result[:start_date] - assert_equal Date.new(2025, 1, 31), result[:end_date] + # Scaduto da poco, lo Stratega dice: "Recupera il buco, parti dal 6 Gennaio" + assert_equal Date.new(2025, 1, 6), suggested_start end end - test "reset: huge gap snaps to CURRENT month start" do - # Scenario: Oggi 20 Gennaio. Scaduto a Ottobre (Gap enorme). + test "reset: huge gap starts fresh from reference_date" do today = Date.new(2025, 1, 20) - past_expiry = Date.new(2024, 10, 31) + past_expiry = Date.new(2024, 10, 31) # Gap enorme travel_to today do - create_subscription(end_date: past_expiry) + create_past_subscription(end_date: past_expiry) - calculator = RenewalCalculator.new(@member, @product) - result = calculator.call + calculator = RenewalCalculator.new(@member, @product, today) + suggested_start = calculator.call - # LOGICA: - # 1. Raw Start (Reset): Oggi (20 Gennaio) - # 2. Duration Snap: 20 Gennaio -> 1° Gennaio - assert_equal Date.new(2025, 1, 1), result[:start_date] - assert_equal Date.new(2025, 1, 31), result[:end_date] + # Buco troppo grosso, lo Stratega dice: "Ricomincia da oggi" + assert_equal today, suggested_start end end private - - def create_subscription(end_date:) - # Creiamo un abbonamento fittizio nel DB per simulare lo storico - start_date = end_date.beginning_of_month - Subscription.create!( - member: @member, - product: @product, - start_date: start_date, - end_date: end_date, - sale: Sale.create!(member: @member, user: users(:staff), product: @product, sold_on: start_date) - ) - end + def create_past_subscription(end_date:) + start_date = end_date.beginning_of_month + + # 1. Creiamo la vendita base + sale = Sale.create!(member: @member, user: users(:staff), product: @product, sold_on: start_date) + + # 2. Creiamo l'abbonamento (lasciando che Duration calcoli le sue date reali per passare le validazioni) + sub = Subscription.create!( + member: @member, + product: @product, + sale: sale + ) + + # 3. FORZIAMO la data di fine nel database ignorando le regole, + # solo per simulare lo scenario di questo specifico test! + sub.update_columns(end_date: end_date) + end end diff --git a/test/models/sale_test.rb b/test/models/sale_test.rb index 57845f2..178ac48 100644 --- a/test/models/sale_test.rb +++ b/test/models/sale_test.rb @@ -3,6 +3,7 @@ class SaleTest < ActiveSupport::TestCase setup do Sale.delete_all + ReceiptCounter.delete_all @member = members(:bob) @user = users(:staff) @@ -23,6 +24,8 @@ class SaleTest < ActiveSupport::TestCase grant_membership_to(@member) end + # --- TEST FISCALI E DI PAGAMENTO --- + test "cash payment generates receipt number and year" do sale = Sale.create!( member: @member, product: @prod_inst, user: @user, @@ -59,112 +62,91 @@ class SaleTest < ActiveSupport::TestCase test "counting skips non-cash payments correctly" do current_year = Date.today.year - # 1. Vendita CASH -> Ricevuta n. 1 s1 = Sale.create!(member: @member, product: @prod_inst, user: @user, payment_method: :cash, sold_on: Date.today) assert_equal 1, s1.receipt_number - # 2. Vendita CARTA -> Niente numero (Non deve consumare il n. 2) s2 = Sale.create!(member: @member, product: @prod_inst, user: @user, payment_method: :credit_card, sold_on: Date.today) assert_nil s2.receipt_number - # 3. Vendita CASH -> Ricevuta n. 2 (Non n. 3!) s3 = Sale.create!(member: @member, product: @prod_inst, user: @user, payment_method: :cash, sold_on: Date.today) assert_equal 2, s3.receipt_number - # Verifica codici virtuali - # Nota: Reload necessario per leggere colonne generate dal DB assert_equal "#{current_year}-institutional-1", s1.reload.receipt_code assert_nil s2.reload.receipt_code assert_equal "#{current_year}-institutional-2", s3.reload.receipt_code end test "sequences are independent even with mixed payments" do - # Cash Istituzionale -> n.1 + # Leggiamo l'ultimo numero emesso (potrebbe essere > 0 a causa dell'helper grant_membership_to) + initial_assoc_max = Sale.where(receipt_sequence: "associative").maximum(:receipt_number).to_i + s1 = Sale.create!(member: @member, product: @prod_inst, user: @user, payment_method: :cash, sold_on: Date.today) assert_equal 1, s1.receipt_number assert_equal "institutional", s1.receipt_sequence - # Cash Associativo -> n.1 (Nuova serie indipendente) s2 = Sale.create!(member: @member, product: @prod_assoc, user: @user, payment_method: :cash, sold_on: Date.today) - assert_equal 1, s2.receipt_number + # Assicuriamoci che faccia +1 rispetto a quello che c'era prima + assert_equal initial_assoc_max + 1, s2.receipt_number assert_equal "associative", s2.receipt_sequence - # Carta Istituzionale -> NULL s3 = Sale.create!(member: @member, product: @prod_inst, user: @user, payment_method: :credit_card, sold_on: Date.today) assert_nil s3.receipt_number - # Cash Istituzionale -> n.2 (Riprende la serie istituzionale) s4 = Sale.create!(member: @member, product: @prod_inst, user: @user, payment_method: :cash, sold_on: Date.today) assert_equal 2, s4.receipt_number end - test "virtual column receipt_code works in DB" do - sale = Sale.create!( - member: @member, product: @prod_inst, user: @user, - payment_method: :cash, sold_on: Date.today - ) - sale.reload - expected_code = "#{Date.today.year}-institutional-1" - assert_equal expected_code, sale.receipt_code - end + # --- TEST SNAPSHOT E VALUTA --- test "snapshots product details on creation" do - # Creiamo la vendita SENZA specificare amount, deve prenderlo dal prodotto sale = Sale.create!( - member: @member, - product: @prod_inst, # Ha price_cents: 5000 (settato nel setup) - user: @user, - sold_on: Date.today, - payment_method: :cash + member: @member, product: @prod_inst, user: @user, + sold_on: Date.today, payment_method: :cash ) - # Verifica Snapshot Nome assert_equal "Yoga Course", sale.product_name_snapshot - - # Verifica Snapshot Prezzo assert_equal 5000, sale.amount_cents assert_equal "institutional", sale.receipt_sequence - # CAMBIAMENTO PRODOTTO FUTURO @prod_inst.update!(name: "Yoga New Price", price_cents: 9999) - # La vendita vecchia deve rimanere immutata (Congelata) sale.reload assert_equal "Yoga Course", sale.product_name_snapshot assert_equal 5000, sale.amount_cents end - test "resets numbering on new year" do - # Vendita nel 2024 - Sale.create!( - member: @member, product: @prod_inst, user: @user, - sold_on: Date.new(2024, 12, 31), payment_method: :cash - ) - - # Vendita nel 2025 -> Deve ripartire da 1 - sale_2025 = Sale.create!( - member: @member, product: @prod_inst, user: @user, - sold_on: Date.new(2025, 1, 1), payment_method: :cash - ) - - assert_equal 2025, sale_2025.receipt_year - assert_equal 1, sale_2025.receipt_number - end - test "monetizable handles strings with italian formatting" do sale = Sale.new - # Caso difficile: 1.200,50 (Mille e duecento virgola cinquanta) sale.amount = "1.200,50" assert_equal 120050, sale.amount_cents assert_equal 1200.5, sale.amount - # Caso standard: 50 sale.amount = "50" assert_equal 5000, sale.amount_cents - # Caso virgola semplice: 12,50 sale.amount = "12,50" assert_equal 1250, sale.amount_cents end + + # --- TEST LOGICA DRAFT / FORM LIVE --- + + test "prepare_draft sets sold_on to today if empty and builds subscription" do + sale = Sale.new(member: @member, product: @prod_inst) + sale.prepare_draft + + assert_equal Date.current, sale.sold_on + assert_not_nil sale.subscription + assert_equal @member, sale.subscription.member + assert_equal @prod_inst, sale.subscription.product + end + + test "prepare_draft with manual_start_date forces the subscription start date" do + sale = Sale.new(member: @member, product: @prod_inst) + forced_date = 5.days.from_now.to_date + + sale.prepare_draft(manual_start_date: forced_date.to_s) + + assert_equal forced_date, sale.subscription.start_date + end end diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb index 07c758a..957004c 100644 --- a/test/models/subscription_test.rb +++ b/test/models/subscription_test.rb @@ -88,16 +88,20 @@ class SubscriptionTest < ActiveSupport::TestCase assert_includes Subscription.upcoming, upcoming end - test "validates end date after start date" do - sale = Sale.create!(member: @member, product: @prod_inst, user: @staff, sold_on: Date.today) + test "admin override: prevents Duration calculator from modifying explicitly provided end_dates" do + invalid_end_date = Date.current + 50.days # Una data sballata - sub = Subscription.new( - member: @member, product: @prod_inst, sale: sale, - start_date: Date.today, - end_date: Date.yesterday # Errore manuale + subscription = Subscription.new( + member: @member, + product: @product, + sale: @sale, + start_date: Date.current, + end_date: invalid_end_date # Simuliamo l'Admin che la inserisce a mano ) - assert_not sub.valid? - assert_includes sub.errors[:end_date], "must be after or equal to start date" + subscription.valid? # Scatena le before_validation + + # Ora ci aspettiamo che il sistema NON l'abbia toccata! + assert_equal invalid_end_date, subscription.end_date end end From 7e4470dc31c115f63f95aad1e3e3652e43f6facc Mon Sep 17 00:00:00 2001 From: jcostd Date: Sun, 29 Mar 2026 19:40:43 +0200 Subject: [PATCH 04/34] Updated UI --- app/controllers/application_controller.rb | 2 +- app/controllers/concerns/localizable.rb | 14 + app/controllers/concerns/themable.rb | 8 +- app/controllers/dashboard_controller.rb | 18 +- app/controllers/members_controller.rb | 45 +-- .../preferences/base_controller.rb | 10 - .../preferences/languages_controller.rb | 13 +- .../preferences/themes_controller.rb | 13 +- app/helpers/members_helper.rb | 34 ++ .../controllers/dropdown_controller.js | 16 + .../controllers/filter_badge_controller.js | 27 ++ .../controllers/language_controller.js | 28 -- .../subscription_edit_controller.js | 55 --- .../controllers/theme_controller.js | 47 --- .../controllers/theme_sync_controller.js | 15 + app/models/member.rb | 185 +--------- app/queries/members_query.rb | 66 ++++ app/views/dashboard/index.html.erb | 315 ++++++------------ app/views/layouts/application.html.erb | 30 +- app/views/layouts/modal.html.erb | 1 - app/views/layouts/pos.html.erb | 9 +- app/views/layouts/unauthenticated.html.erb | 15 +- app/views/members/_member_row.html.erb | 263 +++++---------- app/views/members/index.html.erb | 144 ++++---- app/views/shared/_app_shell_layout.html.erb | 13 + app/views/shared/_dropdown.html.erb | 28 -- app/views/shared/_dropdown_layout.html.erb | 26 ++ app/views/shared/_feedback_fab.html.erb | 7 - app/views/shared/_flash.html.erb | 2 +- app/views/shared/_navbar.html.erb | 36 +- app/views/shared/_sidebar.html.erb | 43 +-- app/views/shared/filter/_active.html.erb | 37 +- app/views/shared/filter/_drawer.html.erb | 38 ++- app/views/shared/filter/_search.html.erb | 18 - app/views/shared/filter/_sort.html.erb | 16 + app/views/shared/filter/_trigger.html.erb | 16 - app/views/shared/header/_page.html.erb | 27 +- .../shared/navbar/_language_dropdown.html.erb | 69 ++-- .../shared/navbar/_theme_dropdown.html.erb | 59 ++-- .../shared/navbar/_user_dropdown.html.erb | 79 ++--- app/views/shared/sidebar/_admin.html.erb | 50 +-- app/views/shared/sidebar/_staff.html.erb | 20 +- config/routes.rb | 2 - 43 files changed, 770 insertions(+), 1189 deletions(-) create mode 100644 app/controllers/concerns/localizable.rb delete mode 100644 app/controllers/preferences/base_controller.rb create mode 100644 app/helpers/members_helper.rb create mode 100644 app/javascript/controllers/dropdown_controller.js create mode 100644 app/javascript/controllers/filter_badge_controller.js delete mode 100644 app/javascript/controllers/language_controller.js delete mode 100644 app/javascript/controllers/subscription_edit_controller.js delete mode 100644 app/javascript/controllers/theme_controller.js create mode 100644 app/javascript/controllers/theme_sync_controller.js create mode 100644 app/queries/members_query.rb create mode 100644 app/views/shared/_app_shell_layout.html.erb delete mode 100644 app/views/shared/_dropdown.html.erb create mode 100644 app/views/shared/_dropdown_layout.html.erb delete mode 100644 app/views/shared/_feedback_fab.html.erb delete mode 100644 app/views/shared/filter/_search.html.erb create mode 100644 app/views/shared/filter/_sort.html.erb delete mode 100644 app/views/shared/filter/_trigger.html.erb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a40ee79..4457cd8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - include Themable, Authentication + include Themable, Localizable, Authentication include Pagy::Method diff --git a/app/controllers/concerns/localizable.rb b/app/controllers/concerns/localizable.rb new file mode 100644 index 0000000..aa6ac24 --- /dev/null +++ b/app/controllers/concerns/localizable.rb @@ -0,0 +1,14 @@ +module Localizable + extend ActiveSupport::Concern + + included do + around_action :switch_locale + end + + private + def switch_locale(&action) + locale = current_user&.locale_or_default || I18n.default_locale + + I18n.with_locale(locale, &action) + end +end diff --git a/app/controllers/concerns/themable.rb b/app/controllers/concerns/themable.rb index ed0f3ec..01e6614 100644 --- a/app/controllers/concerns/themable.rb +++ b/app/controllers/concerns/themable.rb @@ -2,13 +2,11 @@ module Themable extend ActiveSupport::Concern included do - before_action :load_theme + before_action :set_theme end private - def load_theme - return @theme = current_user.theme_or_default if current_user - - @theme = "light" + def set_theme + @theme = current_user&.theme_or_default || "light" end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index dec9e61..25dfde4 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -2,13 +2,17 @@ class DashboardController < ApplicationController def index @daily_cash = DailyCash.current - @recent_sales = Sale.kept - .includes(:member, :product, :user) - .order(created_at: :desc) - .limit(10) + @today_accesses_count = AccessLog.where(entered_at: Time.current.beginning_of_day..Time.current.end_of_day).count - @expiring_count = Subscription.kept - .where(end_date: Date.current..7.days.from_now) - .count + @expiring_subscriptions = Subscription.kept + .includes(:member) + .where(end_date: Date.current..7.days.from_now) + .order(end_date: :asc) + .limit(5) + @expiring_count = @expiring_subscriptions.except(:limit).count + + @recent_accesses = AccessLog.includes(:member, :discipline) + .order(entered_at: :desc) + .limit(5) end end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index c1cc21b..150a203 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -1,33 +1,17 @@ class MembersController < ApplicationController - include FilterableActions - - before_action :set_member, only: [ :edit, :update, :destroy ] + before_action :set_member, only: [ :show, :edit, :update, :destroy ] def index - base_scope = Member.includes(:subscriptions) + query_results = MembersQuery.new(filter_params).results - @pagy, @members = filter_and_paginate(base_scope) - end + @total_active_members = Member.kept.count - def show - @member = Member.includes(subscriptions: :product, sales: []) - .find(params[:id]) + @pagy, @members = pagy(query_results.includes(:subscriptions)) + @is_filtering = filter_params.to_h.except(:sort).reject { |_, v| v.blank? }.any? end - def renewal_info - @member = Member.find(params[:id]) - product = Product.find_by(id: params[:product_id]) - - is_manual_input = params[:ref_date].present? - - reference_date = is_manual_input ? Date.parse(params[:ref_date]) : Date.current - - if product - info = RenewalCalculator.new(@member, product, reference_date, manual_override: is_manual_input).call - render json: info - else - render json: {}, status: :bad_request - end + def show + @member = Member.includes(subscriptions: :product, sales: []).find(params[:id]) end def new @@ -72,16 +56,13 @@ def set_member def member_params params.require(:member).permit( - :first_name, - :last_name, - :fiscal_code, - :birth_date, - :email_address, - :phone, - :address, - :city, - :zip_code, + :first_name, :last_name, :fiscal_code, :birth_date, + :email_address, :phone, :address, :city, :zip_code, :medical_certificate_expiry ) end + + def filter_params + params.permit(:query, :sort, :membership_status, :med_cert, :state) + end end diff --git a/app/controllers/preferences/base_controller.rb b/app/controllers/preferences/base_controller.rb deleted file mode 100644 index daa658f..0000000 --- a/app/controllers/preferences/base_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -class Preferences::BaseController < ApplicationController - protected - def update_preference!(key, value) - current_user.update!(preferences: current_user.preferences.merge(key => value)) - end - - def render_preference(key) - render json: { key => current_user.preferences[key] } - end -end diff --git a/app/controllers/preferences/languages_controller.rb b/app/controllers/preferences/languages_controller.rb index 24ad889..d0b42b6 100644 --- a/app/controllers/preferences/languages_controller.rb +++ b/app/controllers/preferences/languages_controller.rb @@ -1,12 +1,9 @@ -class Preferences::LanguagesController < Preferences::BaseController +class Preferences::LanguagesController < ApplicationController def update - lang = params.require(:language) # Il parametro dal form può restare "language" - allowed = I18n.available_locales.map(&:to_s) - return head :bad_request unless allowed.include?(lang) + if current_user.update(locale: params.require(:language)) + I18n.locale = current_user.locale + end - I18n.locale = lang - - update_preference!("locale", lang) - render_preference("locale") + redirect_back(fallback_location: root_path) end end diff --git a/app/controllers/preferences/themes_controller.rb b/app/controllers/preferences/themes_controller.rb index 6bf5523..c17cc43 100644 --- a/app/controllers/preferences/themes_controller.rb +++ b/app/controllers/preferences/themes_controller.rb @@ -1,14 +1,7 @@ -class Preferences::ThemesController < Preferences::BaseController - def show - render_preference("theme") - end - +class Preferences::ThemesController < ApplicationController def update - theme = params.require(:theme) - allowed = UserPreferences::ALLOWED_THEMES - return head :bad_request unless allowed.include?(theme) + current_user.update(theme: params.require(:theme)) - update_preference!("theme", theme) - render_preference("theme") + redirect_back(fallback_location: root_path) end end diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb new file mode 100644 index 0000000..bf89784 --- /dev/null +++ b/app/helpers/members_helper.rb @@ -0,0 +1,34 @@ +module MembersHelper + def member_membership_filters + [ + [ "In Regola (Attivo)", "active" ], + [ "Scaduto", "expired" ], + [ "Mai Tesserato (Prospect)", "missing" ] + ] + end + + def member_med_cert_filters + [ + [ "Valido", "valid" ], + [ "Scaduto", "expired" ], + [ "Mancante", "missing" ] + ] + end + + def member_state_filters + [ + [ "Attivi", "active" ], + [ "Archiviati", "archived" ] + ] + end + + def member_status_color_class(member) + if member.membership_valid? && member.medical_certificate_valid? + "status-success" + elsif member.membership_valid? + "status-warning" + else + "status-error" + end + end +end diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js new file mode 100644 index 0000000..d141d2e --- /dev/null +++ b/app/javascript/controllers/dropdown_controller.js @@ -0,0 +1,16 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="dropdown" +export default class extends Controller { + close(event) { + if (this.element.open && !this.element.contains(event.target)) { + this.element.removeAttribute("open") + } + } + + closeOnEscape(event) { + if (event.key === "Escape" && this.element.open) { + this.element.removeAttribute("open") + } + } +} diff --git a/app/javascript/controllers/filter_badge_controller.js b/app/javascript/controllers/filter_badge_controller.js new file mode 100644 index 0000000..fc313d3 --- /dev/null +++ b/app/javascript/controllers/filter_badge_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="filter-badge" +export default class extends Controller { + remove(event) { + const key = event.params.key + + const form = document.getElementById("filter-form") + if (!form || !key) return + + const input = form.querySelector(`[name="${key}"]`) || form.querySelector(`[name$="[${key}]"]`) + + if (input) { + input.value = "" + form.requestSubmit() + } + } + + clearAll(event) { + event.preventDefault() + const form = document.getElementById("filter-form") + if (!form) return + + form.reset() + form.requestSubmit() + } +} diff --git a/app/javascript/controllers/language_controller.js b/app/javascript/controllers/language_controller.js deleted file mode 100644 index 75e952e..0000000 --- a/app/javascript/controllers/language_controller.js +++ /dev/null @@ -1,28 +0,0 @@ -// TODO delete -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="language" -export default class extends Controller { - change(event) { - const language = event.currentTarget.dataset.setLanguage - - fetch("/preferences/language", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - "X-CSRF-Token": this.csrfToken - }, - credentials: 'same-origin', - body: JSON.stringify({ language }) - }).then(response => { - if (response.ok) { - window.location.reload() - } - }) - } - - get csrfToken() { - return document.querySelector('meta[name="csrf-token"]').content - } -} diff --git a/app/javascript/controllers/subscription_edit_controller.js b/app/javascript/controllers/subscription_edit_controller.js deleted file mode 100644 index b14b2f8..0000000 --- a/app/javascript/controllers/subscription_edit_controller.js +++ /dev/null @@ -1,55 +0,0 @@ -// TODO delete -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["startDate", "endDate", "status"] - static values = { - memberId: String, - productId: String - } - - // Quando l'utente cambia la data di inizio - async refreshEndDate(event) { - const newStartDate = this.startDateTarget.value - - // Se la data è vuota, non facciamo nulla - if (!newStartDate) return - - this.setStatus("Calcolo...", "opacity-50") - - // Costruiamo l'URL chiamando lo stesso endpoint che usavi nella vendita - // Passiamo ref_date = data inserita manualmente - const url = `/members/${this.memberIdValue}/renewal_info?product_id=${this.productIdValue}&ref_date=${newStartDate}` - - try { - const response = await fetch(url, { - headers: { "Accept": "application/json" } - }) - - if (response.ok) { - const data = await response.json() - - // Aggiorniamo la data di fine con quella calcolata dal server (Duration.rb) - if (this.hasEndDateTarget && data.end_date) { - this.endDateTarget.value = data.end_date - - // Flash verde per feedback visivo - this.endDateTarget.classList.add("input-success") - setTimeout(() => this.endDateTarget.classList.remove("input-success"), 1000) - } - - this.setStatus("Data fine ricalcolata", "text-success") - } - } catch (error) { - console.error(error) - this.setStatus("Errore calcolo", "text-error") - } - } - - setStatus(text, classes) { - if (this.hasStatusTarget) { - this.statusTarget.textContent = text - this.statusTarget.className = `label-text-alt ${classes}` - } - } -} diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js deleted file mode 100644 index 9e93a62..0000000 --- a/app/javascript/controllers/theme_controller.js +++ /dev/null @@ -1,47 +0,0 @@ -// TODO delete -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="theme" -export default class extends Controller { - connect() { - this.load() - } - - load() { - fetch("/preferences/theme", { - headers: { "Accept": "application/json" }, - }) - .then(response => response.json()) - .then(data => { - if (data.theme) { - document.documentElement.setAttribute("data-theme", data.theme) - } - }) - } - - change(event) { - const theme = event.currentTarget.dataset.setTheme - - document.documentElement.setAttribute("data-theme", theme) - - fetch("/preferences/theme", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - "X-CSRF-Token": this.csrfToken - }, - credentials: 'same-origin', - body: JSON.stringify({ theme }) - }).then(response => { - if (response.ok) { - console.log("Theme updated to:", theme) - window.location.reload() - } - }) - } - - get csrfToken() { - return document.querySelector('meta[name="csrf-token"]').content - } -} diff --git a/app/javascript/controllers/theme_sync_controller.js b/app/javascript/controllers/theme_sync_controller.js new file mode 100644 index 0000000..4f6b943 --- /dev/null +++ b/app/javascript/controllers/theme_sync_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="theme-sync" +export default class extends Controller { + static values = { theme: String } + + themeValueChanged() { + if (this.themeValue) { + const htmlTag = document.documentElement; + + htmlTag.setAttribute("data-theme", this.themeValue); + htmlTag.style.backgroundColor = "var(--fallback-b2,oklch(var(--b2)))"; + } + } +} diff --git a/app/models/member.rb b/app/models/member.rb index 6fc978a..a74b544 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -1,202 +1,45 @@ class Member < ApplicationRecord - include FtsSearchable, Filterable, SoftDeletable, Personable, HasAddress, Avatarable + include FtsSearchable, SoftDeletable, Personable, HasAddress, Avatarable - # --- NORMALIZATIONS --- normalizes :fiscal_code, with: ->(c) { c.strip.upcase } - # --- ASSOCIATIONS --- has_many :sales, dependent: :restrict_with_error has_many :access_logs, dependent: :destroy has_many :subscriptions, dependent: :destroy - # Associazione specifica per le quote associative (membership) has_many :memberships, -> { joins(:product).merge(Product.associative) }, class_name: "Subscription" - # --- VALIDATIONS --- validates :birth_date, presence: true - validates :fiscal_code, presence: true, uniqueness: { conditions: -> { kept } }, - format: { with: /\A[A-Z0-9]{16}\z/, message: "must be 16 alphanumeric characters" } - + format: { with: /\A[A-Z0-9]{16}\z/ } validates :phone, phone: { possible: true, allow_blank: true, types: [ :mobile, :fixed_line ] } - # ============================================================================== - # FILTERABLE CONFIGURATION - # ============================================================================== - - def self.available_filters - [ - { key: :query, label: "Cerca (Nome, CF, Email)" }, - { - key: :membership_status, - label: "Stato Tesseramento", - options: [ - [ "In Regola (Attivo)", "active" ], - [ "Scaduto", "expired" ], - [ "Mai Tesserato (Prospect)", "missing" ] - ] - }, - { - key: :med_cert, - label: "Certificato Medico", - options: [ - [ "Valido", "valid" ], - [ "Scaduto", "expired" ], - [ "Mancante", "missing" ] - ] - }, - { - key: :state, - label: "Archivio", - options: [ - [ "Attivi", "active" ], - [ "Archiviati", "archived" ] - ] - } - ] + def medical_certificate_valid?(date = Date.current) + medical_certificate_expiry.present? && medical_certificate_expiry >= date end - def self.available_sorts - [ - { key: :last_name, label: "Cognome (A-Z)" }, - { key: :created_at, label: "Data Iscrizione" }, - { key: :medical_certificate_expiry, label: "Scadenza Cert. Medico" }, - { key: :birth_date, label: "Età / Data Nascita" } - ] - end - - # Default sorting se non specificato - def self.default_sort_key; :last_name; end - def self.default_sort_direction; :asc; end - - # ============================================================================== - # SCOPES (IMPLEMENTATION) - # ============================================================================== - - # 1. Ricerca Testuale - scope :search_by_text, ->(text) { - term = "%#{text.strip}%" - where( - "members.full_name LIKE :term OR members.fiscal_code LIKE :upcase_term OR members.email_address LIKE :term", - term: term, - upcase_term: term.upcase - ) - } - - # 2. Filtro Stato Record (Soft Delete) - scope :filter_by_state, ->(val) { - val == "archived" ? discarded : kept - } - - # 3. Filtro Certificato Medico - scope :filter_by_med_cert, ->(val) { - today = Date.current - case val - when "valid" - where("members.medical_certificate_expiry >= ?", today) - when "expired" - where("members.medical_certificate_expiry < ?", today) - when "missing" - where(members: { medical_certificate_expiry: nil }) - end - } - - # 4. Filtro Membership (COMPLESSO) - # Usa joins espliciti per evitare ambiguità e garantisce performance - scope :filter_by_membership_status, ->(val) { - today = Date.current - case val - when "active" - # Utenti che hanno ALMENO una subscription di tipo associativo che scade nel futuro - joins(:memberships) - .where("subscriptions.end_date >= ?", today) - .distinct - when "missing" - # Utenti che NON hanno mai avuto una subscription associativa (Rails 6.1+ where.missing) - where.missing(:memberships) - when "expired" - # Utenti che hanno memberships MA la cui data massima di fine è nel passato. - # Usiamo una subquery EXISTS per pulizia o HAVING. Qui approccio HAVING per chiarezza: - joins(:memberships) - .group("members.id") - .having("MAX(subscriptions.end_date) < ?", today) - end - } - - # 5. Ordinamenti Custom (NULLS LAST) - scope :sort_by_medical_certificate_expiry, ->(dir) { - # Sanitizziamo la direzione - direction = dir.to_s.downcase == "desc" ? :desc : :asc - - # Costruiamo la query con Arel - order(arel_table[:medical_certificate_expiry].send(direction).nulls_last) - } - - # ============================================================================== - # INSTANCE METHODS - # ============================================================================== - - def medical_certificate_valid?(date = Date.today) - return false if medical_certificate_expiry.nil? - medical_certificate_expiry >= date + def membership_valid?(date = Date.current) + expiry = memberships.kept.maximum(:end_date) + expiry.present? && expiry >= date end - def membership_expiry_date - # Usa kept per evitare di contare abbonamenti cancellati per errore - memberships.kept.maximum(:end_date) - end - - def membership_valid?(date = Date.today) - expiry = membership_expiry_date - return false if expiry.nil? - expiry >= date - end - - def compliant?(date = Date.today) + def compliant?(date = Date.current) medical_certificate_valid?(date) && membership_valid?(date) end - def status_label - return "error" if discarded? - return "warning" unless compliant? - "success" - end - - def relevant_subscriptions - # Carica le associazioni se non sono già caricate per evitare N+1 - source = subscriptions.loaded? ? subscriptions : subscriptions.includes(:product) - - # Filtra in memoria per velocità se già caricati - active_source = source.select { |sub| sub.kept? } - - # Ordina per data decrescente - sorted = active_source.sort_by(&:end_date).reverse - - # Prendi l'ultimo per ogni prodotto - latest_per_product = sorted.uniq(&:product_id) - - # Mostra solo quelli recenti (es. scaduti da meno di 60gg o futuri) - cutoff_date = 60.days.ago.to_date - visible = latest_per_product.select do |sub| - sub.end_date >= cutoff_date - end - - # Ordina visivamente per scadenza imminente - visible.sort_by { |sub| (sub.end_date - Date.current).to_i } + def relevant_subscriptions(date = Date.current) + subscriptions + .kept + .where("end_date >= ?", date - 30.days) + .order(end_date: :desc) end def renewal_info_for(product) dates = RenewalCalculator.new(self, product, Date.current).call - - # Query specifica e ottimizzata - last_sub = subscriptions.kept - .where(product_id: product.id) - .order(end_date: :desc) - .first - + last_sub = subscriptions.kept.where(product_id: product.id).order(end_date: :desc).first dates.merge(last_subscription_end: last_sub&.end_date) end end diff --git a/app/queries/members_query.rb b/app/queries/members_query.rb new file mode 100644 index 0000000..c5cb12f --- /dev/null +++ b/app/queries/members_query.rb @@ -0,0 +1,66 @@ +class MembersQuery + def initialize(params = {}, relation = Member.all) + @params = params + @relation = relation + end + + def results + @relation + .then { |scope| filter_by_state(scope) } # 1. Filtra Attivi/Archiviati + .then { |scope| filter_by_search(scope) } # 2. Testo libero + .then { |scope| filter_by_membership(scope) } # 3. Stato Tesseramento + .then { |scope| filter_by_med_cert(scope) } # 4. Certificato Medico + .then { |scope| apply_sorting(scope) } # 5. Ordinamento finale + end + + private + def filter_by_state(scope) + if @params[:state] == "archived" + scope.discarded + else + scope.kept + end + end + + def filter_by_search(scope) + return scope if @params[:query].blank? + scope.search_text(@params[:query]) + end + + def filter_by_membership(scope) + case @params[:membership_status] + when "active" + scope.joins(:memberships).where("subscriptions.end_date >= ?", Date.current).distinct + when "expired" + scope.joins(:memberships).where("subscriptions.end_date < ?", Date.current).distinct + when "missing" + scope.where.missing(:memberships) + else + scope + end + end + + def filter_by_med_cert(scope) + case @params[:med_cert] + when "valid" + scope.where("medical_certificate_expiry >= ?", Date.current) + when "expired" + scope.where("medical_certificate_expiry < ?", Date.current) + when "missing" + scope.where(medical_certificate_expiry: nil) + else + scope + end + end + + def apply_sorting(scope) + case @params[:sort] + when "created_asc" then scope.order(created_at: :asc) + when "name_asc" then scope.order(last_name: :asc, first_name: :asc) + when "name_desc" then scope.order(last_name: :desc, first_name: :desc) + when "created_desc" then scope.order(created_at: :desc) + else + @params[:query].present? ? scope : scope.order(created_at: :desc) + end + end +end diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index b31a184..4464c42 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -1,227 +1,134 @@ -<%# --- HEADER: BENVENUTO E RICERCA --- %> -
-
-

- Buongiorno, - <%= Current.session.user.first_name %> - 👋 -

- -

- Ecco l'andamento economico di oggi. -

-
+
+
+

+ Buongiorno, <%= Current.session.user.first_name %> 👋 +

+

+ Situazione operativa della struttura. +

+
- <%= form_with scope: :query, - url: members_path, - method: :get, - class: "join", - data: { turbo: false } do |f| %> -
- -
- <%= button_tag "Cerca", type: "submit", class: "btn btn-primary join-item" %> - <% end %> + <%= link_to new_sale_path, class: "btn btn-primary shadow-sm" do %> + <%= icon("add", size: 20) %> Nuova Vendita + <% end %>
-
- <%# --- SEZIONE 1: CASSA GIORNALIERA --- %> -
- <%# MATTINA %> -
-
- <%= icon("sunny", size: 32) %> -
- -
Mattina (Cash)
- -
- <%= format_money(@daily_cash.morning_total) %> -
- -
Fino alle 14:00
-
+<%# RIGA 1: STATISTICHE (Il requisito Cash rimane prioritario) %> +
- <%# POMERIGGIO %> -
-
- <%= icon("moon", size: 32) %> -
+
+
<%= icon("payments", size: 28) %>
+
Cash Mattina
+
<%= format_money(@daily_cash.morning_total) %>
+
-
Pomeriggio (Cash)
+
+
<%= icon("payments", size: 28) %>
+
Cash Pomeriggio
+
<%= format_money(@daily_cash.afternoon_total) %>
+
-
- <%= format_money(@daily_cash.afternoon_total) %> -
+
+
<%= icon("login", size: 28) %>
+
Ingressi Oggi
+
<%= @today_accesses_count %>
+
-
Dopo le 14:00
+
+
+ <%= icon("notification_important", size: 28) %>
+
Scadenze (7gg)
+
<%= @expiring_count %>
+
- <%# TOTALE %> -
-
- <%= icon("savings", size: 40) %> -
- -
- Totale Contanti -
+
-
<%= format_money(@daily_cash.total) %>
+<%# RIGA 2: I FEED OPERATIVI (Liste, non tabelle) %> +
-
Incasso odierno
+ <%# COLONNA 1: Timeline Ingressi %> +
+
+

+ <%= icon("history", size: 18, class: "opacity-50") %> Registro Accessi +

+ <%= link_to "Vedi tutti", access_logs_path, class: "text-xs font-semibold link link-hover text-base-content/60" %>
-
- <%# --- SEZIONE 2: GRIGLIA OPERATIVA --- %> -
- <%# --- COLONNA SINISTRA (2/3): ULTIME VENDITE (Nuova) --- %> -
-
-
-
-

- <%= icon("receipt", size: 20) %> Ultime Transazioni -

- - <%= link_to "Vedi tutte", sales_path, class: "btn btn-xs btn-ghost" %> -
- - <% if @recent_sales.any? %> -
- - - - - - - - - - - - - <% @recent_sales.each do |sale| %> - - - - - - - - - - - - <% end %> - -
SocioProdottoPagamentoImporto
-
-
- <%= link_to sale.member.full_name, sale.member, class: "hover:underline" %> -
-
- -
- Reg. da <%= sale.user.first_name %> -
-
-
- <%= sale.product.name %> -
-
- <%= sale.payment_method %> - - <%= format_money(sale.amount) %> - - <%= link_to icon("view", size: 18), sale_path(sale), class: "btn btn-ghost btn-xs btn-square" %> -
+ <% if @recent_accesses.any? %> +
    + <% @recent_accesses.each do |log| %> +
  • + +
    + <%# Avatar testuale minimalista %> + + +
    + + <%= link_to log.member.full_name, log.member %> + + + <%= log.discipline&.name || 'Sala Pesi / Libero' %> + +
    - <% else %> -
    - <%= icon("sell", size: 32) %> - Nessuna vendita registrata recentemente. - <%= link_to "Registra la prima", new_sale_path, class: "btn btn-sm btn-primary mt-2" %> +
    + <% if log.status == 0 %> + + <% else %> +
    <%= icon("warning", size: 12) %> Check
    + <% end %> + <%= log.entered_at.strftime("%H:%M") %>
    - <% end %> -
    -
-
- - <%# --- COLONNA DESTRA (1/3): AZIONI & ALERT (Invariata) --- %> -
- <%# CARD AZIONI RAPIDE %> -
-
-

- Azioni Rapide -

- -
- <%= link_to new_sale_path, class: "btn btn-primary w-full shadow-md gap-2", data: { turbo_frame: "modal" } do %> - <%= icon("sale", size: 20) %> - Nuova Vendita - <% end %> - <%= link_to new_member_path, class: "btn btn-outline w-full gap-2", data: { turbo_frame: "modal" } do %> - <%= icon("person_add", size: 20) %> - Nuovo Iscritto - <% end %> -
-
+ + <% end %> + + <% else %> +
+ Nessun ingresso registrato oggi.
+ <% end %> +
- <%# CARD SCADENZE %> - <% if @expiring_count > 0 %> -
- <%= icon("notification_important", size: 24, class: "mt-1") %> - -
-

Scadenze in arrivo!

- -
- Ci sono - <%= @expiring_count %> - abbonamenti in scadenza nei prossimi 7 giorni. -
-
-
- <% else %> -
-
-
- <%= icon("success", size: 32) %> -
- -
Nessuna scadenza imminente
- -
- Tutto tranquillo per i prossimi 7 giorni. + <%# COLONNA 2: Scadenze (Task list) %> +
+

+ <%= icon("schedule", size: 18, class: "text-warning opacity-80") %> In Scadenza +

+ + <% if @expiring_subscriptions.any? %> +
    + <% @expiring_subscriptions.each do |sub| %> +
  • +
    + <%= link_to sub.member.full_name, sub.member, class: "hover:underline" %> + <%= sub.end_date.strftime("%d/%m") %>
    -
-
+ <%= link_to new_sale_path(member_id: sub.member_id), class: "btn btn-xs btn-outline w-full mt-1" do %> + Rinnova Abbonamento + <% end %> + + <% end %> + + <% if @expiring_count > 5 %> +
+ <%= link_to "Mostra altre (#{@expiring_count - 5})", subscriptions_path(filter: 'expiring'), class: "text-xs font-semibold link link-primary" %> +
<% end %> -
+ <% else %> +
+ <%= icon("done_all", size: 24) %> + Nessuna urgenza. +
+ <% end %>
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 13acb05..63f13ac 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,5 @@ - + <%= content_for(:title) || "Active Core" %> @@ -22,29 +22,19 @@ <%= javascript_importmap_tags %> - -
-
- + -
- <%= render "shared/navbar" %> - - - -
- <%= yield %> -
-
- - <%= render "shared/sidebar" %> -
-
+ <%= render layout: "shared/app_shell_layout" do %> + <%= yield %> + <% end %> <%= render "shared/flash" %> + <%= turbo_frame_tag "modal" %> <%= turbo_frame_tag "feedback_modal" %> - - <%= render "shared/feedback_fab" %> diff --git a/app/views/layouts/modal.html.erb b/app/views/layouts/modal.html.erb index e0e9113..3a61512 100644 --- a/app/views/layouts/modal.html.erb +++ b/app/views/layouts/modal.html.erb @@ -29,7 +29,6 @@
- <%= render "shared/feedback_fab" %> <% end %> diff --git a/app/views/layouts/pos.html.erb b/app/views/layouts/pos.html.erb index a6cc756..5daad4f 100644 --- a/app/views/layouts/pos.html.erb +++ b/app/views/layouts/pos.html.erb @@ -1,5 +1,5 @@ - + Cassa | <%= content_for(:title) || "Active Core" %> @@ -15,10 +15,13 @@ <%= javascript_importmap_tags %> - + <%# Navbar minimal (solo logo e pulsante indietro) %>
-<%# RIGA 1: STATISTICHE (Il requisito Cash rimane prioritario) %> +<%# RIGA 1: STATISTICHE %>
-
-
<%= icon("payments", size: 28) %>
+
+
<%= icon("payments", classes: "size-8") %>
Cash Mattina
<%= format_money(@daily_cash.morning_total) %>
-
-
<%= icon("payments", size: 28) %>
+
+
<%= icon("payments", classes: "size-8") %>
Cash Pomeriggio
<%= format_money(@daily_cash.afternoon_total) %>
-
-
<%= icon("login", size: 28) %>
+
+
<%= icon("login", classes: "size-8") %>
Ingressi Oggi
<%= @today_accesses_count %>
-
-
- <%= icon("notification_important", size: 28) %> +
+
+ <%= icon("notification_important", classes: "size-8") %>
Scadenze (7gg)
<%= @expiring_count %>
@@ -44,14 +44,14 @@
-<%# RIGA 2: I FEED OPERATIVI (Liste, non tabelle) %> +<%# RIGA 2: I FEED OPERATIVI %>
<%# COLONNA 1: Timeline Ingressi %> -
+

- <%= icon("history", size: 18, class: "opacity-50") %> Registro Accessi + <%= icon("history", classes: "size-5 opacity-50") %> Registro Accessi

<%= link_to "Vedi tutti", access_logs_path, class: "text-xs font-semibold link link-hover text-base-content/60" %>
@@ -59,31 +59,36 @@ <% if @recent_accesses.any? %>
    <% @recent_accesses.each do |log| %> -
  • +
  • -
    - <%# Avatar testuale minimalista %> +
    + <%# Avatar collegato al nostro concern Avatarable %>
    - <% if log.status == 0 %> - + <%# Fix: size-3 per le icone dentro i badge %> + <% if log.status == 0 || log.status == 'ok' %> + <% else %> -
    <%= icon("warning", size: 12) %> Check
    +
    + <%= icon("warning", classes: "size-3") %> Check +
    <% end %> <%= log.entered_at.strftime("%H:%M") %>
    @@ -92,41 +97,48 @@ <% end %>
<% else %> -
+
Nessun ingresso registrato oggi.
<% end %>
<%# COLONNA 2: Scadenze (Task list) %> -
+

- <%= icon("schedule", size: 18, class: "text-warning opacity-80") %> In Scadenza + <%= icon("schedule", classes: "size-5 text-warning") %> In Scadenza

<% if @expiring_subscriptions.any? %>
    <% @expiring_subscriptions.each do |sub| %> -
  • -
    - <%= link_to sub.member.full_name, sub.member, class: "hover:underline" %> - <%= sub.end_date.strftime("%d/%m") %> + <%# Uso del componente Card per i task per un look più pulito %> +
  • +
    +
    + + <%= link_to sub.member.full_name, sub.member, class: "link link-hover" %> + + <%= sub.end_date.strftime("%d/%m") %> +
    + + <%= link_to new_sale_path(member_id: sub.member_id), class: "btn btn-sm btn-outline btn-block mt-2" do %> + Rinnova + <% end %>
    - <%= link_to new_sale_path(member_id: sub.member_id), class: "btn btn-xs btn-outline w-full mt-1" do %> - Rinnova Abbonamento - <% end %>
  • <% end %>
+ <% if @expiring_count > 5 %> -
- <%= link_to "Mostra altre (#{@expiring_count - 5})", subscriptions_path(filter: 'expiring'), class: "text-xs font-semibold link link-primary" %> +
+ <%= link_to "Mostra altre (#{@expiring_count - 5})", subscriptions_path(filter: 'expiring'), class: "btn btn-ghost btn-sm text-xs font-semibold" %>
<% end %> <% else %> -
- <%= icon("done_all", size: 24) %> - Nessuna urgenza. +
+ <%= icon("done_all", classes: "size-10 opacity-20") %> + Nessuna urgenza.
<% end %>
diff --git a/app/views/disciplines/index.html.erb b/app/views/disciplines/index.html.erb index c25407e..66431a2 100644 --- a/app/views/disciplines/index.html.erb +++ b/app/views/disciplines/index.html.erb @@ -8,7 +8,7 @@ <%= link_to [:new, :discipline], class: "btn btn-primary gap-2", data: { turbo_frame: "modal" } do %> - <%= icon("add", size: 20) %> + <%= icon("add") %> <% end %> <% end %> @@ -34,9 +34,9 @@ <% if @disciplines.empty? %> - <%= render "shared/empty_state", - icon_name: "group_off", - title: t("search.results.zero"), + <%= render "shared/empty_state", + icon_name: "group_off", + title: t("search.results.zero"), message: t("search.results.hint") %> <% end %> diff --git a/app/views/feedbacks/new.html.erb b/app/views/feedbacks/new.html.erb index 0ad4ee8..76242c4 100644 --- a/app/views/feedbacks/new.html.erb +++ b/app/views/feedbacks/new.html.erb @@ -1,6 +1,6 @@ <% content_for :title do %>
- <%= icon("contact_support", size: 24, class: "text-base-content") %> + <%= icon("contact_support", classes: "size-10 text-base-content") %> Feedback & Supporto
<% end %> diff --git a/app/views/members/_context.html.erb b/app/views/members/_context.html.erb new file mode 100644 index 0000000..956b600 --- /dev/null +++ b/app/views/members/_context.html.erb @@ -0,0 +1,81 @@ +<%# app/views/members/_context.html.erb %> +<%# --- SIDEBAR DINAMICA --- %> +<% content_for :member_actions do %> + +<% end %> + +<%# --- HEADER DEL RECORD --- %> +<% content_for :record_avatar do %> + <%= ui_avatar(member) %> +<% end %> + +<% content_for :record_title, member.full_name %> + +<% content_for :record_subtitle do %> + <% if member.discarded? %> + <%= ui_badge("Archiviato", style: "error") %> + <% end %> +<% end %> + +<% content_for :record_actions do %> +
+ <%= link_to new_sale_path(member_id: member.id), + class: "btn btn-primary btn-sm gap-2", + data: { turbo_frame: "modal" } do %> + <%= icon("shopping_cart") %> + <% end %> + + <%= render layout: "shared/dropdown_layout", locals: { + group_name: "member_header_dropdowns", + summary_class: "btn-sm btn-square btn-ghost", + icon_name: "more", + with_no_arrow: true + } do %> +
  • + <%= link_to edit_member_path(member), + data: { turbo_frame: "modal" } do %> + <%= icon("edit") %> Modifica Anagrafica + <% end %> +
  • +
  • + <%= link_to member_path(member), + class: "text-error hover:bg-error/10 hover:text-error", + data: { + turbo_method: :delete, + turbo_confirm: "Sei sicuro?" + } do %> + <%= icon("delete") %> Archivia Socio + <% end %> +
  • + <% end %> +
    +<% end %> + +<%= render "shared/header/record" %> diff --git a/app/views/members/_member_row.html.erb b/app/views/members/_member_row.html.erb index abe7ac8..c0c41cc 100644 --- a/app/views/members/_member_row.html.erb +++ b/app/views/members/_member_row.html.erb @@ -1,24 +1,18 @@ -
  • +
  • - <%# -- Colonna 1: Avatar-- %> -
    -
    -
    - <%= member.initials %> -
    -
    + <%# -- Colonna 1: Avatar con Helper -- %> +
    + <%= ui_avatar(member, size: "size-10", text_size: "text-xs") %>
    <%# -- Colonna 2: Contenuto centrale -- %> -
    - +
    <%# Riga A: Nome %>
    - <%= link_to member.full_name, member_path(member), data: { turbo_frame: "_top" }, class: "hover:underline hover:text-primary transition-colors" %> - <% if member.discarded? %> - Archiviato - <% end %> + <%= link_to member.full_name, member, data: { turbo_frame: "_top" }, class: "hover:underline hover:text-primary transition-colors" %> + <%= ui_badge("Archiviato") if member.discarded? %>
    <%# Riga B: Dati fiscali e Status %> @@ -31,74 +25,54 @@ <% unless member.medical_certificate_valid? %> - <%= icon("warning", size: 14) %> Cert. Medico + <%= icon("warning", classes: "size-4") %> Cert. Medico <% end %>
    <%# Riga C: Abbonamenti & Quick Actions %>
    - <% subs = member.relevant_subscriptions %> - - <% if subs.any? %> - <% subs.each do |sub| %> - <% - days_left = (sub.end_date - Date.current).to_i - is_expired = days_left < 0 - is_expiring_soon = days_left.between?(0, 7) - needs_renewal = is_expired || is_expiring_soon - %> - - <% if needs_renewal %> -
    + <% if member.relevant_subscriptions.any? %> + <% member.relevant_subscriptions.each do |sub| %> + <% if sub.expired? || sub.expiring_soon? %> +
    <%= sub.product.name %> - <%= is_expired ? "SCADUTO" : "-#{days_left}gg" %> + <%= sub.expired? ? "SCADUTO" : "-#{sub.days_left}gg" %> - <%= link_to new_sale_path(member_id: member.id, renew_subscription_id: sub.id), class: "btn btn-ghost btn-xs btn-circle ml-0.5 hover:bg-current/20 text-current", - data: { turbo_frame: "modal" }, - title: "Rinnova al volo" do %> - <%= icon("reset", size: 14) %> + data: { turbo_frame: "modal" }, title: "Rinnova al volo" do %> + <%= icon("reset", classes: "size-4") %> <% end %>
    <% else %>
    <%= sub.product.name %> - <%= days_left %>gg + <%= sub.days_left %>gg
    <% end %> <% end %> <% else %> - - Nessuna attività + Nessuna attività <% end %>
    - <%# -- Colonne 3 & 4: Azioni (DaisyUI le posizionerà una a fianco all'altra centrate) -- %> + <%# -- Colonne 3: Azioni -- %> <% unless member.discarded? %> - <%= link_to edit_member_path(member), - class: "btn btn-square btn-ghost text-base-content/40 hover:text-primary", - data: { turbo_frame: "modal" }, - aria: { label: "Modifica #{member.full_name}" }, - title: "Modifica" do %> - <%= icon("edit", size: 20) %> - <% end %> +
    + <%= link_to edit_member_path(member), class: "btn btn-square btn-ghost text-base-content/40 hover:text-primary", data: { turbo_frame: "modal" }, title: "Modifica" do %> + <%= icon("edit") %> + <% end %> - <% if current_user.admin? %> - <%= button_to member_path(member), - method: :delete, - class: "btn btn-square btn-ghost text-base-content/30 hover:text-error hover:bg-error/10", - form: { data: { turbo_confirm: "Sei sicuro di voler archiviare #{member.full_name}?" } }, - aria: { label: "Archivia #{member.full_name}" }, - title: "Archivia" do %> - <%= icon("delete", size: 20) %> + <% if current_user.admin? %> + <%= button_to member, method: :delete, class: "btn btn-square btn-ghost text-base-content/30 hover:text-error hover:bg-error/10", form: { data: { turbo_confirm: "Sei sicuro?" } }, title: "Archivia" do %> + <%= icon("delete") %> + <% end %> <% end %> - <% end %> +
    <% end %> -
  • diff --git a/app/views/members/_shell.html.erb b/app/views/members/_shell.html.erb deleted file mode 100644 index 549055f..0000000 --- a/app/views/members/_shell.html.erb +++ /dev/null @@ -1,117 +0,0 @@ -<% content_for :member_actions do %> -
      -
    • - - -
        -
      • - <%= active_link_to member_path(member), active: :exclusive do %> - <%= icon("badge", size: 18) %> - <%= t("members.sidebar.details", default: "Panoramica") %> - <% end %> -
      • - -
      • - <%= active_link_to member_subscriptions_path(member) do %> - <%= icon("card_membership", size: 18) %> - <%= t("members.sidebar.subscriptions", default: "Abbonamenti") %> - <% end %> -
      • - - <% if current_user.admin? %> -
      • - <%= active_link_to member_sales_path(member) do %> - <%= icon("receipt", size: 18) %> - <%= t("members.sidebar.sales", default: "Storico Acquisti") %> - <% end %> -
      • - <% end %> - - - <% if nil %> -
      • - <%= active_link_to member_access_logs_path(member) do %> - <%= icon("history", size: 18) %> - <%= t("members.sidebar.access_logs", default: "Ingressi") %> - <% end %> -
      • - <% end %> -
      -
    • -
    -<% end %> - -<%# --- 2. HEADER DEL RECORD (Avatar e Titolo) --- %> - -<% content_for :record_avatar do %> -
    -
    - <%= member.initials %> -
    -
    -<% end %> - -<% content_for :record_title, member.full_name %> - -<% content_for :record_subtitle do %> -
    -
    - <%= icon("calendar_today", size: 12) %> - <%= t("members.joined", default: "Dal") %> - <%= format_date(member.created_at) %> -
    - - <% if member.discarded? %> - ARCHIVIATO - <% end %> -
    -<% end %> - -<% content_for :record_actions do %> -
    - <%= link_to new_sale_path(member_id: member.id), - class: "btn btn-primary btn-sm gap-2", - data: { turbo_frame: "modal" } do %> - <%= icon("shopping_cart", size: 20) %> - - <% end %> - - <% trigger_button = capture do %> -
    <%= icon("more", size: 20) %>
    - <% end %> - - <%= render "shared/dropdown", trigger: trigger_button do %> -
  • - <%= link_to edit_member_path(member), data: { turbo_frame: "modal" } do %> - <%= icon("edit", size: 16) %> - Modifica Anagrafica - <% end %> -
  • - -
    - -
  • - <%= link_to member_path(member), - class: "text-error hover:bg-error/10", - data: { turbo_method: :delete, turbo_confirm: "Sei sicuro di voler archiviare questo socio?" } do %> - <%= icon("delete", size: 16) %> - Archivia Socio - <% end %> -
  • - <% end %> -
    -<% end %> - -<%= render "shared/header/record" %> - -
    <%= yield %>
    diff --git a/app/views/members/access_logs/index.html.erb b/app/views/members/access_logs/index.html.erb index 44fdec0..90ae335 100644 --- a/app/views/members/access_logs/index.html.erb +++ b/app/views/members/access_logs/index.html.erb @@ -1,4 +1,39 @@ -
    -

    Members::AccessLogs#index

    -

    Find me in app/views/members/access_logs/index.html.erb

    +<%= turbo_stream_from @member %> +<%= render "members/context", member: @member %> + +
    +

    + <%= icon("history", classes: "size-5 opacity-50") %> Storico Accessi +

    + +
      +
    • + Ultimi 100 Ingressi +
    • + + <% @access_logs.each do |log| %> +
    • +
      +
      + <%= icon("login") %> +
      +
      + +
      +
      Tornello Principale
      +
      + <%= l(log.created_at.to_date, format: :short) %> alle <%= l(log.created_at, format: "%H:%M") %> +
      +
      + +
      + Consentito +
      +
    • + <% end %> + + <% if @access_logs.empty? %> +
    • Nessun accesso registrato finora.
    • + <% end %> +
    diff --git a/app/views/members/index.html.erb b/app/views/members/index.html.erb index 55ae0fc..1586ccf 100644 --- a/app/views/members/index.html.erb +++ b/app/views/members/index.html.erb @@ -8,7 +8,7 @@ <%= link_to [:new, :member], class: "btn btn-primary gap-2", data: { turbo_frame: "modal" } do %> - <%= icon("add", size: 20) %> + <%= icon("add") %> <% end %> <% end %> @@ -26,7 +26,7 @@
    diff --git a/app/views/members/sales/_sale_row.html.erb b/app/views/members/sales/_sale_row.html.erb index 67a3200..2ba45cc 100644 --- a/app/views/members/sales/_sale_row.html.erb +++ b/app/views/members/sales/_sale_row.html.erb @@ -1,70 +1,53 @@ - - <%= format_date(sale.sold_on) %> - - - <% if sale.receipt_number.present? %> - <%= sale.receipt_code %> - <% else %> - - - <% end %> - - - -
    <%= sale.product_name_snapshot %>
    - +
  • + + <%# -- Icona Metodo di Pagamento -- %> +
    + <% icon_name = case sale.payment_method + when "cash" then "payments" + when "credit_card" then "credit_card" + when "bank_transfer" then "account_balance" + else "receipt" end %> +
    <%= icon(icon_name) %>
    +
    + + <%# -- Dati Principali -- %> +
    +
    + <%= sale.product_name_snapshot %> +
    +
    + <%= format_date(sale.sold_on) %> + <% if sale.receipt_number.present? %> + <%= sale.receipt_code %> + <% end %> +
    <% if sale.notes.present? %> -
    - <%= sale.notes %> -
    +
    <%= sale.notes %>
    <% end %> - - - - <% case sale.payment_method %> - <% when "cash" %> -
    - <%= icon("payments", size: 14) %> Contanti -
    - <% when "credit_card" %> -
    - <%= icon("credit_card", size: 14) %> Carta -
    - <% when "bank_transfer" %> -
    - <%= icon("account_balance", size: 14) %> Bonifico -
    - <% else %> -
    - <%= sale.payment_method.humanize %> -
    +
    + + <%# -- Importo & Operatore -- %> + + + <%# -- Azioni -- %> +
    +
    <%= format_money(sale.amount) %>
    + <%= link_to [sale], class: "btn btn-square btn-xs btn-ghost", title: "Dettaglio" do %> + <%= icon("view") %> <% end %> - - - <%= sale.user&.full_name || "Sistema" %> - - <%= format_money(sale.amount) %> - - - -
    - <%= link_to [sale], - class: "join-item btn btn-square btn-xs btn-ghost tooltip tooltip-left", - data: { tip: "Dettaglio" } do %> - <%= icon("view", size: 16) %> + <% if sale.created_at > 24.hours.ago %> + <%= link_to [sale], class: "btn btn-square btn-xs btn-ghost text-error hover:bg-error/20", + data: { + turbo_method: :delete, + turbo_confirm: "Annullare VENDITA e abbonamento? Irreversibile." + }, + title: "Annulla" do %> + <%= icon("delete") %> <% end %> - - <% if sale.created_at > 24.hours.ago %> - <%= link_to [sale], - class: "join-item text-error btn btn-square btn-xs btn-ghost tooltip tooltip-left", - data: { - tip: "Archivia", - turbo_method: :delete, - turbo_confirm: "Sei sicuro di voler annullare questa VENDITA?\n\nVerra annullato anche l'eventuale abbonamento associato.\n\nL'operazione è irreversibile." - } do %> - <%= icon("delete", size: 16) %> - <% end %> - <% end %> -
    - - + <% end %> +
    +
  • diff --git a/app/views/members/sales/index.html.erb b/app/views/members/sales/index.html.erb index bb75c9b..b633630 100644 --- a/app/views/members/sales/index.html.erb +++ b/app/views/members/sales/index.html.erb @@ -1,41 +1,29 @@ <%= turbo_stream_from @member %> +<%= render "members/context", member: @member %> -<%= render layout: "members/shell", locals: { member: @member } do %> -
    -

    Storico Acquisti

    +
    +

    + <%= icon("receipt", classes: "size-5 opacity-50") %> Storico Acquisti +

    -
    - <%= format_cents(@sales.sum(&:amount_cents)) %> - - - Totale speso - +
    +
    + <%= format_cents(@sales.sum(&:amount_cents) || 0) %>
    +
    Totale speso
    +
    - <%= render "shared/table", pagy: @pagy do %> - - - Data - Ricevuta - Prodotto / Descrizione - Metodo - Operatore - Importo - - - - - - <%= render partial: "sale_row", collection: @sales, as: :sale %> - - <% if @sales.empty? %> - - - Nessun acquisto registrato per questo membro. - - - <% end %> - +
      + <% if @sales.any? %> + <%= render partial: "sale_row", collection: @sales, as: :sale %> + <% else %> +
    • Nessun acquisto registrato.
    • <% end %> +
    + +<% if @pagy.pages > 1 %> +
    + <%== pagy_nav(@pagy) %> +
    <% end %> diff --git a/app/views/members/show.html.erb b/app/views/members/show.html.erb index bfa1c6a..c279554 100644 --- a/app/views/members/show.html.erb +++ b/app/views/members/show.html.erb @@ -1,362 +1,183 @@ <%= turbo_stream_from @member %> -<%= render layout: "members/shell", locals: { member: @member } do %> -
    - <%# --- COLONNA SINISTRA: DATI & STATO --- %> -
    - <%# 1. CERTIFICATO MEDICO %> -
    -
    -

    - <%= icon("med", size: 14) %> Certificato Medico -

    +<%= render "members/context", member: @member %> - <% if @member.medical_certificate_valid? %> -
    -
    - <%= icon("success", size: 20) %> - Valido -
    - -
    -
    Scade il
    - -
    - <%= format_date(@member.medical_certificate_expiry) %> -
    -
    -
    - <% else %> -
    - <%= icon("warning", size: 24) %> - -
    -
    Scaduto o Mancante
    -
    Accesso limitato.
    -
    -
    - <% if @member.medical_certificate_expiry %> -
    - Scaduto il: - <%= format_date(@member.medical_certificate_expiry) %> -
    - <% end %> - <% end %> - -
    - - <%= link_to edit_member_path(@member), - class: "btn btn-xs btn-outline w-full", - data: { turbo_frame: "modal" } do %> - Aggiorna Data - <% end %> -
    -
    - - <%# 2. CONTATTI %> -
    -
    -

    - <%= icon("badge", size: 14) %> Anagrafica -

    - -
    -
    -
    <%= icon("phone", size: 16) %>
    - -
    - <%= @member.phone.present? ? @member.phone : "-" %> -
    -
    - -
    -
    <%= icon("mail", size: 16) %>
    +<%# --- 3. CONTENUTO PRINCIPALE (La Grid) --- %> +
    -
    - <%= format_email(@member.email_address) %> -
    -
    + <%# -- COLONNA SINISTRA: DATI & STATO -- %> +
    -
    -
    <%= icon("home", size: 16) %>
    + <%# Certificato Medico %> +
    +
    +

    + <%= icon("med", classes: "size-4") %> Certificato Medico +

    -
    - <%= display_value(@member.full_address) %> -
    + <% if @member.medical_certificate_valid? %> +
    +
    + <%= icon("success") %> Valido
    - -
    -
    <%= icon("cake", size: 16) %>
    - -
    - <%= format_date(@member.birth_date) %> - - - (<%= time_ago_in_words(@member.birth_date) %>) - -
    +
    +
    Scade il
    +
    <%= format_date(@member.medical_certificate_expiry) %>
    - -
    -
    - <%= icon("fingerprint", size: 16) %> -
    - -
    <%= @member.fiscal_code %>
    +
    + <% else %> +
    + <%= icon("warning") %> +
    +
    Scaduto o Mancante
    +
    Accesso limitato.
    -
    + <% end %> + +
    + <%= link_to edit_member_path(@member), class: "btn btn-xs btn-outline w-full", data: { turbo_frame: "modal" } do %> + Aggiorna Data + <% end %>
    - <%# --- COLONNA DESTRA: OPERATIVITÀ (2/3 width) --- %> -
    - <%# 3. LISTA ABBONAMENTI (Attivi & Futuri) %> -
    -
    - <%# Header Card %> -
    -

    - <%= icon("credit_card", size: 18) %> Abbonamenti -

    + <%# Anagrafica %> +
    +
    +

    + <%= icon("badge") %> Anagrafica +

    +
      +
    • + <%= icon("phone") %> + <%= @member.phone.presence || "-" %> +
    • +
    • + <%= icon("mail") %> + + <%= format_email(@member.email_address) %> + +
    • +
    • + <%= icon("home") %> + <%= display_value(@member.full_address) %> +
    • +
    • + <%= icon("cake") %> + <%= format_date(@member.birth_date) %> (<%= time_ago_in_words(@member.birth_date) %>) +
    • +
    • + <%= icon("fingerprint") %> + <%= @member.fiscal_code %> +
    • +
    +
    +
    +
    - <%= link_to "Vedi Tutti", member_subscriptions_path(@member), class: "link link-hover text-xs opacity-60" %> -
    + <%# -- COLONNA DESTRA: OPERATIVITÀ (2/3 width) -- %> +
    -
    - <% - # LOGICA RUBY DI FILTRO (usa il tuo Concern SoftDeletable) - # 1. Filtriamo via i discarded usando il metodo .kept? del concern - # 2. Teniamo solo quelli che non sono ancora finiti (Active + Future) + <%# Abbonamenti (Utilizzo del componente 'list' nativo di DaisyUI) %> +
    +
    +

    + <%= icon("credit_card", size: 18) %> Abbonamenti +

    + <%= link_to "Vedi Tutti", member_subscriptions_path(@member), class: "link link-hover text-xs opacity-60" %> +
    - valid_subs = @member.subscriptions.select do |s| - s.kept? && s.end_date >= Date.current - end + <% valid_subs = @member.subscriptions.select { |s| s.kept? && s.end_date >= Date.current }.sort_by(&:start_date) %> - # Ordiniamo: Prima quelli che iniziano prima - sorted_subs = valid_subs.sort_by(&:start_date) + <% if valid_subs.any? %> +
      + <% valid_subs.each do |sub| %> + <% + is_future = sub.start_date > Date.current + days_left = (sub.end_date - Date.current).to_i + is_expiring_soon = !is_future && days_left.between?(0, 7) %> - - <% if sorted_subs.any? %> - <% sorted_subs.each do |sub| %> - <% today = Date.current - is_future = sub.start_date > today - days_left = (sub.end_date - today).to_i - is_expiring_soon = !is_future && days_left.between?(0, 7) - - # Classi dinamiche basate sullo stato - border_class = if is_future - "border-info/30 bg-info/5" - elsif is_expiring_soon - "border-warning/50 bg-warning/5" - else - "border-base-300 bg-base-100 hover:border-primary/30" - end %> - -
      - <%# Badge "Futuro" o "Scadenza" %> +
    • +
      +
      + <%= icon(is_future ? "clock" : "success", size: 24) %> +
      +
      +
      +
      <%= sub.product&.name %>
      +
      <% if is_future %> - - Futuro - - <% elsif is_expiring_soon %> - - Scade Presto - + Inizia il <%= l(sub.start_date) %> + <% else %> + Scade il <%= l(sub.end_date) %> <% end %> - - <%# Icona e Dettagli %> -
      -
      - <%= icon(is_future ? "clock" : "success", size: 24) %> -
      - -
      -

      - <%= sub.product&.name %> -

      - -
      - <% if is_future %> - - Inizia il <%= l(sub.start_date) %> - - - fino al <%= l(sub.end_date) %> - - <% else %> - - Scade il <%= l(sub.end_date) %> - - - Iniziato il <%= l(sub.start_date) %> - - <% end %> -
      -
      -
      - - <%# Azione (Rinnova solo se attivo) %> -
      - <% unless is_future %> - - <%= link_to new_sale_path(member_id: @member.id, renew_subscription_id: sub.id), - class: "btn btn-sm #{is_expiring_soon ? 'btn-primary' : 'btn-ghost border-base-300'}", - data: { turbo_frame: "modal" } do %> - <%= icon("reset", size: 16) %> - - <% end %> - <% else %> - <%# Tasto Modifica per Futuri %> - <%# = link_to edit_subscription_path(sub), class: "btn btn-square btn-ghost btn-sm opacity-50 hover:opacity-100", title: "Modifica date" do %> - <%# = icon("edit", size: 16) %> - <%# end %> - <% end %> -
      -
      - <% end %> - <% else %> - <%# Empty State %> -
      -
      - <%= icon("shopping_cart", size: 20) %>
      - -

      - Nessun servizio attivo -

      - -
      - <%= link_to new_sale_path(member_id: @member.id), - class: "btn btn-primary btn-sm btn-outline", - data: { turbo_frame: "modal" } do %> - Vendi Abbonamento +
      +
      + <% if is_future %> + Futuro + <% else %> + + <%= link_to new_sale_path(member_id: @member.id, renew_subscription_id: sub.id), + class: "btn btn-sm btn-square #{is_expiring_soon ? 'btn-primary' : 'btn-ghost'}", + data: { turbo_frame: "modal" }, title: "Rinnova" do %> + <%= icon("refresh", size: 18) %> <% end %> -
      + <% end %>
      +
    • + <% end %> +
    + <% else %> +
    +
    + <%= icon("shopping_cart", size: 20) %> +
    +

    Nessun servizio attivo

    +
    + <%= link_to new_sale_path(member_id: @member.id), class: "btn btn-primary btn-sm btn-outline", data: { turbo_frame: "modal" } do %> + Vendi Abbonamento <% end %>
    -
    - - <%# 4. ACQUISTI RECENTI %> -
    -
    -
    -

    - <%= icon("receipt", size: 16) %> Storico Acquisti -

    + <% end %> +
    - <%= link_to "Vedi Tutti", member_sales_path(@member), class: "link link-hover text-xs opacity-60" %> -
    + <%# ACQUISTI RECENTI %> +
    +
    +

    + <%= icon("receipt") %> Storico Acquisti +

    + <%= link_to "Vedi Tutti", member_sales_path(@member), class: "link link-hover text-xs opacity-60" %> +
    -
    - - - - - - + <% recent_sales = @member.sales.order(created_at: :desc).limit(5) %> + <% if recent_sales.any? %> +
    +
    DataProdottoImporto
    + + <% recent_sales.each do |sale| %> + + + + - - - - <%# Qui mostriamo semplicemente le ultime 5 vendite %> - <% @member.sales.order(created_at: :desc).limit(5).each do |sale| %> - - - - - - - - <% end %> - -
    <%= l(sale.sold_on) %> +
    <%= sale.product_name_snapshot %>
    +
    <%= sale.receipt_code %>
    +
    <%= number_to_currency(sale.try(:amount) || (sale.amount_cents / 100.0)) %>
    - <%= l(sale.sold_on) %> - -
    - <%= sale.product_name_snapshot %> -
    - -
    - <%= sale.receipt_code %> -
    -
    - <%= number_to_currency(sale.try(:amount) || (sale.amount_cents / 100.0)) %> -
    - - <% if @member.sales.empty? %> -
    - Nessun movimento registrato. -
    - <% end %> -
    + <% end %> + +
    -
    + <% else %> +
    Nessun movimento registrato.
    + <% end %>
    +
    -<% end %> +
    diff --git a/app/views/members/subscriptions/_subscription_row.html.erb b/app/views/members/subscriptions/_subscription_row.html.erb index 100aa75..ee377b4 100644 --- a/app/views/members/subscriptions/_subscription_row.html.erb +++ b/app/views/members/subscriptions/_subscription_row.html.erb @@ -1,88 +1,74 @@ -<% is_active = Date.current.between?(subscription.start_date, subscription.end_date) -is_future = Date.current < subscription.start_date -is_expired = Date.current > subscription.end_date %> +<% + is_active = Date.current.between?(subscription.start_date, subscription.end_date) + is_future = Date.current < subscription.start_date + is_expired = Date.current > subscription.end_date +%> - - - <% if is_active %> - Attivo - <% elsif is_future %> - Futuro - <% else %> - Scaduto - <% end %> - +
  • - <%= subscription.product.name %> - - -
    - - Dal: <%= format_date(subscription.start_date) %> - + <%# -- Icona Stato -- %> +
    +
    + <%= icon(is_active ? "check_circle" : (is_future ? "schedule" : "block")) %> +
    +
    - - Al:  <%= format_date(subscription.end_date) %> - + <%# -- Dati Principali -- %> +
    +
    + <%= subscription.product.name %> + <% if is_active %> + Attivo + <% elsif is_future %> + Futuro + <% else %> + Scaduto + <% end %>
    - - - <% if is_active %> -
    - <%= display_value((subscription.end_date - Date.current).to_i) %> -
    -
    - Giorni rimasti -
    - <% else %> - - <%= display_value((subscription.end_date - subscription.start_date).to_i) %> gg tot - - <% end %> - +
    + Dal <%= format_date(subscription.start_date) %> + al <%= format_date(subscription.end_date) %> +
    - <% if subscription.sale %> -
    - <%= icon("receipt", size: 16) %> - +
    <% if subscription.sale.receipt_code %> - - <%= link_to [ subscription.sale ], class: "link link-hover" do %> - <%= subscription.sale.receipt_code %> - <% end %> - + Rif: <%= link_to subscription.sale.receipt_code, [subscription.sale], class: "link link-hover" %> <% else %> - <%= format_datetime(subscription.sale.created_at, format: :long) %> + Acquistato il <%= format_date(subscription.sale.created_at) %> <% end %>
    <% end %> - +
    - -
    - <%= link_to [:edit, subscription], - class: "join-item btn btn-square btn-xs btn-ghost tooltip tooltip-left", - data: { turbo_frame: "modal", tip: "Modifica" } do %> - <%= icon("edit", size: 16) %> - <% end %> - <% if subscription.end_date >= 7.days.ago.to_date %> - <%= link_to [subscription], - class: "join-item text-error btn btn-square btn-xs btn-ghost tooltip tooltip-left", - data: { - tip: "Archivia", - turbo_method: :delete, - turbo_confirm: "SEI SICURO?\n\nEliminando l'abbonamento verrà annullata anche la VENDITA (incasso) di #{format_money(subscription.sale&.amount)}.\n\nContinuare?" - } do %> - <%= icon("delete", size: 16) %> - <% end %> + <%# -- Giorni Rimasti -- %> + + + <%# -- Azioni -- %> +
    + <%= link_to [:edit, subscription], class: "btn btn-square btn-xs btn-ghost", data: { turbo_frame: "modal" }, title: "Modifica" do %> + <%= icon("edit") %> + <% end %> + + <% if subscription.end_date >= 7.days.ago.to_date %> + <%= link_to [subscription], + class: "btn btn-square btn-xs btn-ghost text-error hover:bg-error/20", + data: { + turbo_method: :delete, + turbo_confirm: "Eliminando l'abbonamento annullerai l'incasso. Continuare?" + }, title: "Archivia" do %> + <%= icon("delete") %> <% end %> -
    - - + <% end %> +
    +
  • diff --git a/app/views/members/subscriptions/index.html.erb b/app/views/members/subscriptions/index.html.erb index 3e23205..2a5d9cc 100644 --- a/app/views/members/subscriptions/index.html.erb +++ b/app/views/members/subscriptions/index.html.erb @@ -1,32 +1,22 @@ <%= turbo_stream_from @member %> +<%= render "members/context", member: @member %> -<%= render layout: "members/shell", locals: { member: @member } do %> -
    -

    Storico Abbonamenti

    -
    - - <%= render "shared/table", pagy: @pagy do %> - - - Stato - Abbonamento - Periodo di Validità - Giorni - Riferimento Acquisto - - - - - - <%= render partial: "subscription_row", collection: @subscriptions, as: :subscription %> +
    +

    + <%= icon("card_membership", classes: "size-5 opacity-50") %> Storico Abbonamenti +

    +
    - <% if @subscriptions.empty? %> - - - Nessun abbonamento attivo o passato. - - - <% end %> - +
      + <% if @subscriptions.any? %> + <%= render partial: "subscription_row", collection: @subscriptions, as: :subscription %> + <% else %> +
    • Nessun abbonamento attivo o passato.
    • <% end %> +
    + +<% if @pagy.pages > 1 %> +
    + <%== pagy_nav(@pagy) %> +
    <% end %> diff --git a/app/views/shared/_dropdown_layout.html.erb b/app/views/shared/_dropdown_layout.html.erb index 548ba9c..ebd1558 100644 --- a/app/views/shared/_dropdown_layout.html.erb +++ b/app/views/shared/_dropdown_layout.html.erb @@ -8,7 +8,7 @@ aria-label="<%= local_assigns[:title] %>"> <% if local_assigns[:icon_name] %> - <%= icon(local_assigns[:icon_name], size: 16, class: "text-base-content/70") %> + <%= icon(local_assigns[:icon_name], classes: "size-5 text-base-content/70") %> <% end %> <% if local_assigns[:summary_text] %> @@ -17,7 +17,9 @@ <% end %> - <%= icon("arrow_drop_down", size: 16, class: "hidden opacity-60 sm:inline-block") %> + <% unless local_assigns[:with_no_arrow] %> + <%= icon("arrow_drop_down", classes: "size-5 hidden opacity-60 sm:inline-block") %> + <% end %>
    <% if is_active %> - <%= icon("check", size: 16) %> + <%= icon("check") %> <% end %> <% end %> diff --git a/app/views/shared/navbar/_theme_dropdown.html.erb b/app/views/shared/navbar/_theme_dropdown.html.erb index 281fafc..5132397 100644 --- a/app/views/shared/navbar/_theme_dropdown.html.erb +++ b/app/views/shared/navbar/_theme_dropdown.html.erb @@ -18,7 +18,7 @@ <%= theme %> <% if is_active %> - <%= icon("check", size: 16) %> + <%= icon("check") %> <% end %> <% end %> diff --git a/app/views/shared/navbar/_user_dropdown.html.erb b/app/views/shared/navbar/_user_dropdown.html.erb index 5a63f16..bff4056 100644 --- a/app/views/shared/navbar/_user_dropdown.html.erb +++ b/app/views/shared/navbar/_user_dropdown.html.erb @@ -7,7 +7,6 @@ menu_class: "menu dropdown-content bg-base-100 rounded-box shadow-lg mt-4 w-60 p-2 gap-1" } do %> - <%# Header non cliccabile nativo di DaisyUI %>
  • <%= link_to user_path(current_user), class: "flex items-center gap-3" do %> - <%= icon("user", size: 18, class: "opacity-70") %> + <%= icon("user", classes: "size-5 opacity-70") %> <%= t("navbar.my_profile", default: "Il mio profilo") %> <% end %>
  • @@ -25,7 +24,7 @@ <%= link_to session_path, class: "flex items-center gap-3 text-error hover:bg-error hover:text-error-content", data: { turbo_method: :delete } do %> - <%= icon("logout", size: 18) %> + <%= icon("logout") %> <%= t("navbar.logout", default: "Esci") %> <% end %> diff --git a/app/views/shared/sidebar/_admin.html.erb b/app/views/shared/sidebar/_admin.html.erb index 7053e89..5d20bff 100644 --- a/app/views/shared/sidebar/_admin.html.erb +++ b/app/views/shared/sidebar/_admin.html.erb @@ -1,9 +1,8 @@ -<%# Rimuoviamo gap custom, border, group, hover ecc. Lasciamo fare a 'menu' %>
    - <%# --- COLONNA DESTRA: OPERATIVITÀ --- %> -
    - <%# STATISTICHE ATTIVE (Solo Vendite per ora) %> -
    -
    -
    - <%= icon("sale", size: 40) %> -
    + <%# --- COLONNA DESTRA: OPERATIVITÀ --- %> +
    -
    Vendite Totali
    + <%# STATISTICHE %> +
    +

    + <%= icon("monitoring", classes: "size-4") %> Performance +

    +
    +
    +
    <%= icon("sale", classes: "size-8") %>
    +
    Vendite Registrate
    <%= @sales_count %>
    -
    Transazioni registrate da questo utente
    +
    - <%# AUDIT LOG (Date e Metadati) %> -
    -
    -

    - <%= icon("fingerprint", size: 18) %> Audit Log -

    - -
    - <%# Data Creazione %> -
    -
    - <%= icon("event", size: 20) %> -
    - -
    -
    - Data Creazione -
    - -
    - <%= format_datetime(@user.created_at, format: :long) %> -
    -
    -
    - - <%# Ultima Modifica %> -
    -
    - <%= icon("update", size: 20) %> -
    - -
    -
    - Ultima Modifica -
    - -
    - <%= format_datetime(@user.updated_at, format: :long) %> -
    -
    -
    + <%# AUDIT LOG %> +
    +

    + <%= icon("fingerprint", classes: "size-4") %> Audit Log +

    + +
      +
    • +
      <%= icon("event", classes: "size-5") %>
      +
      Creazione Profilo
      +
      + <%= format_datetime(@user.created_at, format: :long) %>
      -
    -
    + +
  • +
    <%= icon("update", classes: "size-5") %>
    +
    Ultima Modifica
    +
    + <%= format_datetime(@user.updated_at, format: :long) %> +
    +
  • +
    +
    -<% end %> +
    diff --git a/config/application.rb b/config/application.rb index 8e4c951..f7be746 100644 --- a/config/application.rb +++ b/config/application.rb @@ -6,7 +6,7 @@ # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) -module ActiveCoreMinimal +module ActiveCore class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 8.1 diff --git a/config/deploy.yml b/config/deploy.yml index a57d52e..3925287 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -52,7 +52,7 @@ env: # WEB_CONCURRENCY: 2 # Match this to any external database server to configure Active Record correctly - # Use active_core_minimal-db for a db accessory server on same machine via local kamal docker network. + # Use active-core-db for a db accessory server on same machine via local kamal docker network. # DB_HOST: 192.168.0.2 # Log everything from Rails diff --git a/db/structure.sql b/db/structure.sql index e772cfb..82547c8 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1,80 +1,80 @@ -CREATE TABLE IF NOT EXISTS "disciplines" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "name" varchar NOT NULL, "requires_medical_certificate" boolean DEFAULT TRUE NOT NULL, "requires_membership" boolean DEFAULT TRUE NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE INDEX "index_disciplines_on_discarded_at" ON "disciplines" ("discarded_at") /*application='ActiveCoreMinimal'*/; -CREATE UNIQUE INDEX "index_disciplines_on_name" ON "disciplines" ("name") WHERE discarded_at IS NULL /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "gym_profiles" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "address_line_1" varchar, "address_line_2" varchar, "bank_iban" varchar, "city" varchar, "created_at" datetime(6) NOT NULL, "email" varchar, "name" varchar, "phone" varchar, "updated_at" datetime(6) NOT NULL, "vat_number" varchar, "zip_code" varchar); -CREATE TABLE IF NOT EXISTS "products" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "accounting_category" varchar DEFAULT 'institutional' NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "duration_days" integer NOT NULL, "name" varchar NOT NULL, "price_cents" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE INDEX "index_products_on_discarded_at" ON "products" ("discarded_at") /*application='ActiveCoreMinimal'*/; -CREATE UNIQUE INDEX "index_products_on_name" ON "products" ("name") WHERE discarded_at IS NULL /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "receipt_counters" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "last_number" integer DEFAULT 0 NOT NULL, "sequence_category" varchar NOT NULL, "updated_at" datetime(6) NOT NULL, "year" integer NOT NULL); -CREATE UNIQUE INDEX "index_receipt_counters_on_year_and_sequence_category" ON "receipt_counters" ("year", "sequence_category") /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "activity_logs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "action" varchar NOT NULL, "changes_set" json DEFAULT '{}', "created_at" datetime(6) NOT NULL, "subject_id" integer NOT NULL, "subject_type" varchar NOT NULL, "updated_at" datetime(6) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "fk_rails_c9badf82db" -FOREIGN KEY ("user_id") - REFERENCES "users" ("id") -); -CREATE INDEX "index_activity_logs_on_subject" ON "activity_logs" ("subject_type", "subject_id") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_activity_logs_on_user_id_and_created_at" ON "activity_logs" ("user_id", "created_at") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_activity_logs_on_user_id" ON "activity_logs" ("user_id") /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "feedbacks" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "admin_notes" text, "browser_info" varchar, "created_at" datetime(6) NOT NULL, "message" text NOT NULL, "page_url" varchar, "status" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "fk_rails_c57bb6cf28" +CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY); +CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE TABLE IF NOT EXISTS "sessions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "ip_address" varchar, "user_agent" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_758836b4f0" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ); -CREATE INDEX "index_feedbacks_on_status" ON "feedbacks" ("status") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_feedbacks_on_user_id" ON "feedbacks" ("user_id") /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "product_disciplines" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discipline_id" integer NOT NULL, "product_id" integer NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_78b6087a54" -FOREIGN KEY ("discipline_id") - REFERENCES "disciplines" ("id") -, CONSTRAINT "fk_rails_3e95f394f9" +CREATE INDEX "index_sessions_on_user_id" ON "sessions" ("user_id") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "disciplines" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "requires_medical_certificate" boolean DEFAULT TRUE NOT NULL, "requires_membership" boolean DEFAULT TRUE NOT NULL, "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE INDEX "index_disciplines_on_discarded_at" ON "disciplines" ("discarded_at") /*application='ActiveCore'*/; +CREATE UNIQUE INDEX "index_disciplines_on_name" ON "disciplines" ("name") WHERE discarded_at IS NULL /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "products" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "price_cents" integer DEFAULT 0 NOT NULL, "duration_days" integer NOT NULL, "accounting_category" varchar DEFAULT 'institutional' NOT NULL, "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE INDEX "index_products_on_discarded_at" ON "products" ("discarded_at") /*application='ActiveCore'*/; +CREATE UNIQUE INDEX "index_products_on_name" ON "products" ("name") WHERE discarded_at IS NULL /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "product_disciplines" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "product_id" integer NOT NULL, "discipline_id" integer NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_3e95f394f9" FOREIGN KEY ("product_id") REFERENCES "products" ("id") +, CONSTRAINT "fk_rails_78b6087a54" +FOREIGN KEY ("discipline_id") + REFERENCES "disciplines" ("id") ); -CREATE INDEX "index_product_disciplines_on_discipline_id" ON "product_disciplines" ("discipline_id") /*application='ActiveCoreMinimal'*/; -CREATE UNIQUE INDEX "index_product_disciplines_on_product_id_and_discipline_id" ON "product_disciplines" ("product_id", "discipline_id") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_product_disciplines_on_product_id" ON "product_disciplines" ("product_id") /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "sales" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "amount_cents" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "member_id" integer NOT NULL, "notes" text, "payment_method" integer DEFAULT 0 NOT NULL, "product_id" integer NOT NULL, "product_name_snapshot" varchar NOT NULL, "receipt_code" varchar GENERATED ALWAYS AS (receipt_year || '-' || receipt_sequence || '-' || receipt_number) STORED, "receipt_number" integer, "receipt_sequence" varchar, "receipt_year" integer, "sold_on" date NOT NULL, "updated_at" datetime(6) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "fk_rails_afd82832c8" -FOREIGN KEY ("product_id") - REFERENCES "products" ("id") -, CONSTRAINT "fk_rails_935e249f94" +CREATE INDEX "index_product_disciplines_on_product_id" ON "product_disciplines" ("product_id") /*application='ActiveCore'*/; +CREATE INDEX "index_product_disciplines_on_discipline_id" ON "product_disciplines" ("discipline_id") /*application='ActiveCore'*/; +CREATE UNIQUE INDEX "index_product_disciplines_on_product_id_and_discipline_id" ON "product_disciplines" ("product_id", "discipline_id") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "sales" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "user_id" integer NOT NULL, "product_name_snapshot" varchar NOT NULL, "amount_cents" integer DEFAULT 0 NOT NULL, "payment_method" integer DEFAULT 0 NOT NULL, "sold_on" date NOT NULL, "notes" text, "receipt_sequence" varchar, "receipt_number" integer, "receipt_year" integer, "receipt_code" varchar GENERATED ALWAYS AS (receipt_year || '-' || receipt_sequence || '-' || receipt_number) STORED, "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_935e249f94" FOREIGN KEY ("member_id") REFERENCES "members" ("id") +, CONSTRAINT "fk_rails_afd82832c8" +FOREIGN KEY ("product_id") + REFERENCES "products" ("id") , CONSTRAINT "fk_rails_8e94f16ccc" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ); -CREATE INDEX "index_sales_on_discarded_at" ON "sales" ("discarded_at") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_sales_on_member_id" ON "sales" ("member_id") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_sales_on_product_id" ON "sales" ("product_id") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_sales_on_receipt_code" ON "sales" ("receipt_code") /*application='ActiveCoreMinimal'*/; -CREATE UNIQUE INDEX "idx_on_receipt_year_receipt_sequence_receipt_number_3689acdaf9" ON "sales" ("receipt_year", "receipt_sequence", "receipt_number") WHERE receipt_number IS NOT NULL /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_sales_on_sold_on" ON "sales" ("sold_on") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_sales_on_user_id" ON "sales" ("user_id") /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "sessions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "ip_address" varchar, "updated_at" datetime(6) NOT NULL, "user_agent" varchar, "user_id" integer NOT NULL, CONSTRAINT "fk_rails_758836b4f0" -FOREIGN KEY ("user_id") - REFERENCES "users" ("id") -); -CREATE INDEX "index_sessions_on_user_id" ON "sessions" ("user_id") /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "subscriptions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "end_date" date NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "sale_id" integer NOT NULL, "start_date" date NOT NULL, "suspension_days_count" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_52a3b81fce" -FOREIGN KEY ("product_id") - REFERENCES "products" ("id") -, CONSTRAINT "fk_rails_bfac3ecd2f" +CREATE INDEX "index_sales_on_member_id" ON "sales" ("member_id") /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_product_id" ON "sales" ("product_id") /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_user_id" ON "sales" ("user_id") /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_discarded_at" ON "sales" ("discarded_at") /*application='ActiveCore'*/; +CREATE UNIQUE INDEX "idx_on_receipt_year_receipt_sequence_receipt_number_3689acdaf9" ON "sales" ("receipt_year", "receipt_sequence", "receipt_number") WHERE receipt_number IS NOT NULL /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_receipt_code" ON "sales" ("receipt_code") /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_sold_on" ON "sales" ("sold_on") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "subscriptions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "sale_id" integer NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "suspension_days_count" integer DEFAULT 0 NOT NULL, "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_bfac3ecd2f" FOREIGN KEY ("member_id") REFERENCES "members" ("id") +, CONSTRAINT "fk_rails_52a3b81fce" +FOREIGN KEY ("product_id") + REFERENCES "products" ("id") , CONSTRAINT "fk_rails_bb36d9c2a0" FOREIGN KEY ("sale_id") REFERENCES "sales" ("id") ); -CREATE INDEX "index_subscriptions_on_discarded_at" ON "subscriptions" ("discarded_at") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_subscriptions_on_member_id_and_end_date" ON "subscriptions" ("member_id", "end_date") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_subscriptions_on_member_id" ON "subscriptions" ("member_id") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_subscriptions_on_product_id" ON "subscriptions" ("product_id") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_subscriptions_on_sale_id" ON "subscriptions" ("sale_id") /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY); -CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS "access_logs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "checkin_by_user_id" integer NOT NULL, "created_at" datetime(6) NOT NULL, "entered_at" datetime(6) NOT NULL, "medical_certificate_valid" boolean DEFAULT FALSE NOT NULL, "member_id" integer NOT NULL, "subscription_id" integer, "updated_at" datetime(6) NOT NULL, "discipline_id" integer, "status" integer DEFAULT 0 NOT NULL /*application='ActiveCoreMinimal'*/, CONSTRAINT "fk_rails_df50081f1b" -FOREIGN KEY ("subscription_id") - REFERENCES "subscriptions" ("id") -, CONSTRAINT "fk_rails_21592df11b" +CREATE INDEX "index_subscriptions_on_member_id" ON "subscriptions" ("member_id") /*application='ActiveCore'*/; +CREATE INDEX "index_subscriptions_on_product_id" ON "subscriptions" ("product_id") /*application='ActiveCore'*/; +CREATE INDEX "index_subscriptions_on_sale_id" ON "subscriptions" ("sale_id") /*application='ActiveCore'*/; +CREATE INDEX "index_subscriptions_on_discarded_at" ON "subscriptions" ("discarded_at") /*application='ActiveCore'*/; +CREATE INDEX "index_subscriptions_on_member_id_and_end_date" ON "subscriptions" ("member_id", "end_date") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "activity_logs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "action" varchar NOT NULL, "subject_type" varchar NOT NULL, "subject_id" integer NOT NULL, "changes_set" json DEFAULT '{}', "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_c9badf82db" +FOREIGN KEY ("user_id") + REFERENCES "users" ("id") +); +CREATE INDEX "index_activity_logs_on_user_id" ON "activity_logs" ("user_id") /*application='ActiveCore'*/; +CREATE INDEX "index_activity_logs_on_subject" ON "activity_logs" ("subject_type", "subject_id") /*application='ActiveCore'*/; +CREATE INDEX "index_activity_logs_on_user_id_and_created_at" ON "activity_logs" ("user_id", "created_at") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "feedbacks" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "message" text NOT NULL, "page_url" varchar, "browser_info" varchar, "status" integer DEFAULT 0 NOT NULL, "admin_notes" text, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_c57bb6cf28" +FOREIGN KEY ("user_id") + REFERENCES "users" ("id") +); +CREATE INDEX "index_feedbacks_on_user_id" ON "feedbacks" ("user_id") /*application='ActiveCore'*/; +CREATE INDEX "index_feedbacks_on_status" ON "feedbacks" ("status") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "receipt_counters" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "year" integer NOT NULL, "sequence_category" varchar NOT NULL, "last_number" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE UNIQUE INDEX "index_receipt_counters_on_year_and_sequence_category" ON "receipt_counters" ("year", "sequence_category") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "gym_profiles" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar, "address_line_1" varchar, "address_line_2" varchar, "zip_code" varchar, "city" varchar, "vat_number" varchar, "email" varchar, "phone" varchar, "bank_iban" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE TABLE IF NOT EXISTS "access_logs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "member_id" integer NOT NULL, "subscription_id" integer, "checkin_by_user_id" integer NOT NULL, "entered_at" datetime(6) NOT NULL, "medical_certificate_valid" boolean DEFAULT FALSE NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "discipline_id" integer, "status" integer DEFAULT 0 NOT NULL /*application='ActiveCore'*/, CONSTRAINT "fk_rails_21592df11b" FOREIGN KEY ("member_id") REFERENCES "members" ("id") +, CONSTRAINT "fk_rails_df50081f1b" +FOREIGN KEY ("subscription_id") + REFERENCES "subscriptions" ("id") , CONSTRAINT "fk_rails_1f32fe057e" FOREIGN KEY ("checkin_by_user_id") REFERENCES "users" ("id") @@ -82,25 +82,25 @@ FOREIGN KEY ("checkin_by_user_id") FOREIGN KEY ("discipline_id") REFERENCES "disciplines" ("id") ); -CREATE INDEX "index_access_logs_on_checkin_by_user_id" ON "access_logs" ("checkin_by_user_id") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_access_logs_on_entered_at" ON "access_logs" ("entered_at") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_access_logs_on_member_id_and_entered_at" ON "access_logs" ("member_id", "entered_at") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_access_logs_on_member_id" ON "access_logs" ("member_id") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_access_logs_on_subscription_id" ON "access_logs" ("subscription_id") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_access_logs_on_discipline_id" ON "access_logs" ("discipline_id") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_access_logs_on_status" ON "access_logs" ("status") /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "email_address" varchar NOT NULL, "first_name" varchar NOT NULL, "full_name" varchar GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL, "last_name" varchar NOT NULL, "password_digest" varchar NOT NULL, "preferences" json DEFAULT '{}', "role" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, "username" varchar NOT NULL); -CREATE INDEX "index_users_on_discarded_at" ON "users" ("discarded_at") /*application='ActiveCoreMinimal'*/; -CREATE UNIQUE INDEX "index_users_on_email_address" ON "users" ("email_address") WHERE discarded_at IS NULL /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_users_on_preferences" ON "users" ("preferences") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_users_on_role" ON "users" ("role") /*application='ActiveCoreMinimal'*/; -CREATE UNIQUE INDEX "index_users_on_username" ON "users" ("username") WHERE discarded_at IS NULL /*application='ActiveCoreMinimal'*/; -CREATE TABLE IF NOT EXISTS "members" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "address" varchar, "birth_date" date NOT NULL, "city" varchar, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "email_address" varchar, "first_name" varchar NOT NULL, "fiscal_code" varchar NOT NULL, "full_address" varchar GENERATED ALWAYS AS (address || ', ' || city || ' (' || zip_code || ')') VIRTUAL, "full_name" varchar GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED, "last_name" varchar NOT NULL, "medical_certificate_expiry" date, "phone" varchar, "updated_at" datetime(6) NOT NULL, "zip_code" varchar); -CREATE INDEX "index_members_on_discarded_at" ON "members" ("discarded_at") /*application='ActiveCoreMinimal'*/; -CREATE UNIQUE INDEX "index_members_on_fiscal_code" ON "members" ("fiscal_code") WHERE discarded_at IS NULL /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_members_on_full_address" ON "members" ("full_address") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_members_on_full_name" ON "members" ("full_name") /*application='ActiveCoreMinimal'*/; -CREATE INDEX "index_members_on_medical_certificate_expiry" ON "members" ("medical_certificate_expiry") /*application='ActiveCoreMinimal'*/; +CREATE INDEX "index_access_logs_on_member_id" ON "access_logs" ("member_id") /*application='ActiveCore'*/; +CREATE INDEX "index_access_logs_on_subscription_id" ON "access_logs" ("subscription_id") /*application='ActiveCore'*/; +CREATE INDEX "index_access_logs_on_checkin_by_user_id" ON "access_logs" ("checkin_by_user_id") /*application='ActiveCore'*/; +CREATE INDEX "index_access_logs_on_entered_at" ON "access_logs" ("entered_at") /*application='ActiveCore'*/; +CREATE INDEX "index_access_logs_on_member_id_and_entered_at" ON "access_logs" ("member_id", "entered_at") /*application='ActiveCore'*/; +CREATE INDEX "index_access_logs_on_discipline_id" ON "access_logs" ("discipline_id") /*application='ActiveCore'*/; +CREATE INDEX "index_access_logs_on_status" ON "access_logs" ("status") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email_address" varchar NOT NULL, "password_digest" varchar NOT NULL, "username" varchar NOT NULL, "first_name" varchar NOT NULL, "last_name" varchar NOT NULL, "full_name" varchar GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL, "role" integer DEFAULT 0 NOT NULL, "preferences" json DEFAULT '{}', "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE INDEX "index_users_on_preferences" ON "users" ("preferences") /*application='ActiveCore'*/; +CREATE INDEX "index_users_on_discarded_at" ON "users" ("discarded_at") /*application='ActiveCore'*/; +CREATE UNIQUE INDEX "index_users_on_email_address" ON "users" ("email_address") WHERE discarded_at IS NULL /*application='ActiveCore'*/; +CREATE UNIQUE INDEX "index_users_on_username" ON "users" ("username") WHERE discarded_at IS NULL /*application='ActiveCore'*/; +CREATE INDEX "index_users_on_role" ON "users" ("role") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "members" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "first_name" varchar NOT NULL, "last_name" varchar NOT NULL, "full_name" varchar GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL, "fiscal_code" varchar NOT NULL, "birth_date" date NOT NULL, "email_address" varchar, "phone" varchar, "address" varchar, "city" varchar, "zip_code" varchar, "full_address" varchar GENERATED ALWAYS AS (address || ', ' || city || ' (' || zip_code || ')') VIRTUAL, "medical_certificate_expiry" date, "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE INDEX "index_members_on_medical_certificate_expiry" ON "members" ("medical_certificate_expiry") /*application='ActiveCore'*/; +CREATE INDEX "index_members_on_discarded_at" ON "members" ("discarded_at") /*application='ActiveCore'*/; +CREATE UNIQUE INDEX "index_members_on_fiscal_code" ON "members" ("fiscal_code") WHERE discarded_at IS NULL /*application='ActiveCore'*/; +CREATE INDEX "index_members_on_full_name" ON "members" ("full_name") /*application='ActiveCore'*/; +CREATE INDEX "index_members_on_full_address" ON "members" ("full_address") /*application='ActiveCore'*/; CREATE VIRTUAL TABLE members_fts USING fts5( first_name, last_name, From 02a03585e3e0b1f75d12ec802856775919445c62 Mon Sep 17 00:00:00 2001 From: jcostd Date: Mon, 30 Mar 2026 19:33:47 +0200 Subject: [PATCH 09/34] Updated UI and Refresh logic --- app/controllers/concerns/authentication.rb | 15 ++++++++++++- app/models/access_log.rb | 7 ++++-- app/models/concerns/refreshable.rb | 8 +++++++ app/models/feedback.rb | 2 +- app/models/member.rb | 3 +-- app/models/product_discipline.rb | 2 +- app/models/sale.rb | 5 ++++- app/models/session.rb | 2 +- app/models/subscription.rb | 4 ++-- app/models/user.rb | 9 ++++++-- app/views/members/_context.html.erb | 3 --- app/views/members/index.html.erb | 4 ++-- app/views/shared/_empty_state.html.erb | 24 ++++++++++---------- app/views/users/_context.html.erb | 26 ++++++++++++++-------- 14 files changed, 75 insertions(+), 39 deletions(-) create mode 100644 app/models/concerns/refreshable.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 890c69c..2154b17 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -33,7 +33,20 @@ def resume_session end def find_session_by_cookie - Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + session = Session.find_by(id: cookies.signed[:session_id]) + return nil unless session + + if session.user.discarded? + session.destroy + return nil + end + + if session.updated_at < 30.days.ago + session.destroy + return nil + end + + session end def current_user diff --git a/app/models/access_log.rb b/app/models/access_log.rb index bbb1d2e..fbe35b4 100644 --- a/app/models/access_log.rb +++ b/app/models/access_log.rb @@ -1,7 +1,10 @@ class AccessLog < ApplicationRecord - belongs_to :member - belongs_to :subscription + include Refreshable + + belongs_to :member, touch: true + belongs_to :subscription, optional: true, touch: true belongs_to :checkin_by_user, class_name: "User" + belongs_to :discipline, optional: true before_validation :set_defaults diff --git a/app/models/concerns/refreshable.rb b/app/models/concerns/refreshable.rb new file mode 100644 index 0000000..d70f0d5 --- /dev/null +++ b/app/models/concerns/refreshable.rb @@ -0,0 +1,8 @@ +module Refreshable + extend ActiveSupport::Concern + + included do + broadcasts_refreshes + broadcasts_refreshes_to ->(record) { record.class.model_name.plural } + end +end diff --git a/app/models/feedback.rb b/app/models/feedback.rb index 3c8f2ad..409b761 100644 --- a/app/models/feedback.rb +++ b/app/models/feedback.rb @@ -1,5 +1,5 @@ class Feedback < ApplicationRecord - belongs_to :user + belongs_to :user, touch: true enum :status, { pending: 0, diff --git a/app/models/member.rb b/app/models/member.rb index 9b8fbf4..8442bbc 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -1,7 +1,6 @@ class Member < ApplicationRecord include FtsSearchable, SoftDeletable, Personable, HasAddress, Avatarable - - broadcasts_refreshes + include Refreshable normalizes :fiscal_code, with: ->(c) { c.strip.upcase } diff --git a/app/models/product_discipline.rb b/app/models/product_discipline.rb index c330491..1546ba1 100644 --- a/app/models/product_discipline.rb +++ b/app/models/product_discipline.rb @@ -1,6 +1,6 @@ class ProductDiscipline < ApplicationRecord belongs_to :product, touch: true - belongs_to :discipline + belongs_to :discipline, touch: true validates :product_id, uniqueness: { scope: :discipline_id, diff --git a/app/models/sale.rb b/app/models/sale.rb index 5b22fb7..569ad0e 100644 --- a/app/models/sale.rb +++ b/app/models/sale.rb @@ -1,12 +1,15 @@ class Sale < ApplicationRecord include SubscriptionIssuer, FiscalLockable, Monetizable, Trackable, SoftDeletable + include Refreshable monetize :amount - belongs_to :member + belongs_to :member, touch: true belongs_to :user belongs_to :product + has_many :subscriptions + enum :payment_method, { cash: 1, credit_card: 2, bank_transfer: 3, other: 4 }, default: :credit_card, validate: true diff --git a/app/models/session.rb b/app/models/session.rb index e297942..e02018b 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,5 +1,5 @@ class Session < ApplicationRecord - belongs_to :user + belongs_to :user, touch: true def self.sweep(duration = 30.days) where(updated_at: ...duration.ago).delete_all diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 813980a..b00930a 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -1,9 +1,9 @@ class Subscription < ApplicationRecord include SoftDeletable, DateRangeable - belongs_to :member + belongs_to :member, touch: true + belongs_to :sale, inverse_of: :subscription, touch: true belongs_to :product - belongs_to :sale, inverse_of: :subscription validates :member, :product, :sale, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index 0b72394..14cd70c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,10 +1,10 @@ class User < ApplicationRecord include SoftDeletable, Personable, UserPreferences, Avatarable - - broadcasts_refreshes + include Refreshable has_secure_password has_many :sessions, dependent: :destroy + after_discard :terminate_all_sessions has_many :sales, dependent: :restrict_with_error has_many :feedbacks, dependent: :restrict_with_error @@ -29,4 +29,9 @@ class User < ApplicationRecord term: term ) } + + private + def terminate_all_sessions + sessions.delete_all + end end diff --git a/app/views/members/_context.html.erb b/app/views/members/_context.html.erb index 956b600..cd56adc 100644 --- a/app/views/members/_context.html.erb +++ b/app/views/members/_context.html.erb @@ -1,5 +1,3 @@ -<%# app/views/members/_context.html.erb %> -<%# --- SIDEBAR DINAMICA --- %> <% content_for :member_actions do %> <% end %> -<%# --- HEADER DEL RECORD --- %> <% content_for :record_avatar do %> <%= ui_avatar(member) %> <% end %> diff --git a/app/views/members/index.html.erb b/app/views/members/index.html.erb index dc68e26..ce2a957 100644 --- a/app/views/members/index.html.erb +++ b/app/views/members/index.html.erb @@ -85,8 +85,8 @@ <% if @members.empty? %> <%= render "shared/empty_state", icon_name: "filter_off", - title: t("search.results.zero", default: "Nessun socio trovato"), - message: t("search.results.hint", default: "Prova a cambiare i criteri di ricerca o rimuovere i filtri.") %> + title: "Nessun socio trovato", + message: "Prova a cambiare i criteri di ricerca o rimuovere i filtri." %> <% else %> <%# 2. Feedback contestuale della ricerca %> diff --git a/app/views/shared/_empty_state.html.erb b/app/views/shared/_empty_state.html.erb index 9d9b0c2..22a0510 100644 --- a/app/views/shared/_empty_state.html.erb +++ b/app/views/shared/_empty_state.html.erb @@ -1,19 +1,19 @@ -<%# app/views/shared/_empty_state.html.erb %> -<%# - Mostra un messaggio per quando non ci sono dati. - Riceve `icon_name`, `title`, `message` e opzionalmente un blocco per le azioni. -%> +
    -
    -
    - <%= icon(icon_name, size: 24, class: "text-base-content/50") %> -
    + <%= icon(icon_name, classes: "size-16 text-base-content/20 mb-6") %> -

    <%= title %>

    +

    + <%= title %> +

    -

    <%= message %>

    +

    + <%= message %> +

    <% if block_given? %> -
    <%= yield %>
    +
    + <%= yield %> +
    <% end %> +
    diff --git a/app/views/users/_context.html.erb b/app/views/users/_context.html.erb index 5749b4e..24c3bc0 100644 --- a/app/views/users/_context.html.erb +++ b/app/views/users/_context.html.erb @@ -1,3 +1,17 @@ +<% content_for :user_actions do %> +
      + + +
    • + <%= active_link_to user_path(user), active: :exclusive do %> + <%= icon("user") %> Profilo + <% end %> +
    • +
    +<% end %> + <% content_for :record_avatar do %> <%= ui_avatar(user) %> <% end %> @@ -10,20 +24,14 @@ <% end %> <% end %> - -<%# Usiamo il content_for corretto e i badge classici di daisy %> <% content_for :record_status_badges do %>
    @<%= user.username %>
    - <% if user.discarded? %> -
    Archiviato
    - <% else %> -
    - <%= user.role %> -
    - <% end %> +
    + <%= user.role %> +
    <% end %> <% content_for :record_actions do %> From 1137d9e675e5806b6b683a22a81563995525972d Mon Sep 17 00:00:00 2001 From: jcostd Date: Mon, 30 Mar 2026 21:01:59 +0200 Subject: [PATCH 10/34] Fixed safari glitch --- .../controllers/autosubmit_controller.js | 1 - app/views/sales/_form.html.erb | 24 +++++++++---------- app/views/sales/new.html.erb | 2 -- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/app/javascript/controllers/autosubmit_controller.js b/app/javascript/controllers/autosubmit_controller.js index 812bf08..cb24f3d 100644 --- a/app/javascript/controllers/autosubmit_controller.js +++ b/app/javascript/controllers/autosubmit_controller.js @@ -1,4 +1,3 @@ -// app/javascript/controllers/autosubmit_controller.js import { Controller } from "@hotwired/stimulus" import { debounce } from "utils/debounce" diff --git a/app/views/sales/_form.html.erb b/app/views/sales/_form.html.erb index 05be055..e44549d 100644 --- a/app/views/sales/_form.html.erb +++ b/app/views/sales/_form.html.erb @@ -14,10 +14,10 @@ <%= render "shared/form_errors", model: sale %> <%# SEZIONE 1: Cliente %> -
    - +
    +
    <%= icon("person_search", classes: "size-6 opacity-40") %> Anagrafica Cliente - +
    <%= f.hidden_field :member_id, data: { autocomplete_target: "hidden", action: "change->autosubmit#submit" } %> @@ -33,13 +33,13 @@
    -
    +
    <%# SEZIONE 2: Prodotto %> -
    - +
    +
    <%= icon("shopping_bag", classes: "size-6 opacity-40") %> Dettagli Acquisto - +
    @@ -59,13 +59,13 @@ <%= f.select :payment_method, Sale.payment_methods.keys, {}, class: "select w-full", data: { action: "change->autosubmit#submit" } %>
    -
    +
    <%# SEZIONE 3: Iscrizione %> -
    - +
    +
    <%= icon("calendar_today", classes: "size-6 opacity-40") %> Validità Iscrizione - +
    <%= f.fields_for :subscription do |sub_f| %>
    @@ -79,7 +79,7 @@
    <% end %> -
    +
    diff --git a/app/views/sales/new.html.erb b/app/views/sales/new.html.erb index b4c7247..60c4e3c 100644 --- a/app/views/sales/new.html.erb +++ b/app/views/sales/new.html.erb @@ -1,5 +1,3 @@ -<%# app/views/sales/new.html.erb %> - <% content_for :title, "Terminale POS" %> <% content_for :modal_class, "max-w-5xl w-11/12" %> From 9e05918908fe24024f12b78b5bcea1d534588725 Mon Sep 17 00:00:00 2001 From: jcostd Date: Tue, 31 Mar 2026 18:11:14 +0200 Subject: [PATCH 11/34] Updated UI and refactoring --- app/assets/tailwind/application.css | 2 +- app/controllers/concerns/filterable.rb | 16 +++ .../concerns/filterable_actions.rb | 31 ----- app/controllers/concerns/localizable.rb | 2 +- app/controllers/concerns/themable.rb | 6 +- app/controllers/disciplines_controller.rb | 25 ++-- app/controllers/members_controller.rb | 8 +- .../preferences/languages_controller.rb | 6 +- .../preferences/themes_controller.rb | 2 +- app/controllers/users_controller.rb | 7 +- app/helpers/ui_helper.rb | 26 +++++ .../controllers/drawer_controller.js | 35 ++++++ app/models/concerns/user_preferences.rb | 22 ++-- app/models/discipline.rb | 4 +- app/queries/disciplines_query.rb | 17 +++ app/queries/members_query.rb | 42 ++----- .../disciplines/_discipline_row.html.erb | 102 ++++++----------- app/views/disciplines/index.html.erb | 107 +++++++++++++----- app/views/layouts/application.html.erb | 2 +- app/views/members/_member_row.html.erb | 15 ++- app/views/members/index.html.erb | 16 ++- app/views/shared/filter/_drawer.html.erb | 53 ++++----- .../shared/navbar/_theme_dropdown.html.erb | 2 +- app/views/users/index.html.erb | 6 +- lib/tasks/maintenance.rake | 28 +++++ 25 files changed, 321 insertions(+), 261 deletions(-) create mode 100644 app/controllers/concerns/filterable.rb delete mode 100644 app/controllers/concerns/filterable_actions.rb create mode 100644 app/javascript/controllers/drawer_controller.js create mode 100644 app/queries/disciplines_query.rb create mode 100644 lib/tasks/maintenance.rake diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 9b30276..cce29fe 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -3,5 +3,5 @@ @source not "./daisyui{,*}.mjs"; @plugin "./daisyui.mjs" { - themes: all; + themes: corporate --default, business --prefersdark, nord, light, dark, dim; } diff --git a/app/controllers/concerns/filterable.rb b/app/controllers/concerns/filterable.rb new file mode 100644 index 0000000..4cf1628 --- /dev/null +++ b/app/controllers/concerns/filterable.rb @@ -0,0 +1,16 @@ +module Filterable + extend ActiveSupport::Concern + + included do + helper_method :filtering? + end + + private + def filtering? + filter_params.to_h.except(:sort).reject { |_, v| v.blank? }.any? + end + + def filter_params + params.permit(:query, :sort, :state) + end +end diff --git a/app/controllers/concerns/filterable_actions.rb b/app/controllers/concerns/filterable_actions.rb deleted file mode 100644 index 4db6548..0000000 --- a/app/controllers/concerns/filterable_actions.rb +++ /dev/null @@ -1,31 +0,0 @@ -module FilterableActions - extend ActiveSupport::Concern - - included do - helper_method :current_filter_params - end - - def filter_and_paginate(scope, filter_namespace: :query) - model_class = scope.model - - @available_filters = model_class.available_filters - @available_sorts = model_class.available_sorts - - flat_params = extract_flat_params(model_class, filter_namespace) - filtered_scope = scope.merge(model_class.apply_filters(flat_params)) - - pagy(filtered_scope) - end - - private - def extract_flat_params(model_class, namespace) - permitted_keys = model_class.available_filters.map { |f| f[:key] } - namespace_params = params.fetch(namespace, {}).permit(permitted_keys) - sort_params = params.permit(:sort, :direction) - namespace_params.merge(sort_params) - end - - def current_filter_params - @_current_filter_params ||= params.fetch(:query, {}).to_unsafe_h - end -end diff --git a/app/controllers/concerns/localizable.rb b/app/controllers/concerns/localizable.rb index aa6ac24..8f3071d 100644 --- a/app/controllers/concerns/localizable.rb +++ b/app/controllers/concerns/localizable.rb @@ -7,7 +7,7 @@ module Localizable private def switch_locale(&action) - locale = current_user&.locale_or_default || I18n.default_locale + locale = current_user&.locale || I18n.default_locale I18n.with_locale(locale, &action) end diff --git a/app/controllers/concerns/themable.rb b/app/controllers/concerns/themable.rb index 01e6614..931726d 100644 --- a/app/controllers/concerns/themable.rb +++ b/app/controllers/concerns/themable.rb @@ -2,11 +2,11 @@ module Themable extend ActiveSupport::Concern included do - before_action :set_theme + helper_method :current_theme end private - def set_theme - @theme = current_user&.theme_or_default || "light" + def current_theme + current_user&.theme || "corporate" end end diff --git a/app/controllers/disciplines_controller.rb b/app/controllers/disciplines_controller.rb index 913b6ee..1c0ccb6 100644 --- a/app/controllers/disciplines_controller.rb +++ b/app/controllers/disciplines_controller.rb @@ -1,8 +1,13 @@ class DisciplinesController < ApplicationController + include Filterable + before_action :set_discipline, only: [ :show, :edit, :update, :destroy ] + layout "modal", only: [ :new, :create, :edit, :update ] + def index - @pagy, @disciplines = pagy(Discipline.kept.order(:name)) + @total_active_disciplines = Discipline.kept.count + @pagy, @disciplines = pagy(DisciplinesQuery.new(filter_params).results) end def show @@ -14,37 +19,33 @@ def new requires_medical_certificate: true, requires_membership: true ) - - render layout: "modal" end def create @discipline = Discipline.new(discipline_params) if @discipline.save - redirect_to disciplines_path, notice: t(".created", default: "Disciplina creata con successo.") + redirect_to disciplines_path, notice: "Disciplina creata con successo." else - render :new, layout: "modal", status: :unprocessable_entity + render :new, status: :unprocessable_entity end end - def edit - render layout: "modal" - end + def edit; end def update if @discipline.update(discipline_params) - redirect_to disciplines_path, notice: t(".updated", default: "Disciplina aggiornata.") + redirect_to disciplines_path, notice: "Disciplina aggiornata." else - render :edit, layout: "modal", status: :unprocessable_entity + render :edit, status: :unprocessable_entity end end def destroy if @discipline.discard! - redirect_to disciplines_path, notice: t(".discarded", default: "Disciplina archiviata.") + redirect_to disciplines_path, notice: "Disciplina archiviata." else - redirect_to disciplines_path, alert: t(".error", default: "Impossibile archiviare.") + redirect_to disciplines_path, alert: "Impossibile archiviare." end end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index 34d169a..544cabc 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -1,15 +1,13 @@ class MembersController < ApplicationController + include Filterable + before_action :set_member, only: [ :show, :edit, :update, :destroy ] layout "modal", only: [ :new, :create, :edit, :update ] def index - query_results = MembersQuery.new(filter_params).results - @total_active_members = Member.kept.count - - @pagy, @members = pagy(query_results.includes(:subscriptions)) - @is_filtering = filter_params.to_h.except(:sort).reject { |_, v| v.blank? }.any? + @pagy, @members = pagy(MembersQuery.new(filter_params).results.includes(:subscriptions)) end def show diff --git a/app/controllers/preferences/languages_controller.rb b/app/controllers/preferences/languages_controller.rb index d0b42b6..ca87a07 100644 --- a/app/controllers/preferences/languages_controller.rb +++ b/app/controllers/preferences/languages_controller.rb @@ -1,9 +1,7 @@ class Preferences::LanguagesController < ApplicationController def update - if current_user.update(locale: params.require(:language)) - I18n.locale = current_user.locale - end + current_user.update(locale: params.require(:language)) - redirect_back(fallback_location: root_path) + redirect_back_or_to root_path end end diff --git a/app/controllers/preferences/themes_controller.rb b/app/controllers/preferences/themes_controller.rb index c17cc43..bbcb2d7 100644 --- a/app/controllers/preferences/themes_controller.rb +++ b/app/controllers/preferences/themes_controller.rb @@ -2,6 +2,6 @@ class Preferences::ThemesController < ApplicationController def update current_user.update(theme: params.require(:theme)) - redirect_back(fallback_location: root_path) + redirect_back_or_to root_path end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 76cdf2a..a6bf0b8 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,14 +1,13 @@ class UsersController < ApplicationController + include Filterable + before_action :set_user, only: [ :show, :edit, :update, :destroy ] layout "modal", only: [ :new, :create, :edit, :update ] def index - query_results = UsersQuery.new(filter_params).results - @total_active_users = User.kept.count - @pagy, @users = pagy(query_results) - @is_filtering = filter_params.to_h.except(:sort).reject { |_, v| v.blank? }.any? + @pagy, @users = pagy(UsersQuery.new(filter_params).results) end def show diff --git a/app/helpers/ui_helper.rb b/app/helpers/ui_helper.rb index 1e65801..a5b905d 100644 --- a/app/helpers/ui_helper.rb +++ b/app/helpers/ui_helper.rb @@ -33,4 +33,30 @@ def ui_row_delete_button(path, confirm: "Sei sicuro?", title: "Archivia") icon("delete") end end + + def ui_requirement_badge(condition, text:, icon_name:, active_class: "badge-info badge-soft") + if condition + content_tag(:div, class: "badge badge-sm gap-1 font-bold #{active_class}", title: "Richiede #{text}") do + icon(icon_name, classes: "size-3") + " #{text}" + end + else + content_tag(:div, class: "badge badge-sm badge-ghost opacity-40 gap-1 font-normal line-through", title: "Non richiede #{text}") do + "No #{text}" + end + end + end + + def ui_status_badge(is_valid, valid_text:, invalid_text:, valid_class: "badge-success badge-soft", invalid_class: "badge-error badge-soft", icon_name: nil) + base_classes = "badge badge-sm gap-1 font-bold" + + if is_valid + content_tag(:div, class: "#{base_classes} #{valid_class}") do + (icon_name ? icon(icon_name, classes: "size-3") + " " : "".html_safe) + valid_text + end + else + content_tag(:div, class: "#{base_classes} #{invalid_class}", title: "Attenzione: #{invalid_text}") do + icon("error", classes: "size-3") + " #{invalid_text}" + end + end + end end diff --git a/app/javascript/controllers/drawer_controller.js b/app/javascript/controllers/drawer_controller.js new file mode 100644 index 0000000..5774b6c --- /dev/null +++ b/app/javascript/controllers/drawer_controller.js @@ -0,0 +1,35 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="drawer" +export default class extends Controller { + static targets = ["dialog"] + + connect() { + if (this.dialogTarget.hasAttribute("open")) { + this.dialogTarget.removeAttribute("open") + this.dialogTarget.showModal() + } + } + + disconnect() { + if (this.dialogTarget.hasAttribute("open")) { + this.dialogTarget.close() + } + } + + open(event) { + if (event) event.preventDefault() + this.dialogTarget.showModal() + } + + close(event) { + if (event) event.preventDefault() + this.dialogTarget.close() + } + + clickOutside(event) { + if (event.target === this.dialogTarget) { + this.close() + } + } +} diff --git a/app/models/concerns/user_preferences.rb b/app/models/concerns/user_preferences.rb index a629ebb..6d8c363 100644 --- a/app/models/concerns/user_preferences.rb +++ b/app/models/concerns/user_preferences.rb @@ -1,27 +1,21 @@ module UserPreferences extend ActiveSupport::Concern - ALLOWED_THEMES = %w[light dark cupcake bumblebee emerald corporate synthwave retro cyberpunk valentine halloween garden forest aqua lofi pastel fantasy wireframe black luxury dracula cmyk autumn business acid lemonade night coffee winter dim nord sunset caramellate abyss silk].freeze + THEMES = %w[light dark nord corporate business dim].freeze included do store_accessor :preferences, :theme, :locale - validates :theme, inclusion: { in: ALLOWED_THEMES }, allow_nil: true - validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }, allow_nil: true - - after_initialize :set_default_preferences, if: :new_record? + validates :theme, inclusion: { in: THEMES }, allow_blank: true + validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }, allow_blank: true end - def theme_or_default - theme.presence || "light" + def theme + saved_theme = super.presence + THEMES.include?(saved_theme) ? saved_theme : "corporate" end - def locale_or_default - locale.presence || I18n.default_locale.to_s + def locale + super.presence || I18n.default_locale.to_s end - - private - def set_default_preferences - self.preferences ||= {} - end end diff --git a/app/models/discipline.rb b/app/models/discipline.rb index 00c0fb4..d68baee 100644 --- a/app/models/discipline.rb +++ b/app/models/discipline.rb @@ -1,14 +1,14 @@ class Discipline < ApplicationRecord include SoftDeletable + include Refreshable has_many :product_disciplines, dependent: :destroy has_many :products, through: :product_disciplines normalizes :name, with: ->(n) { n.squish.titleize } - validates :name, presence: true, uniqueness: { conditions: -> { kept } } - broadcasts_refreshes + scope :search_text, ->(query) { where("name LIKE ?", "%#{query}%") } def recent_subscriptions Subscription.kept diff --git a/app/queries/disciplines_query.rb b/app/queries/disciplines_query.rb new file mode 100644 index 0000000..844a4b8 --- /dev/null +++ b/app/queries/disciplines_query.rb @@ -0,0 +1,17 @@ +class DisciplinesQuery < ApplicationQuery + private + def default_relation + Discipline.all + end + + def apply_sorting(scope) + case @params[:sort] + when "name_desc" then scope.order(name: :desc) + when "created_asc" then scope.order(created_at: :asc) + when "created_desc" then scope.order(created_at: :desc) + when "name_asc" then scope.order(name: :asc) + else + @params[:query].present? ? scope : scope.order(name: :asc) + end + end +end diff --git a/app/queries/members_query.rb b/app/queries/members_query.rb index c5cb12f..4a0d37d 100644 --- a/app/queries/members_query.rb +++ b/app/queries/members_query.rb @@ -1,30 +1,13 @@ -class MembersQuery - def initialize(params = {}, relation = Member.all) - @params = params - @relation = relation - end - - def results - @relation - .then { |scope| filter_by_state(scope) } # 1. Filtra Attivi/Archiviati - .then { |scope| filter_by_search(scope) } # 2. Testo libero - .then { |scope| filter_by_membership(scope) } # 3. Stato Tesseramento - .then { |scope| filter_by_med_cert(scope) } # 4. Certificato Medico - .then { |scope| apply_sorting(scope) } # 5. Ordinamento finale - end - +class MembersQuery < ApplicationQuery private - def filter_by_state(scope) - if @params[:state] == "archived" - scope.discarded - else - scope.kept - end + def default_relation + Member.all end - def filter_by_search(scope) - return scope if @params[:query].blank? - scope.search_text(@params[:query]) + def apply_custom_filters(scope) + scope + .then { |s| filter_by_membership(s) } + .then { |s| filter_by_med_cert(s) } end def filter_by_membership(scope) @@ -52,15 +35,4 @@ def filter_by_med_cert(scope) scope end end - - def apply_sorting(scope) - case @params[:sort] - when "created_asc" then scope.order(created_at: :asc) - when "name_asc" then scope.order(last_name: :asc, first_name: :asc) - when "name_desc" then scope.order(last_name: :desc, first_name: :desc) - when "created_desc" then scope.order(created_at: :desc) - else - @params[:query].present? ? scope : scope.order(created_at: :desc) - end - end end diff --git a/app/views/disciplines/_discipline_row.html.erb b/app/views/disciplines/_discipline_row.html.erb index d235200..c030655 100644 --- a/app/views/disciplines/_discipline_row.html.erb +++ b/app/views/disciplines/_discipline_row.html.erb @@ -1,74 +1,42 @@ - - <%# NOME %> - - <%= link_to discipline.name, discipline, class: "font-bold text-base-content hover:text-primary transition-colors truncate" %> - +
  • - <%# REGOLE (Badge) %> - -
    - <%# Badge Certificato Medico %> - <% if discipline.requires_medical_certificate? %> -
    - <%= icon("med", size: 12) %> Cert. Medico -
    - <% else %> -
    - No Cert. -
    - <% end %> - - <%# Badge Membership %> - <% if discipline.requires_membership? %> -
    - <%= icon("card_membership", size: 12) %> Iscrizione -
    - <% end %> + <%# -- Colonna 1: Icona (Equivalente all'avatar di Member) -- %> +
    +
    + <%= icon("category", classes: "size-5") %>
    - +
    - <%# CONTEGGIO PRODOTTI %> - - <%= display_value(discipline.products.count) %> - + <%# -- Colonna 2: Contenuto centrale -- %> +
    + <%# Riga A: Nome %> +
    + <%= link_to discipline.name, discipline, data: { turbo_frame: "_top" }, class: "hover:underline hover:text-primary transition-colors" %> +
    - <%# AZIONI %> - -
    - <%= link_to [discipline], - class: "join-item btn btn-square btn-xs btn-ghost tooltip tooltip-left", - data: { tip: "Dettaglio" } do %> - <%= icon("view", size: 16) %> - <% end %> + <%# Riga B: Dati numerici %> +
    + <%= display_value(discipline.products.count) %> Prodotti collegati +
    - <%= link_to [:edit, discipline], - class: "join-item btn btn-square btn-xs btn-ghost tooltip tooltip-left", - data: { tip: "Modifica", turbo_frame: "modal" } do %> - <%= icon("edit", size: 16) %> - <% end %> +
    + <%= ui_requirement_badge(discipline.requires_medical_certificate?, + text: "Cert. Medico", icon_name: "med", active_class: "badge-error badge-soft") %> - <% if current_user.admin? %> - <%= link_to [discipline], - class: "join-item text-error btn btn-square btn-xs btn-ghost tooltip tooltip-left", - data: { - tip: "Archivia", - turbo_method: :delete, - turbo_confirm: "Sei sicuro di voler archiviare #{discipline.name}?" - } do %> - <%= icon("delete", size: 16) %> - <% end %> - <% end %> + <%= ui_requirement_badge(discipline.requires_membership?, + text: "Iscrizione", icon_name: "card_membership", active_class: "badge-info badge-soft") %>
    - - +
    + + <%# -- Colonna 3: Azioni -- %> +
    + <%# Uso gli stessi helper puliti di member_row anziché il join vecchio %> + <%= ui_row_edit_button(edit_discipline_path(discipline)) %> + + <% if current_user.admin? %> + <%= ui_row_delete_button(discipline, confirm: "Sei sicuro di voler archiviare #{discipline.name}?") %> + <% end %> +
    +
  • diff --git a/app/views/disciplines/index.html.erb b/app/views/disciplines/index.html.erb index 66431a2..f85d435 100644 --- a/app/views/disciplines/index.html.erb +++ b/app/views/disciplines/index.html.erb @@ -2,43 +2,96 @@ <% content_for :page_title, "Discipline" %> -<%# content_for :page_counter, t("search.results", count: @pagy.count) %> +<% content_for :page_counter, "#{@total_active_disciplines} totali" %> <% content_for :page_actions do %> <%= link_to [:new, :discipline], - class: "btn btn-primary gap-2", - data: { turbo_frame: "modal" } do %> + class: "btn btn-primary gap-2", + data: { turbo_frame: "modal" } do %> <%= icon("add") %> - + <% end %> <% end %> - +<% content_for :page_search_and_filters do %> + <%= form_with url: disciplines_path, + method: :get, + html: { + autocomplete: "off", + id: "filter-form", + data: { controller: "autosubmit drawer" } + } do |form| %> + +
    + +
    + + + +
    + + <%# Ordinamento (se applicabile anche a discipline) %> +
    + <%= render "shared/filter/sort", form: form %> +
    +
    + + <%# IL CASSETTO DEI FILTRI %> + <%= render "shared/filter/drawer", title: "Filtri Discipline" do %> +
    + + <%# Esempio di filtro: adattalo ai tuoi scope reali delle discipline %> +
    + + <%= form.select :state, [["Attive", "active"], ["Archiviate", "archived"]], + { include_blank: "Tutti gli stati", selected: params[:state] }, + { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> +
    + + <%# Aggiungi qui eventuali altri filtri specifici %> + +
    + <% end %> + <% end %> +<% end %> <%= render "shared/header/page" %> <%= render "shared/filter/active", filter_keys: @keys %> -<%= render "shared/table", pagy: @pagy do %> - - - Nome - Regole Accesso - Prodotti Collegati - - - - - - <%= render partial: "discipline_row", collection: @disciplines, as: :discipline %> - - <% if @disciplines.empty? %> - - <%= render "shared/empty_state", - icon_name: "group_off", - title: t("search.results.zero"), - message: t("search.results.hint") %> - +
    + <% if @disciplines.empty? %> + <%= render "shared/empty_state", + icon_name: "category", + title: "Nessuna disciplina trovata", + message: "Prova a cambiare i criteri di ricerca o crea una nuova disciplina." %> + <% else %> + + <% if filtering? %> +
    + Trovati <%= @pagy.count %> risultati +
    <% end %> - -<% end %> + +
      + <%= render partial: "discipline_row", collection: @disciplines, as: :discipline %> +
    + + <% if @pagy.pages > 1 %> +
    + <%= render "shared/pagination", pagy: @pagy %> +
    + <% end %> + + <% end %> +
    diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 63f13ac..275d239 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -24,7 +24,7 @@ diff --git a/app/views/members/_member_row.html.erb b/app/views/members/_member_row.html.erb index 74f021a..880a6dc 100644 --- a/app/views/members/_member_row.html.erb +++ b/app/views/members/_member_row.html.erb @@ -15,18 +15,17 @@ <%= ui_badge("Archiviato") if member.discarded? %>
    - <%# Riga B: Dati fiscali e Status %>
    <%= member.fiscal_code %> - - <%= member.membership_valid? ? 'Tessera Attiva' : 'Tessera Scaduta' %> - + + <%= ui_status_badge(member.membership_valid?, + valid_text: "Tessera Attiva", invalid_text: "Tessera Scaduta") %> + <% unless member.medical_certificate_valid? %> - - - <%= icon("warning", classes: "size-4") %> Cert. Medico - + + <%= ui_status_badge(false, + valid_text: "", invalid_text: "Cert. Medico", invalid_class: "badge-warning badge-soft") %> <% end %>
    diff --git a/app/views/members/index.html.erb b/app/views/members/index.html.erb index ce2a957..8333cab 100644 --- a/app/views/members/index.html.erb +++ b/app/views/members/index.html.erb @@ -19,7 +19,7 @@ html: { autocomplete: "off", id: "filter-form", - data: { controller: "autosubmit" } + data: { controller: "autosubmit drawer" } } do |form| %>
    @@ -27,18 +27,17 @@
    -
    <%# DESTRA: Ordinamento %> @@ -49,7 +48,7 @@
    <%# IL CASSETTO DEI FILTRI %> - <%= render "shared/filter/drawer", drawer_id: "members-filters", title: "Filtri Soci" do %> + <%= render "shared/filter/drawer", title: "Filtri Soci" do %>
    @@ -89,8 +88,7 @@ message: "Prova a cambiare i criteri di ricerca o rimuovere i filtri." %> <% else %> - <%# 2. Feedback contestuale della ricerca %> - <% if @is_filtering %> + <% if filtering? %>
    Trovati <%= @pagy.count %> risultati
    diff --git a/app/views/shared/filter/_drawer.html.erb b/app/views/shared/filter/_drawer.html.erb index 8310e6f..5c1ef9a 100644 --- a/app/views/shared/filter/_drawer.html.erb +++ b/app/views/shared/filter/_drawer.html.erb @@ -1,36 +1,25 @@ -<%# - Riceve: - - drawer_id: L'ID univoco per l'input checkbox (default: "filter-drawer") - - title: Il titolo del drawer (default: "Filtri") -%> -<% drawer_id ||= "filter-drawer" %> -<% title ||= "Filtri" %> + +
    -
    - - -
    - -
    - - -
    - <%# Header %> -
    -

    - <%= icon("filter", size: 20, class: "opacity-70") %> - <%= title %> -

    - - -
    + <%# Header del cassetto %> +
    +

    + <%= icon("filter", size: 20, class: "opacity-70") %> + <%= title %> +

    + +
    - <%# Contenuto (I filtri passati dal blocco) %> -
    - <%= yield %> -
    + <%# Corpo dei filtri %> +
    + <%= yield %>
    +
    -
    +
    diff --git a/app/views/shared/navbar/_theme_dropdown.html.erb b/app/views/shared/navbar/_theme_dropdown.html.erb index 5132397..63c3f33 100644 --- a/app/views/shared/navbar/_theme_dropdown.html.erb +++ b/app/views/shared/navbar/_theme_dropdown.html.erb @@ -7,7 +7,7 @@ - <% UserPreferences::ALLOWED_THEMES.each do |theme| %> + <% UserPreferences::THEMES.each do |theme| %> <% is_active = current_user&.preferences&.dig("theme") == theme %>
  • diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 1a6f4bb..8e7dd9a 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -19,7 +19,7 @@ html: { autocomplete: "off", id: "filter-form", - data: { controller: "autosubmit" } + data: { controller: "autosubmit drawer" } } do |form| %>
    @@ -35,10 +35,10 @@ data: { action: "input->autosubmit#submit" } %> -
    <%# DESTRA: Ordinamento %> diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake new file mode 100644 index 0000000..16cff51 --- /dev/null +++ b/lib/tasks/maintenance.rake @@ -0,0 +1,28 @@ +namespace :maintenance do + desc "Bonifica i temi legacy degli utenti, forzando il fallback a 'corporate'" + task sanitize_themes: :environment do + puts "🕵️‍♂️ Ricerca di utenti con temi fuori menu (non-Omakase)..." + + invalid_users = User.where.not("preferences->>'theme' IN (?)", UserPreferences::THEMES) + .where.not("preferences->>'theme' IS NULL") + + count = invalid_users.count + + if count.zero? + puts "✅ Tutto pulito! Nessun tema legacy trovato nel database." + next + end + + puts "🧹 Trovati #{count} utenti da bonificare. Inizio pulizia..." + + invalid_users.find_each do |user| + new_preferences = user.preferences.merge("theme" => "corporate") + + user.update_column(:preferences, new_preferences) + + print "." + end + + puts "\n🎉 Bonifica completata! #{count} profili aggiornati a 'corporate'." + end +end From 229ff74d4d0a369cabac3893b1f421e0233d88a1 Mon Sep 17 00:00:00 2001 From: jcostd Date: Wed, 1 Apr 2026 15:22:56 +0200 Subject: [PATCH 12/34] Updated UI --- Gemfile.lock | 8 +- app/assets/images/icons/access.svg | 1 + app/controllers/concerns/filterable.rb | 8 +- .../disciplines/members_controller.rb | 17 +- app/controllers/disciplines_controller.rb | 4 + app/controllers/members_controller.rb | 5 +- app/controllers/products_controller.rb | 22 +- .../receipt_counters_controller.rb | 10 - app/controllers/sales_controller.rb | 31 +- app/controllers/subscriptions_controller.rb | 12 + app/helpers/filters_helper.rb | 37 +++ app/helpers/members_helper.rb | 7 - app/helpers/products_helper.rb | 17 + app/helpers/sales_helper.rb | 45 +++ .../controllers/filter_badge_controller.js | 18 +- app/models/concerns/filterable.rb | 70 ---- app/models/member.rb | 23 +- app/models/subscription.rb | 8 +- app/models/user.rb | 8 - app/queries/application_query.rb | 2 +- app/queries/discipline_subscriptions_query.rb | 33 ++ app/queries/products_query.rb | 36 +++ app/queries/sales_query.rb | 33 ++ app/queries/users_query.rb | 10 + app/views/dashboard/index.html.erb | 17 +- app/views/disciplines/_context.html.erb | 67 ++++ .../disciplines/_discipline_row.html.erb | 7 +- app/views/disciplines/index.html.erb | 51 ++- .../disciplines/members/_member_row.html.erb | 74 ----- .../members/_subscription_row.html.erb | 47 +++ app/views/disciplines/members/index.html.erb | 121 +++++-- app/views/disciplines/show.html.erb | 189 +++++------ app/views/members/_member_row.html.erb | 6 +- app/views/members/index.html.erb | 56 ++-- app/views/members/searches/index.html.erb | 24 +- app/views/products/_context.html.erb | 57 ++++ app/views/products/_product_row.html.erb | 102 +++--- app/views/products/_shell.html.erb | 80 ----- app/views/products/index.html.erb | 86 +++-- app/views/products/show.html.erb | 217 ++++++------- app/views/sales/_context.html.erb | 62 ++++ app/views/sales/_sale_row.html.erb | 109 +++---- app/views/sales/_shell.html.erb | 93 ------ app/views/sales/index.html.erb | 96 ++++-- app/views/sales/show.html.erb | 303 +++++++----------- app/views/shared/_navbar.html.erb | 2 +- app/views/shared/_pagination.html.erb | 9 +- app/views/shared/_table.html.erb | 13 - app/views/shared/filter/_active.html.erb | 33 +- app/views/shared/filter/_sort.html.erb | 4 +- app/views/shared/header/_page.html.erb | 2 +- app/views/shared/header/_record.html.erb | 2 +- app/views/users/_user_row.html.erb | 2 +- app/views/users/index.html.erb | 78 +++-- .../new_framework_defaults_8_1.rb | 74 ----- config/initializers/pagy.rb | 127 -------- config/initializers/pagy_daisyui.rb | 72 +++++ config/routes.rb | 2 +- .../20260323183701_create_members_fts.rb | 26 +- db/seeds.rb | 14 - db/structure.sql | 155 ++++----- lib/tasks/fts.rake | 14 + 62 files changed, 1487 insertions(+), 1471 deletions(-) create mode 100644 app/assets/images/icons/access.svg delete mode 100644 app/controllers/receipt_counters_controller.rb create mode 100644 app/helpers/filters_helper.rb create mode 100644 app/helpers/products_helper.rb create mode 100644 app/helpers/sales_helper.rb delete mode 100644 app/models/concerns/filterable.rb create mode 100644 app/queries/discipline_subscriptions_query.rb create mode 100644 app/queries/products_query.rb create mode 100644 app/queries/sales_query.rb create mode 100644 app/views/disciplines/_context.html.erb delete mode 100644 app/views/disciplines/members/_member_row.html.erb create mode 100644 app/views/disciplines/members/_subscription_row.html.erb create mode 100644 app/views/products/_context.html.erb delete mode 100644 app/views/products/_shell.html.erb create mode 100644 app/views/sales/_context.html.erb delete mode 100644 app/views/sales/_shell.html.erb delete mode 100644 app/views/shared/_table.html.erb delete mode 100644 config/initializers/new_framework_defaults_8_1.rb delete mode 100644 config/initializers/pagy.rb create mode 100644 config/initializers/pagy_daisyui.rb create mode 100644 lib/tasks/fts.rake diff --git a/Gemfile.lock b/Gemfile.lock index 5ffc5d2..147d630 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -173,7 +173,7 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (6.0.2) + minitest (6.0.3) drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) @@ -239,7 +239,7 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.5) + rack (3.2.6) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -510,7 +510,7 @@ CHECKSUMS matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef - minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d + minitest (6.0.3) sha256=88ac8a1de36c00692420e7cb3cc11a0773bbcb126aee1c249f320160a7d11411 msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 @@ -544,7 +544,7 @@ CHECKSUMS puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8 raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3 + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 diff --git a/app/assets/images/icons/access.svg b/app/assets/images/icons/access.svg new file mode 100644 index 0000000..6728946 --- /dev/null +++ b/app/assets/images/icons/access.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/controllers/concerns/filterable.rb b/app/controllers/concerns/filterable.rb index 4cf1628..3b73847 100644 --- a/app/controllers/concerns/filterable.rb +++ b/app/controllers/concerns/filterable.rb @@ -2,15 +2,15 @@ module Filterable extend ActiveSupport::Concern included do - helper_method :filtering? + helper_method :filtering?, :active_filters end private def filtering? - filter_params.to_h.except(:sort).reject { |_, v| v.blank? }.any? + active_filters.any? end - def filter_params - params.permit(:query, :sort, :state) + def active_filters + request.query_parameters.except(:sort, :commit, :page).reject { |_, v| v.blank? } end end diff --git a/app/controllers/disciplines/members_controller.rb b/app/controllers/disciplines/members_controller.rb index 6d945c1..bcc0127 100644 --- a/app/controllers/disciplines/members_controller.rb +++ b/app/controllers/disciplines/members_controller.rb @@ -1,20 +1,21 @@ class Disciplines::MembersController < ApplicationController + include Filterable + before_action :set_discipline def index - product_ids = @discipline.product_ids + @products = @discipline.products.kept - all_subs = Subscription.kept - .where(product_id: product_ids) - .where("end_date >= ?", 30.days.ago) - .includes(:member, :product) - @subscriptions = all_subs.group_by(&:member_id) - .map { |_, subs| subs.max_by(&:end_date) } - .sort_by(&:end_date) + query = DisciplineSubscriptionsQuery.new(params, @discipline.recent_subscriptions) + @pagy, @subscriptions = pagy(query.results) end private def set_discipline @discipline = Discipline.find(params[:discipline_id]) end + + def filter_params + params.permit(:query, :sort, :state, :product_id, :membership_status, :med_cert) + end end diff --git a/app/controllers/disciplines_controller.rb b/app/controllers/disciplines_controller.rb index 1c0ccb6..12d83ca 100644 --- a/app/controllers/disciplines_controller.rb +++ b/app/controllers/disciplines_controller.rb @@ -57,4 +57,8 @@ def set_discipline def discipline_params params.require(:discipline).permit(:name, :requires_medical_certificate, :requires_membership) end + + def filter_params + params.permit(:query, :sort, :state) + end end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index 544cabc..b00f721 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -7,7 +7,10 @@ class MembersController < ApplicationController def index @total_active_members = Member.kept.count - @pagy, @members = pagy(MembersQuery.new(filter_params).results.includes(:subscriptions)) + @pagy, @members = pagy( + MembersQuery.new(filter_params) + .results + .includes(subscriptions: :product)) end def show diff --git a/app/controllers/products_controller.rb b/app/controllers/products_controller.rb index ff96a75..9e0a9e3 100644 --- a/app/controllers/products_controller.rb +++ b/app/controllers/products_controller.rb @@ -1,8 +1,13 @@ class ProductsController < ApplicationController + include Filterable + before_action :set_product, only: [ :show, :edit, :update, :destroy ] + layout "modal", only: [ :new, :create, :edit, :update ] + def index - @pagy, @products = pagy(Product.kept.includes(:disciplines).order(:name)) + @total_active_products = Product.kept.count + @pagy, @products = pagy(ProductsQuery.new(filter_params).results.includes(:disciplines)) end def show; end @@ -15,8 +20,6 @@ def new accounting_category: :institutional, duration_days: 30 ) - - render layout: "modal" end def create @@ -25,19 +28,17 @@ def create if @product.save redirect_to products_path, notice: t(".created", default: "Prodotto creato correttamente.") else - render :new, layout: "modal", status: :unprocessable_entity + render :new, status: :unprocessable_entity end end - def edit - render layout: "modal" - end + def edit; end def update if @product.update(product_params) redirect_to products_path, notice: t(".updated", default: "Prodotto aggiornato.") else - render :edit, layout: "modal", status: :unprocessable_entity + render :edit, status: :unprocessable_entity end end @@ -50,7 +51,6 @@ def destroy end private - def set_product @product = Product.find(params[:id]) end @@ -64,4 +64,8 @@ def product_params discipline_ids: [] ) end + + def filter_params + params.permit(:query, :sort, :state, :accounting_category) + end end diff --git a/app/controllers/receipt_counters_controller.rb b/app/controllers/receipt_counters_controller.rb deleted file mode 100644 index 5b0c2fb..0000000 --- a/app/controllers/receipt_counters_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -class ReceiptCountersController < ApplicationController - def index - end - - def edit - end - - def update - end -end diff --git a/app/controllers/sales_controller.rb b/app/controllers/sales_controller.rb index ddc7043..25456a5 100644 --- a/app/controllers/sales_controller.rb +++ b/app/controllers/sales_controller.rb @@ -1,14 +1,18 @@ class SalesController < ApplicationController + include Filterable + before_action :require_admin, only: [ :index ] before_action :set_sale, only: [ :show, :destroy ] layout -> { turbo_frame_request_id == "pos_form_frame" ? false : "modal" }, only: [ :new, :create ] def index - scope = Sale.kept - .includes(:member, :user) - .order(sold_on: :desc, created_at: :desc) - @pagy, @sales = pagy(scope) + @total_active_sales = Sale.kept.count + @pagy, @sales = pagy( + SalesQuery.new(filter_params) + .results + .includes(:member, :user) + ) end def show @@ -19,7 +23,7 @@ def show send_data pdf.render, filename: "ricevuta_#{@sale.id}_#{@sale.member.last_name}.pdf", type: "application/pdf", - disposition: "inline" + disposition: "_blank" end end end @@ -69,27 +73,22 @@ def destroy end private - def set_sale @sale = Sale.find(params[:id]) end - # Questo ci serve perché la action `new` ora viene chiamata in due modi: - # 1. Link normale (params[:sale] non esiste -> solleverebbe errore con `require`) - # 2. Autosubmit di Turbo (params[:sale] esiste e vogliamo i dati) def sale_params_for_build params.has_key?(:sale) ? sale_params : {} end def sale_params params.require(:sale).permit( - :member_id, - :product_id, - :amount, - :payment_method, - :sold_on, - :notes, - subscription_attributes: [ :start_date ] + :member_id, :product_id, :amount, :payment_method, + :sold_on, :notes, subscription_attributes: [ :start_date ] ) end + + def filter_params + params.permit(:query, :sort, :payment_method) + end end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index 3ba63d9..e0b4082 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -1,6 +1,18 @@ class SubscriptionsController < ApplicationController before_action :set_subscription, only: [ :edit, :update, :destroy ] + def index + @subscriptions = Subscription.kept.includes(:member, :product) + + if params[:filter] == "expiring" + @subscriptions = @subscriptions.where(end_date: Date.current..7.days.from_now).order(:end_date) + else + @subscriptions = @subscriptions.order(created_at: :desc) + end + + @pagy, @subscriptions = pagy(@subscriptions) + end + def edit; end def update diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb new file mode 100644 index 0000000..07882ff --- /dev/null +++ b/app/helpers/filters_helper.rb @@ -0,0 +1,37 @@ +module FiltersHelper + def humanize_filter_key(key) + case key.to_s + when "product_id" then "Corso" + when "state" then "Stato" + when "query" then "Ricerca" + when "med_cert" then "Certificato Medico" + else key.to_s.humanize + end + end + + def humanize_filter_value(key, value) + case key.to_s + when "product_id" + Product.find_by(id: value)&.name || "Sconosciuto" + when "state" + value.to_s == "kept" ? "Attivi" : "Archiviati" + else + value.to_s + end + end + + def state_filters + [ + [ "Attivi", "kept" ], + [ "Archiviati", "discarded" ] + ] + end + + def filtered_results_counter(pagy) + return unless filtering? + + testo = "Trovati #{pagy.count} risultati" + + content_tag :div, testo, class: "mb-4 text-sm font-medium text-base-content/70" + end +end diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 4ffc842..f31db6b 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -15,13 +15,6 @@ def member_med_cert_filters ] end - def member_state_filters - [ - [ "Attivi", "active" ], - [ "Archiviati", "archived" ] - ] - end - def member_status_color_class(member) if member.membership_valid? && member.medical_certificate_valid? "text-success" diff --git a/app/helpers/products_helper.rb b/app/helpers/products_helper.rb new file mode 100644 index 0000000..330d56f --- /dev/null +++ b/app/helpers/products_helper.rb @@ -0,0 +1,17 @@ +module ProductsHelper + def product_category_badge(product) + if product.associative? + content_tag(:span, "Q. Associativa", class: "badge badge-info badge-soft badge-sm") + else + content_tag(:span, "Q. Istituzionale", class: "badge badge-warning badge-soft badge-sm") + end + end + + def product_category_text(product) + if product.associative? + content_tag(:span, "Quota Associativa", class: "text-info font-bold") + else + content_tag(:span, "Quota Istituzionale", class: "text-warning font-bold") + end + end +end diff --git a/app/helpers/sales_helper.rb b/app/helpers/sales_helper.rb new file mode 100644 index 0000000..5e99d38 --- /dev/null +++ b/app/helpers/sales_helper.rb @@ -0,0 +1,45 @@ +module SalesHelper + def payment_method_badge(method) + base_class = "badge badge-sm badge-soft gap-1 text-[10px] uppercase font-bold tracking-wider" + + case method.to_s + when "cash" + content_tag(:div, class: "#{base_class} badge-success") do + icon("payments", classes: "size-3") + " Contanti" + end + when "credit_card" + content_tag(:div, class: "#{base_class} badge-info") do + icon("credit_card", classes: "size-3") + " Carta" + end + when "bank_transfer" + content_tag(:div, class: "#{base_class} badge-warning") do + icon("account_balance", classes: "size-3") + " Bonifico" + end + else + content_tag(:div, class: "#{base_class} badge-ghost") do + method.to_s.humanize + end + end + end + + def payment_method_icon(method, classes: "size-6") + case method.to_s + when "cash" then icon("payments", classes: classes) + when "credit_card" then icon("credit_card", classes: classes) + when "bank_transfer" then icon("account_balance", classes: classes) + else icon("paid", classes: classes) + end + end + + def transaction_status_indicator(sale) + if sale.discarded? + content_tag(:span, class: "text-error font-bold flex items-center gap-1") do + icon("close", classes: "size-3") + " ANNULLATA" + end + else + content_tag(:span, class: "text-success font-bold flex items-center gap-1") do + icon("success", classes: "size-3") + " Pagamento Confermato" + end + end + end +end diff --git a/app/javascript/controllers/filter_badge_controller.js b/app/javascript/controllers/filter_badge_controller.js index fc313d3..237808b 100644 --- a/app/javascript/controllers/filter_badge_controller.js +++ b/app/javascript/controllers/filter_badge_controller.js @@ -4,14 +4,17 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { remove(event) { const key = event.params.key - const form = document.getElementById("filter-form") if (!form || !key) return const input = form.querySelector(`[name="${key}"]`) || form.querySelector(`[name$="[${key}]"]`) if (input) { - input.value = "" + if (input.type === 'checkbox' || input.type === 'radio') { + input.checked = false + } else { + input.value = "" + } form.requestSubmit() } } @@ -21,7 +24,16 @@ export default class extends Controller { const form = document.getElementById("filter-form") if (!form) return - form.reset() + const inputs = form.querySelectorAll('input:not([type="hidden"]), select:not([name="sort"]), textarea') + + inputs.forEach(input => { + if (input.type === 'checkbox' || input.type === 'radio') { + input.checked = false + } else { + input.value = "" + } + }) + form.requestSubmit() } } diff --git a/app/models/concerns/filterable.rb b/app/models/concerns/filterable.rb deleted file mode 100644 index ee2156b..0000000 --- a/app/models/concerns/filterable.rb +++ /dev/null @@ -1,70 +0,0 @@ -module Filterable - extend ActiveSupport::Concern - - class_methods do - def available_filters - [] - end - - def available_sorts - [] - end - - def default_sort_key - :created_at - end - - def default_sort_direction - :desc - end - - def apply_filters(params) - scope = all - return scope if params.nil? - - if params[:query].present? && respond_to?(:search_by_text) - scope = scope.search_by_text(params[:query]) - end - - available_filters.each do |filter_config| - key = filter_config[:key] - value = params[key] - - next if key == :query - next if value.blank? - - scope_name = "filter_by_#{key}" - if respond_to?(scope_name) - scope = scope.send(scope_name, value) - end - end - - scope = apply_sorting(scope, params) - - scope - end - - def apply_sorting(scope, params) - sort_key = params[:sort].presence || default_sort_key - direction = params[:direction] == "desc" ? :desc : :asc - - allowed_keys = available_sorts.map { |s| s[:key].to_s } - - unless allowed_keys.include?(sort_key.to_s) - sort_key = default_sort_key - direction = default_sort_direction - end - - scope_name = "sort_by_#{sort_key}" - if respond_to?(scope_name) - return scope.send(scope_name, direction) - end - - if column_names.include?(sort_key.to_s) - return scope.order(sort_key => direction) - end - - scope - end - end -end diff --git a/app/models/member.rb b/app/models/member.rb index 8442bbc..b5424a3 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -25,20 +25,27 @@ def medical_certificate_valid?(date = Date.current) medical_certificate_expiry.present? && medical_certificate_expiry >= date end - def membership_valid?(date = Date.current) - expiry = memberships.kept.maximum(:end_date) - expiry.present? && expiry >= date - end - def compliant?(date = Date.current) medical_certificate_valid?(date) && membership_valid?(date) end + def membership_valid?(date = Date.current) + expiry = subscriptions + .reject(&:discarded?) + .select { |s| s.product&.associative? } + .map(&:end_date) + .compact + .max + + expiry.present? && expiry >= date + end + def relevant_subscriptions(date = Date.current) subscriptions - .kept - .where("end_date >= ?", date - 30.days) - .order(end_date: :desc) + .reject(&:discarded?) + .select { |s| s.end_date && s.end_date >= (date - 30.days) } + .sort_by { |s| s.end_date || Date.new(1970) } + .reverse end def renewal_info_for(product) diff --git a/app/models/subscription.rb b/app/models/subscription.rb index b00930a..31e097d 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -17,8 +17,12 @@ def future? start_date > Date.current end - def expired? - days_left < 0 + def expired?(date = Date.current) + end_date < date + end + + def days_difference(date = Date.current) + (date - end_date).to_i.abs end def expiring_soon? diff --git a/app/models/user.rb b/app/models/user.rb index 14cd70c..ee7793d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -22,14 +22,6 @@ class User < ApplicationRecord validates :password, length: { minimum: 4 }, allow_nil: true - scope :search_text, ->(query) { - term = "%#{query}%" - where( - "first_name LIKE :term OR last_name LIKE :term OR email_address LIKE :term OR username LIKE :term", - term: term - ) - } - private def terminate_all_sessions sessions.delete_all diff --git a/app/queries/application_query.rb b/app/queries/application_query.rb index 436d54e..bc48907 100644 --- a/app/queries/application_query.rb +++ b/app/queries/application_query.rb @@ -22,7 +22,7 @@ def apply_custom_filters(scope) end def filter_by_state(scope) - if @params[:state] == "archived" + if @params[:state] == "discarded" scope.discarded else scope.kept diff --git a/app/queries/discipline_subscriptions_query.rb b/app/queries/discipline_subscriptions_query.rb new file mode 100644 index 0000000..365369c --- /dev/null +++ b/app/queries/discipline_subscriptions_query.rb @@ -0,0 +1,33 @@ +class DisciplineSubscriptionsQuery < ApplicationQuery + private + def default_relation + raise ArgumentError, "Richiesta la relation base (es. @discipline.recent_subscriptions)" + end + + def filter_by_search(scope) + return scope if @params[:query].blank? + + scope.joins(:member).merge(Member.search_text(@params[:query])) + end + + def apply_custom_filters(scope) + scope + .then { |s| filter_by_product(s) } + end + + def filter_by_product(scope) + return scope if @params[:product_id].blank? + + scope.where(product_id: @params[:product_id]) + end + + def apply_sorting(scope) + case @params[:sort] + when "expiring_asc" then scope.order(end_date: :asc) + when "expiring_desc" then scope.order(end_date: :desc) + when "recent" then scope.order(created_at: :desc) + else + @params[:query].present? ? scope : scope.order(end_date: :asc) + end + end +end diff --git a/app/queries/products_query.rb b/app/queries/products_query.rb new file mode 100644 index 0000000..c320ca4 --- /dev/null +++ b/app/queries/products_query.rb @@ -0,0 +1,36 @@ +class ProductsQuery < ApplicationQuery + private + def default_relation + Product.all + end + + def filter_by_search(scope) + return scope if @params[:query].blank? + + scope.where("products.name LIKE ?", "%#{@params[:query]}%") + end + + def apply_custom_filters(scope) + scope + .then { |s| filter_by_category(s) } + end + + def filter_by_category(scope) + return scope if @params[:accounting_category].blank? + + scope.where(accounting_category: @params[:accounting_category]) + end + + def apply_sorting(scope) + case @params[:sort] + when "name_asc" then scope.order(name: :asc) + when "name_desc" then scope.order(name: :desc) + when "price_asc" then scope.order(price_cents: :asc) + when "price_desc" then scope.order(price_cents: :desc) + when "created_asc" then scope.order(created_at: :asc) + when "created_desc" then scope.order(created_at: :desc) + else + @params[:query].present? ? scope : scope.order(name: :asc) + end + end +end diff --git a/app/queries/sales_query.rb b/app/queries/sales_query.rb new file mode 100644 index 0000000..f9bbecf --- /dev/null +++ b/app/queries/sales_query.rb @@ -0,0 +1,33 @@ +class SalesQuery < ApplicationQuery + private + def default_relation + Sale.all + end + + def apply_custom_filters(scope) + scope.then { |s| filter_by_payment_method(s) } + end + + def filter_by_search(scope) + return scope if @params[:query].blank? + + term = "%#{@params[:query]}%" + + scope.joins(:member).where( + "CAST(sales.receipt_number AS TEXT) LIKE :q OR members.first_name LIKE :q OR members.last_name LIKE :q", + q: term + ) + end + + def filter_by_payment_method(scope) + return scope if @params[:payment_method].blank? + + scope.where(payment_method: @params[:payment_method]) + end + + def apply_sorting(scope) + return super if @params[:sort].present? + + scope.order(sold_on: :desc, created_at: :desc) + end +end diff --git a/app/queries/users_query.rb b/app/queries/users_query.rb index 8e8063e..ebec98a 100644 --- a/app/queries/users_query.rb +++ b/app/queries/users_query.rb @@ -4,12 +4,22 @@ def default_relation User.all end + def filter_by_search(scope) + return scope if @params[:query].blank? + + scope.where( + "users.first_name LIKE :term OR users.last_name LIKE :term OR users.email_address LIKE :term OR users.username LIKE :term", + term: term + ) + end + def apply_custom_filters(scope) filter_by_role(scope) end def filter_by_role(scope) return scope if @params[:role].blank? + scope.where(role: @params[:role]) end end diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 7297b03..5cc9947 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -11,7 +11,7 @@ <%= link_to new_sale_path, class: "btn btn-primary shadow-sm", data: { turbo_frame: "modal" } do %> - <%= icon("add", classes: "size-5") %> Nuova Vendita + <%= icon("add") %> Nuova Vendita <% end %> @@ -19,19 +19,19 @@
    -
    <%= icon("payments", classes: "size-8") %>
    +
    <%= icon("sunny", classes: "size-8") %>
    Cash Mattina
    <%= format_money(@daily_cash.morning_total) %>
    -
    <%= icon("payments", classes: "size-8") %>
    +
    <%= icon("moon", classes: "size-8") %>
    Cash Pomeriggio
    <%= format_money(@daily_cash.afternoon_total) %>
    -
    <%= icon("login", classes: "size-8") %>
    +
    <%= icon("access", classes: "size-8") %>
    Ingressi Oggi
    <%= @today_accesses_count %>
    @@ -82,7 +82,6 @@
    - <%# Fix: size-3 per le icone dentro i badge %> <% if log.status == 0 || log.status == 'ok' %>
    -<% end %> + +
  • diff --git a/app/views/sales/_context.html.erb b/app/views/sales/_context.html.erb new file mode 100644 index 0000000..d27d142 --- /dev/null +++ b/app/views/sales/_context.html.erb @@ -0,0 +1,62 @@ +<% content_for :sale_actions do %> + +<% end %> + +<% content_for :record_avatar do %> + <%= icon("receipt", classes: "size-14 opacity-50") %> +<% end %> + +<% content_for :record_title do %> + Vendita del <%= format_date(sale.sold_on) %> +<% end %> + +<% content_for :record_subtitle do %> +
    + + Rif: <%= sale.receipt_code.presence || "Bozza / N/A" %> + + <% if sale.discarded? %> + <%= ui_badge("Annullata", style: "error") %> + <% else %> + <%= ui_badge("Confermata", style: "success") %> + <% end %> +
    +<% end %> + +<% content_for :record_actions do %> + <% unless sale.discarded? %> +
    + <%= link_to sale_path(sale , format: :pdf), + class: "btn btn-primary btn-sm gap-2", + data: { turbo_frame: "modal" } do %> + <%= icon("print") %> + <% end %> + + <% if current_user.admin? && sale.created_at > 24.hours.ago %> + <%= render layout: "shared/dropdown_layout", locals: { + group_name: "sale_header_dropdowns", + summary_class: "btn-sm btn-square btn-ghost", + icon_name: "more", + with_no_arrow: true + } do %> +
  • + <%= ui_row_delete_button(sale, + confirm: "Sei sicuro di voler annullare questa transazione?") %> +
  • + <% end %> +
    + <% end %> + <% end %> +<% end %> + +<%= render "shared/header/record" %> diff --git a/app/views/sales/_sale_row.html.erb b/app/views/sales/_sale_row.html.erb index 013b17f..fb69d38 100644 --- a/app/views/sales/_sale_row.html.erb +++ b/app/views/sales/_sale_row.html.erb @@ -1,71 +1,56 @@ - - <%# DATA %> - - <%= format_date(sale.sold_on, format: :long) %> - +
  • - <%# RICEVUTA %> - - <%= display_value(sale.receipt_code) %> - +
    +
    + <%= icon("receipt") %> +
    +
    - <%# SOCIO %> - - <%= link_to sale.member.full_name, member_path(sale.member), class: "font-bold text-base-content hover:text-primary transition-colors truncate" %> - +
    - <%# PRODOTTO %> - -
    <%= sale.product_name_snapshot %>
    + <%# Riga A: Prodotto e Socio %> +
    + <%= link_to sale.product_name_snapshot, sale_path(sale), + data: { turbo_frame: "_top" }, + class: "hover:underline hover:text-primary transition-colors" %> + <%= ui_badge("Archiviato") if sale.discarded? %> +
    -
    - <%= icon("badge", size: 10) %> op: <%= sale.user.username %> +
    + Socio: + <%= link_to sale.member.full_name, member_path(sale.member), + data: { turbo_frame: "_top" }, + class: "hover:underline hover:text-primary transition-colors font-medium text-base-content/80" %>
    - - <%# METODO PAGAMENTO %> - - <% case sale.payment_method %> - <% when 'cash' %> -
    - <%= icon("payments", size: 12) %> Contanti -
    - <% when 'credit_card' %> -
    - <%= icon("credit_card", size: 12) %> Carta -
    - <% else %> -
    - <%= sale.payment_method.humanize %> -
    - <% end %> - + <%# Riga C: Metadati (Data, Ricevuta, Operatore) %> +
    + <%= format_date(sale.sold_on, format: :short) %> + + #<%= display_value(sale.receipt_code) %> + + + <%= icon("badge", size: 12) %> <%= sale.user.username %> + +
    +
    - <%# IMPORTO %> - - <%= format_money(sale.amount) %> - + <%# -- Colonna 3: Importo e Pagamento -- %> +
    +
    + <%= format_money(sale.amount) %> +
    - <%# AZIONI %> - -
    - <%= link_to [sale], - class: "join-item btn btn-square btn-xs btn-ghost tooltip tooltip-left", - data: { tip: "Dettaglio" } do %> - <%= icon("view", size: 16) %> - <% end %> + <%= payment_method_badge(sale.payment_method) %> +
    - <% if current_user.admin? && sale.created_at > 24.hours.ago %> - <%= link_to [sale], - class: "join-item text-error btn btn-square btn-xs btn-ghost tooltip tooltip-left", - data: { - tip: "Archivia", - turbo_method: :delete, - turbo_confirm: "Sei sicuro di volerla archiviare?" - } do %> - <%= icon("delete", size: 16) %> - <% end %> - <% end %> -
    - - + <% unless sale.discarded? %> + <% if current_user.admin? && sale.created_at > 24.hours.ago %> +
    + <%= ui_row_delete_button(sale, confirm: "Sei sicuro di voler archiviare questa vendita?") %> +
    + <% end %> + <% end %> +
  • diff --git a/app/views/sales/_shell.html.erb b/app/views/sales/_shell.html.erb deleted file mode 100644 index 66e8ff6..0000000 --- a/app/views/sales/_shell.html.erb +++ /dev/null @@ -1,93 +0,0 @@ -<% content_for :sale_actions do %> -
      -
    • - - -
        -
      • - <%= active_link_to sale_path(sale), active: :exclusive do %> - <%= icon("badge", size: 18) %> - <%= t("sales.sidebar.details", default: "Panoramica") %> - <% end %> -
      • -
      -
    • -
    -<% end %> - -<% content_for :record_title do %> - <%= sale.product_name_snapshot %> -<% end %> - -<% content_for :record_subtitle do %> -
    - <%# Data Vendita %> -
    - <%= icon("event", size: 12) %> - <%= format_date(sale.sold_on, format: :long) %> -
    - - <%# Acquirente (Linkabile al socio) %> -
    - <%= icon("member", size: 14) %> - <%= link_to sale.member.full_name, member_path(sale.member), class: "font-bold hover:underline" %> -
    - - <%# Stato Annullato %> - <% if sale.discarded? %> - <%= icon("close", size: 12) %> ANNULLATA - <% end %> -
    -<% end %> - -<% content_for :record_actions do %> -
    - <% if (sale.cash? || sale.credit_card?) && sale.kept? %> - <%= link_to sale_path(sale, format: :pdf), - class: "btn btn-primary btn-sm gap-2", - target: "_blank" do %> - <%= icon("print", size: 16) %> - Stampa PDF - <% end %> - <% end %> - - <%# DROPDOWN AZIONI SECONDARIE %> - -
    -<% end %> - -<%# Renderizza l'header standard (che usa i content_for qui sopra) %> - -<%= render "shared/header/record" %> - -<%# --- 3. CONTENUTO DELLA VIEW --- %> - -
    <%= yield %>
    diff --git a/app/views/sales/index.html.erb b/app/views/sales/index.html.erb index 00bdc74..93dd709 100644 --- a/app/views/sales/index.html.erb +++ b/app/views/sales/index.html.erb @@ -2,45 +2,85 @@ <% content_for :page_title, "Vendite" %> -<%# content_for :page_counter, t("search.results", count: @pagy.count) %> +<% content_for :page_counter, "#{@total_active_sales} totali" %> <% content_for :page_actions do %> <%= link_to [:new, :sale], - class: "btn btn-primary gap-2", - data: { turbo_frame: "modal" } do %> - <%= icon("add", size: 20) %> - + class: "btn btn-primary gap-2", + data: { turbo_frame: "modal" } do %> + <%= icon("add") %> + <% end %> <% end %> - +<% content_for :page_search_and_filters do %> + <%= form_with url: sales_path, + method: :get, + html: { + autocomplete: "off", + id: "filter-form", + data: { + controller: "autosubmit drawer", + turbo_frame: "sales_list", + turbo_action: "advance" + } + } do |form| %> -<%= render "shared/header/page" %> +
    + +
    + + + +
    -<%= render "shared/filter/active", filter_keys: @keys %> + <%# DESTRA: Ordinamento %> +
    + <%= render "shared/filter/sort", form: form %> +
    -<%= render "shared/table", pagy: @pagy do %> - - - Data - Ricevuta - Socio - Prodotto - Metodo - Totale - - - +
    - - <%= render partial: "sale_row", collection: @sales, as: :sale %> + <%# IL CASSETTO DEI FILTRI %> + <%= render "shared/filter/drawer", title: "Filtri Vendite" do %> +
    +
    + Nessun filtro avanzato ancora configurato per le vendite. +
    +
    + <% end %> + <% end %> +<% end %> +<%= render "shared/header/page" %> + +<%= turbo_frame_tag "sales_list" do %> + <%= render "shared/filter/active", filter_keys: @keys || [] %> + +
    <% if @sales.empty? %> - - - Nessuna vendita registrata. - - + <%= render "shared/empty_state", + icon_name: "receipt", + title: "Nessuna vendita trovata", + message: "Non ci sono movimenti registrati con questi criteri." %> + <% else %> + <%= filtered_results_counter(@pagy) %> + +
      + <%= render partial: "sale_row", collection: @sales, as: :sale %> +
    + + <%= render "shared/pagination" %> <% end %> - +
    <% end %> diff --git a/app/views/sales/show.html.erb b/app/views/sales/show.html.erb index f8bddd8..944e30c 100644 --- a/app/views/sales/show.html.erb +++ b/app/views/sales/show.html.erb @@ -1,225 +1,164 @@ <%# app/views/sales/show.html.erb %> +<%= turbo_stream_from @sale %> -<%= render layout: "sales/shell", locals: { sale: @sale } do %> -
    - <%# --- 1. RIGA DEI TOTALI (KPI) --- %> -
    - <%# Card 1: IMPORTO %> -
    -
    -
    -
    - <%= icon("euro_symbol", size: 24) %> -
    -
    +<%# --- HEADER CONTEXT --- %> +<%= render "sales/context", sale: @sale %> -
    Importo Transazione
    +<%# --- CONTENUTO PRINCIPALE --- %> +
    -
    - <%= format_money(@sale.amount) %> -
    + <%# --- 1. RIGA DEI TOTALI (KPI) --- %> +
    -
    - <% if @sale.discarded? %> - <%= icon("close", size: 14) %> ANNULLATO - <% else %> - - <%= icon("success", size: 14) %> Pagamento Confermato - - <% end %> + <%# Card 1: IMPORTO %> +
    +
    +
    +
    +
    Importo
    +
    + <%= format_money(@sale.amount) %> +
    +
    +
    + <%= icon("euro_symbol", classes: "size-6") %>
    +
    + <%= transaction_status_indicator(@sale) %> +
    +
    - <%# Card 2: METODO PAGAMENTO %> -
    -
    -
    - <% case @sale.payment_method %> - <% when 'cash' %> - <%= icon("payments", size: 32) %> - <% when 'credit_card' %> - <%= icon("credit_card", size: 32) %> - <% when 'bank_transfer' %> - <%= icon("account_balance", size: 32) %> - <% else %> - <%= icon("paid", size: 32) %> - <% end %> + <%# Card 2: METODO PAGAMENTO %> +
    +
    +
    +
    +
    Metodo
    +
    + <%= display_value(@sale.payment_method.humanize) %> +
    - -
    Metodo Pagamento
    - -
    - <%= display_value(@sale.payment_method.humanize) %> +
    + <%= payment_method_icon(@sale.payment_method, classes: "size-6") %>
    - -
    Registrato a sistema
    +
    Registrato a sistema
    +
    - <%# Card 3: RIFERIMENTO FISCALE %> -
    -
    -
    - <%= icon("receipt", size: 32) %> -
    - -
    Codice Ricevuta
    - -
    - <%= display_value(@sale.receipt_code, placeholder: "N/A") %> + <%# Card 3: RIFERIMENTO FISCALE %> +
    +
    +
    +
    +
    Ricevuta
    +
    + <%= display_value(@sale.receipt_code, placeholder: "N/A") %> +
    - -
    - Anno <%= display_value(@sale.receipt_year) %> - Seq. <%= display_value(@sale.receipt_sequence) %> +
    + <%= icon("receipt", classes: "size-6") %>
    +
    + Anno <%= display_value(@sale.receipt_year) %> - Seq. <%= display_value(@sale.receipt_sequence) %> +
    +
    - <%# --- 2. DETTAGLI (Layout 2/3 + 1/3) --- %> -
    - <%# COLONNA SINISTRA: OGGETTO DELLA VENDITA %> -
    -
    -
    -

    - <%= icon("bag", size: 20) %> Oggetto della Vendita -

    - - <%# Box Prodotto %> -
    -
    -
    - <%= display_value(@sale.product_name_snapshot) %> -
    - -
    - Venduto il <%= format_date(@sale.sold_on, format: :long) %> -
    + <%# --- 2. DETTAGLI (Layout 2/3 + 1/3) --- %> +
    + + <%# COLONNA SINISTRA: OGGETTO DELLA VENDITA (2/3) %> +
    +
    +
    +

    + <%= icon("bag", classes: "size-5") %> Dettagli Prodotto +

    + + <%# Box Prodotto %> +
    +
    +
    + <%= display_value(@sale.product_name_snapshot) %>
    +
    + Data operazione: <%= format_date(@sale.sold_on, format: :long) %> +
    +
    - <%# Link al prodotto originale (se esiste ancora) %> - <%= link_to [@sale.product], class: "btn btn-xs btn-ghost gap-1 opacity-50 hover:opacity-100" do %> - Vedi Prodotto Originale - <%= icon("arrow_right", size: 12) %> + <% if @sale.product %> + <%= link_to [@sale.product], class: "btn btn-sm btn-ghost gap-2 opacity-60 hover:opacity-100" do %> + Vedi Prodotto <%= icon("arrow_forward", classes: "size-4") %> <% end %> -
    + <% end %> +
    - <%# Note %> -
    -
    - Note Transazione + <%# Note %> +
    +

    Note Transazione

    + <% if @sale.notes.present? %> +
    + <%= icon("sticky_note_2", classes: "size-4 float-left mr-2 mt-0.5 opacity-70") %> + <%= @sale.notes %>
    - - <% if @sale.notes.present? %> -
    - <%= icon("sticky_note_2", size: 16, class: "float-left mr-2 mt-0.5") %> - <%= @sale.notes %> -
    - <% else %> - Nessuna nota aggiuntiva. - <% end %> -
    + <% else %> +
    + Nessuna nota aggiuntiva registrata per questa transazione. +
    + <% end %>
    +
    - <%# COLONNA DESTRA: SOGGETTI (CHI E CHI) %> -
    - <%# BOX SOCIO %> -
    -
    -

    - Cliente -

    - -
    -
    -
    - <%= @sale.member.initials %> -
    + <%# COLONNA DESTRA: SOGGETTI (CHI E CHI) (1/3) %> +
    + + <%# BOX CLIENTE %> +
    +
    +

    + <%= icon("member") %> Cliente +

    +
    + <%= ui_avatar(@sale.member, size: "size-10", text_size: "text-md") %> +
    +
    + <%= link_to @sale.member.full_name, member_path(@sale.member) %>
    - -
    -
    - <%= link_to @sale.member.full_name, member_path(@sale.member) %> -
    - -
    - <%= @sale.member.fiscal_code %> -
    +
    + CF: <%= @sale.member.fiscal_code.presence || "N/A" %>
    +
    - <%# BOX OPERATORE %> -
    -
    -

    - Registrato Da -

    - -
    -
    -
    - <%= @sale.user.initials %> -
    + <%# BOX OPERATORE %> +
    +
    +

    + <%= icon("badge") %> Operatore +

    +
    + <%= ui_avatar(@sale.user, size: "size-10", text_size: "text-md") %> +
    +
    + <%= display_value(@sale.user.full_name) %>
    - -
    -
    - <%= display_value(@sale.user.full_name) %> -
    - -
    Staff ActiveCore
    +
    + Registrato il <%= format_date(@sale.created_at) %>
    - -
    - -
    - Creato il: - - <%= l(@sale.created_at, format: :short) %> -
    -
    - <%# --- ALERT DI STORNO (Solo se stornata) --- %> - <% if @sale.discarded? %> -
    - <%= icon("warning", size: 24) %> - -
    -

    Attenzione: Transazione Annullata

    - -
    - Questa vendita è stata stornata il - <%= l(@sale.discarded_at, format: :short) %> - . Non è valida ai fini contabili e non dovrebbe generare accessi. -
    -
    -
    - <% end %> +
    -<% end %> +
    diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index ef4ea9a..8931b51 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -13,7 +13,7 @@
    <%= render "shared/navbar/theme_dropdown" %> - <%= render "shared/navbar/language_dropdown" %> + <%#= render "shared/navbar/language_dropdown" %> <%= render "shared/navbar/user_dropdown" %>
    diff --git a/app/views/shared/_pagination.html.erb b/app/views/shared/_pagination.html.erb index eebeffa..884f73a 100644 --- a/app/views/shared/_pagination.html.erb +++ b/app/views/shared/_pagination.html.erb @@ -1,4 +1,5 @@ -<% pagy ||= @pagy %> - -<%== pagy.series_nav(:daisyui) %> -<%== pagy.info_tag %> +<% if @pagy.pages > 1 %> +
    + <%== @pagy.series_nav(:daisyui) %> +
    +<% end %> diff --git a/app/views/shared/_table.html.erb b/app/views/shared/_table.html.erb deleted file mode 100644 index bba7b13..0000000 --- a/app/views/shared/_table.html.erb +++ /dev/null @@ -1,13 +0,0 @@ -
    -
    -
    - <%= yield %>
    -
    - - <% if local_assigns[:pagy] && pagy.pages > 1 %> -
    - <%= render "shared/pagination", pagy: pagy %> -
    - <% end %> -
    -
    diff --git a/app/views/shared/filter/_active.html.erb b/app/views/shared/filter/_active.html.erb index f51d1d1..8dbf655 100644 --- a/app/views/shared/filter/_active.html.erb +++ b/app/views/shared/filter/_active.html.erb @@ -1,30 +1,33 @@ <% filter_keys ||= [] %> -<% -filter_params = params.permit(*filter_keys) -active_filters = filter_params.to_h.reject { |k, v| v.blank? || k == "query" } -%> +<% active_badges = active_filters.slice(*filter_keys.map(&:to_s)) %> -<% if active_filters.any? %> -
    - <% active_filters.each do |key, value| %> -
    - <%= key.humanize %>: - <%= value %> +<% if active_badges.any? %> +
    + + <% active_badges.each do |key, value| %> +
    + + <%= humanize_filter_key(key) %>: + + + + <%= humanize_filter_value(key, value) %> +
    <% end %>
    <% end %> diff --git a/app/views/shared/filter/_sort.html.erb b/app/views/shared/filter/_sort.html.erb index c1e4ae5..7de0848 100644 --- a/app/views/shared/filter/_sort.html.erb +++ b/app/views/shared/filter/_sort.html.erb @@ -10,7 +10,7 @@ default_sort ||= "created_desc" <%= form.select :sort, sort_options, - { selected: params[:sort] || default_sort }, - { class: "select w-full sm:w-auto", + { prompt: "Ordina per...", selected: params[:sort] || default_sort }, + { class: "select select-bordered w-full sm:w-auto", data: { action: "change->autosubmit#submit" }, aria: { label: "Ordina per" } } %> diff --git a/app/views/shared/header/_page.html.erb b/app/views/shared/header/_page.html.erb index ebe259a..a999837 100644 --- a/app/views/shared/header/_page.html.erb +++ b/app/views/shared/header/_page.html.erb @@ -20,7 +20,7 @@ <%# ====== RIGA 2: Search + Filters ====== %> <% if content_for?(:page_search_and_filters) %> -
    +
    <%= yield :page_search_and_filters %>
    <% end %> diff --git a/app/views/shared/header/_record.html.erb b/app/views/shared/header/_record.html.erb index 6371d8a..9db1f1d 100644 --- a/app/views/shared/header/_record.html.erb +++ b/app/views/shared/header/_record.html.erb @@ -1,4 +1,4 @@ -
    +
    <%# --- 1. BLOCCO IDENTITÀ (Back + Avatar + Testi) --- %>
    diff --git a/app/views/users/_user_row.html.erb b/app/views/users/_user_row.html.erb index 1edb8b8..801ea44 100644 --- a/app/views/users/_user_row.html.erb +++ b/app/views/users/_user_row.html.erb @@ -4,7 +4,7 @@ <%# -- Colonna 1: Avatar -- %>
    - <%= ui_avatar(user, size: "size-10", text_size: "text-xs") %> + <%= ui_avatar(user, size: "size-10", text_size: "text-md") %>
    <%# -- Colonna 2: Contenuto centrale -- %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 8e7dd9a..1b3d1d2 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -6,8 +6,8 @@ <% content_for :page_actions do %> <%= link_to [:new, :user], - class: "btn btn-primary gap-2", - data: { turbo_frame: "modal" } do %> + class: "btn btn-primary gap-2", + data: { turbo_frame: "modal" } do %> <%= icon("add") %> <% end %> @@ -15,12 +15,16 @@ <% content_for :page_search_and_filters do %> <%= form_with url: users_path, - method: :get, - html: { - autocomplete: "off", - id: "filter-form", - data: { controller: "autosubmit drawer" } - } do |form| %> + method: :get, + html: { + autocomplete: "off", + id: "filter-form", + data: { + controller: "autosubmit drawer", + turbo_frame: "users_list", + turbo_action: "advance" + } + } do |form| %>
    @@ -29,10 +33,10 @@ <%= icon("search", classes: "size-5 opacity-50") %> <%= form.search_field :query, - value: params[:query], - placeholder: "Cerca nome, email...", - class: "grow", - data: { action: "input->autosubmit#submit" } %> + value: params[:query], + placeholder: "Cerca nome, email...", + class: "grow", + data: { action: "input->autosubmit#submit" } %>
    @@ -71,33 +75,25 @@ <%= render "shared/header/page" %> -<%= render "shared/filter/active", filter_keys: [:role, :state] %> - -<%# --- INIZIO LISTA --- %> -
    - <% if @users.empty? %> - <%= render "shared/empty_state", - icon_name: "filter_off", - title: t("search.results.zero", default: "Nessun utente trovato"), - message: t("search.results.hint", default: "Prova a cambiare i criteri di ricerca o rimuovere i filtri.") %> - <% else %> - - <%# Feedback contestuale della ricerca %> - <% if @is_filtering %> -
    - Trovati <%= @pagy.count %> risultati -
    - <% end %> +<%= turbo_frame_tag "users_list" do %> + <%= render "shared/filter/active", filter_keys: [:role, :state] %> -
      - <%= render partial: "user_row", collection: @users, as: :user %> -
    + <%# --- INIZIO LISTA --- %> +
    + <% if @users.empty? %> + <%= render "shared/empty_state", + icon_name: "filter_off", + title: t("search.results.zero", default: "Nessun utente trovato"), + message: t("search.results.hint", default: "Prova a cambiare i criteri di ricerca o rimuovere i filtri.") %> + <% else %> - <% if @pagy.pages > 1 %> -
    - <%= render "shared/pagination", pagy: @pagy %> -
    - <% end %> + <%= filtered_results_counter(@pagy) %> - <% end %> -
    +
      + <%= render partial: "user_row", collection: @users, as: :user %> +
    + + <%= render "shared/pagination" %> + <% end %> +
    +<% end %> diff --git a/config/initializers/new_framework_defaults_8_1.rb b/config/initializers/new_framework_defaults_8_1.rb deleted file mode 100644 index 8569b5b..0000000 --- a/config/initializers/new_framework_defaults_8_1.rb +++ /dev/null @@ -1,74 +0,0 @@ -# Be sure to restart your server when you modify this file. -# -# This file eases your Rails 8.1 framework defaults upgrade. -# -# Uncomment each configuration one by one to switch to the new default. -# Once your application is ready to run with all new defaults, you can remove -# this file and set the `config.load_defaults` to `8.1`. -# -# Read the Guide for Upgrading Ruby on Rails for more info on each option. -# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html - -### -# Skips escaping HTML entities and line separators. When set to `false`, the -# JSON renderer no longer escapes these to improve performance. -# -# Example: -# class PostsController < ApplicationController -# def index -# render json: { key: "\u2028\u2029<>&" } -# end -# end -# -# Renders `{"key":"\u2028\u2029\u003c\u003e\u0026"}` with the previous default, but `{"key":"

<>&"}` with the config -# set to `false`. -# -# Applications that want to keep the escaping behavior can set the config to `true`. -#++ -# Rails.configuration.action_controller.escape_json_responses = false - -### -# Skips escaping LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) in JSON. -# -# Historically these characters were not valid inside JavaScript literal strings but that changed in ECMAScript 2019. -# As such it's no longer a concern in modern browsers: https://caniuse.com/mdn-javascript_builtins_json_json_superset. -#++ -# Rails.configuration.active_support.escape_js_separators_in_json = false - -### -# Raises an error when order dependent finder methods (e.g. `#first`, `#second`) are called without `order` values -# on the relation, and the model does not have any order columns (`implicit_order_column`, `query_constraints`, or -# `primary_key`) to fall back on. -# -# The current behavior of not raising an error has been deprecated, and this configuration option will be removed in -# Rails 8.2. -#++ -# Rails.configuration.active_record.raise_on_missing_required_finder_order_columns = true - -### -# Controls how Rails handles path relative URL redirects. -# When set to `:raise`, Rails will raise an `ActionController::Redirecting::UnsafeRedirectError` -# for relative URLs without a leading slash, which can help prevent open redirect vulnerabilities. -# -# Example: -# redirect_to "example.com" # Raises UnsafeRedirectError -# redirect_to "@attacker.com" # Raises UnsafeRedirectError -# redirect_to "/safe/path" # Works correctly -# -# Applications that want to allow these redirects can set the config to `:log` (previous default) -# to only log warnings, or `:notify` to send ActiveSupport notifications. -#++ -# Rails.configuration.action_controller.action_on_path_relative_redirect = :raise - -### -# Use a Ruby parser to track dependencies between Action View templates -#++ -# Rails.configuration.action_view.render_tracker = :ruby - -### -# When enabled, hidden inputs generated by `form_tag`, `token_tag`, `method_tag`, and the hidden parameter fields -# included in `button_to` forms will omit the `autocomplete="off"` attribute. -# -# Applications that want to keep generating the `autocomplete` attribute for those tags can set it to `false`. -#++ -# Rails.configuration.action_view.remove_hidden_field_autocomplete = true diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb deleted file mode 100644 index c64bd0b..0000000 --- a/config/initializers/pagy.rb +++ /dev/null @@ -1,127 +0,0 @@ -# frozen_string_literal: true - -# Pagy initializer file (43.2.2) -# See https://ddnexus.github.io/pagy/resources/initializer/ - -############ Global Options ################################################################ -# See https://ddnexus.github.io/pagy/toolbox/options/ for details. -# Add your global options below. They will be applied globally. -# For example: -# -# Pagy.options[:limit] = 10 # Limit the items per page -# Pagy.options[:client_max_limit] = 100 # The client can request a limit up to 100 -# Pagy.options[:max_pages] = 200 # Allow only 200 pages -# Pagy.options[:jsonapi] = true # Use JSON:API compliant URLs - - -############ JavaScript #################################################################### -# See https://ddnexus.github.io/pagy/resources/javascript/ for details. -# Examples for Rails: -# For apps with an assets pipeline -# Rails.application.config.assets.paths << Pagy::ROOT.join('javascripts') -# -# For apps with a javascript builder (e.g. esbuild, webpack, etc.) -# javascript_dir = Rails.root.join('app/javascript') -# Pagy.sync_javascript(javascript_dir, 'pagy.mjs') if Rails.env.development? - - -############# Overriding Pagy::I18n Lookup ################################################# -# Refer to https://ddnexus.github.io/pagy/resources/i18n/ for details. -# Override the I18n lookup by dropping your custom dictionary in some pagy dir. -# Example for Rails: -# -# Pagy::I18n.pathnames << Rails.root.join('config/locales/pagy') - - -############# I18n Gem Translation ######################################################### -# See https://ddnexus.github.io/pagy/resources/i18n/ for details. -# -# Pagy.translate_with_the_slower_i18n_gem! - - -############# Calendar Localization for non-en locales #################################### -# See https://ddnexus.github.io/pagy/toolbox/paginators/calendar#localization for details. -# Add your desired locales to the list and uncomment the following line to enable them, -# regardless of whether you use the I18n gem for translations or not, whether with -# Rails or not. -# -# Pagy::Calendar.localize_with_rails_i18n_gem(*your_locales) - -# DaisyUI -require "pagy/toolbox/helpers/support/wrap_series_nav" - -class Pagy - private - def daisyui_series_nav(classes: "join", **) - a_lambda = daisyui_a_lambda(**) - html = %(
    #{daisyui_html_for(:previous, a_lambda)}) - series(**).each do |item| - html << case item - when Integer - a_lambda.(item, - classes: "join-item btn") - when String - a_lambda.(item, - page_label(item), - classes: "join-item btn btn-active", - disabled: true) - when :gap - a_lambda.(:gap, - I18n.translate("pagy.gap"), - classes: "join-item btn btn-disabled", - disabled: true) - else - raise InternalError, "expected Integer, String or :gap; got #{item.inspect}" - end - end - html << %(#{daisyui_html_for(:next, a_lambda)}
    ) - end - - def daisyui_html_for(which, a_lambda) - if send(which) - a_lambda.(send(which), - I18n.translate("pagy.#{which}"), - classes: "join-item btn", - aria_label: I18n.translate("pagy.aria_label.#{which}")) - else - a_lambda.(which, - I18n.translate("pagy.#{which}"), - classes: "join-item btn btn-disabled", - disabled: true, - aria_label: I18n.translate("pagy.aria_label.#{which}")) - end - end - - def daisyui_a_lambda(anchor_string: nil, **) - left, right = %(#{text}) - end - - title = if (counts = @options[:counts]) - count = counts[page - 1] - classes = classes ? "#{classes} empty-page" : "empty-page" if count.zero? - info_key = count.zero? ? "pagy.info_tag.no_items" : "pagy.info_tag.single_page" - %( title="#{I18n.translate(info_key, item_name: I18n.translate('pagy.item_name', count:), count:)}") - end - - rel = case page - when @previous then %( rel="prev") - when @next then %( rel="next") - end - - %(#{left}#{page}#{right}#{title}#{ - %( class="#{classes}") if classes}#{rel}#{ - %( aria-label="#{aria_label}") if aria_label}#{ - %( aria-current="#{aria_current}") if aria_current}>#{text}) - end - end -end diff --git a/config/initializers/pagy_daisyui.rb b/config/initializers/pagy_daisyui.rb new file mode 100644 index 0000000..2dbc01a --- /dev/null +++ b/config/initializers/pagy_daisyui.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class Pagy + module NumericHelpers + private + + def daisyui_html_for(which, a_lambda) + base_classes = "join-item btn bg-base-100 border-base-300 text-base-content/70 hover:bg-base-200 hover:text-base-content" + + if send(which) + a_lambda.(send(which), I18n.translate("pagy.#{which}"), + classes: base_classes, + aria_label: I18n.translate("pagy.aria_label.#{which}")) + else + %(#{I18n.translate("pagy.#{which}")}) + end + end + + def daisyui_series_nav(classes: "join", **) + a_lambda = a_lambda(**) + + html = %(
    #{daisyui_html_for(:previous, a_lambda)}) + series(**).each do |item| + html << case item + when Integer + # Normal page links + a_lambda.(item, classes: "join-item btn bg-base-100 border-base-300 text-base-content/70 hover:bg-base-200 hover:text-base-content") + when String + # Active page + %(#{page_label(item)}) + when :gap + # Gap (...) + %(#{I18n.translate('pagy.gap')}) + else raise InternalError, "expected item types in series to be Integer, String or :gap; got #{item.inspect}" + end + end + html << %(#{daisyui_html_for(:next, a_lambda)}
    ) + + wrap_series_nav(html, "pagy-daisyui series-nav", **) + end + + def daisyui_series_nav_js(classes: "join", **) + a_lambda = a_lambda(**) + + tokens = { before: %(
    #{daisyui_html_for(:previous, a_lambda)}), + anchor: a_lambda.(PAGE_TOKEN, LABEL_TOKEN, classes: "join-item btn bg-base-100 border-base-300 text-base-content/70 hover:bg-base-200 hover:text-base-content"), + current: %(#{LABEL_TOKEN}), + gap: %(#{I18n.translate('pagy.gap')}), + after: %(#{daisyui_html_for(:next, a_lambda)}
    ) } + + wrap_series_nav_js(tokens, "pagy-daisyui series-nav-js", **) + end + + def daisyui_input_nav_js(classes: "join", **) + a_lambda = a_lambda(**) + + input = %(#{A_TAG}) + + html = %(
    #{ + daisyui_html_for(:previous, a_lambda) + }#{ + I18n.translate('pagy.input_nav_js', page_input: input, pages: @last) + }#{ + daisyui_html_for(:next, a_lambda) + }
    ) + + wrap_input_nav_js(html, "pagy-daisyui input-nav-js", **) + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 2a3237f..04c9a66 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -41,7 +41,7 @@ # ============================================================================ resources :sales, only: [ :index, :new, :create, :show, :destroy ] - resources :subscriptions, only: [ :edit, :update, :destroy ] + resources :subscriptions, only: [ :index, :edit, :update, :destroy ] resources :receipt_counters # ============================================================================ diff --git a/db/migrate/20260323183701_create_members_fts.rb b/db/migrate/20260323183701_create_members_fts.rb index c3e81a1..bdb455b 100644 --- a/db/migrate/20260323183701_create_members_fts.rb +++ b/db/migrate/20260323183701_create_members_fts.rb @@ -1,5 +1,10 @@ class CreateMembersFts < ActiveRecord::Migration[8.1] def up + execute "DROP TRIGGER IF EXISTS members_ai" + execute "DROP TRIGGER IF EXISTS members_ad" + execute "DROP TRIGGER IF EXISTS members_au" + execute "DROP TABLE IF EXISTS members_fts" + execute <<-SQL CREATE VIRTUAL TABLE members_fts USING fts5( first_name, @@ -7,6 +12,7 @@ def up fiscal_code, email_address, phone, + birth_date, content='members', content_rowid='id' ); @@ -14,27 +20,29 @@ def up execute <<-SQL CREATE TRIGGER members_ai AFTER INSERT ON members BEGIN - INSERT INTO members_fts(rowid, first_name, last_name, fiscal_code, email_address, phone) - VALUES (new.id, new.first_name, new.last_name, new.fiscal_code, new.email_address, new.phone); + INSERT INTO members_fts(rowid, first_name, last_name, fiscal_code, email_address, phone, birth_date) + VALUES (new.id, new.first_name, new.last_name, new.fiscal_code, new.email_address, new.phone, new.birth_date); END; SQL execute <<-SQL CREATE TRIGGER members_ad AFTER DELETE ON members BEGIN - INSERT INTO members_fts(members_fts, rowid, first_name, last_name, fiscal_code, email_address, phone) - VALUES ('delete', old.id, old.first_name, old.last_name, old.fiscal_code, old.email_address, old.phone); + INSERT INTO members_fts(members_fts, rowid, first_name, last_name, fiscal_code, email_address, phone, birth_date) + VALUES ('delete', old.id, old.first_name, old.last_name, old.fiscal_code, old.email_address, old.phone, old.birth_date); END; SQL execute <<-SQL CREATE TRIGGER members_au AFTER UPDATE ON members BEGIN - INSERT INTO members_fts(members_fts, rowid, first_name, last_name, fiscal_code, email_address, phone) - VALUES ('delete', old.id, old.first_name, old.last_name, old.fiscal_code, old.email_address, old.phone); - #{' '} - INSERT INTO members_fts(rowid, first_name, last_name, fiscal_code, email_address, phone) - VALUES (new.id, new.first_name, new.last_name, new.fiscal_code, new.email_address, new.phone); + INSERT INTO members_fts(members_fts, rowid, first_name, last_name, fiscal_code, email_address, phone, birth_date) + VALUES ('delete', old.id, old.first_name, old.last_name, old.fiscal_code, old.email_address, old.phone, old.birth_date); + + INSERT INTO members_fts(rowid, first_name, last_name, fiscal_code, email_address, phone, birth_date) + VALUES (new.id, new.first_name, new.last_name, new.fiscal_code, new.email_address, new.phone, new.birth_date); END; SQL + + execute "INSERT INTO members_fts(members_fts) VALUES('rebuild');" end def down diff --git a/db/seeds.rb b/db/seeds.rb index 17f97a8..e69de29 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,14 +0,0 @@ -admin = User.create!( - username: "admin", - first_name: "Mario", last_name: "Capo", - email_address: "admin@kento.it", - password: "password", - role: :admin -) - -puts "✅ SEED COMPLETATO CON SUCCESSO!" -puts "-------------------------------------------" -puts "Credenziali Staff:" -puts "Username: admin / Password: password" -puts "cambiare la password il prima possibile" -puts "-------------------------------------------" diff --git a/db/structure.sql b/db/structure.sql index 82547c8..a0208fb 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1,80 +1,80 @@ -CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY); -CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS "sessions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "ip_address" varchar, "user_agent" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_758836b4f0" +CREATE TABLE IF NOT EXISTS "disciplines" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "name" varchar NOT NULL, "requires_medical_certificate" boolean DEFAULT TRUE NOT NULL, "requires_membership" boolean DEFAULT TRUE NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE INDEX "index_disciplines_on_discarded_at" ON "disciplines" ("discarded_at"); +CREATE UNIQUE INDEX "index_disciplines_on_name" ON "disciplines" ("name") WHERE discarded_at IS NULL; +CREATE TABLE IF NOT EXISTS "gym_profiles" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "address_line_1" varchar, "address_line_2" varchar, "bank_iban" varchar, "city" varchar, "created_at" datetime(6) NOT NULL, "email" varchar, "name" varchar, "phone" varchar, "updated_at" datetime(6) NOT NULL, "vat_number" varchar, "zip_code" varchar); +CREATE TABLE IF NOT EXISTS "products" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "accounting_category" varchar DEFAULT 'institutional' NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "duration_days" integer NOT NULL, "name" varchar NOT NULL, "price_cents" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE INDEX "index_products_on_discarded_at" ON "products" ("discarded_at"); +CREATE UNIQUE INDEX "index_products_on_name" ON "products" ("name") WHERE discarded_at IS NULL; +CREATE TABLE IF NOT EXISTS "receipt_counters" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "last_number" integer DEFAULT 0 NOT NULL, "sequence_category" varchar NOT NULL, "updated_at" datetime(6) NOT NULL, "year" integer NOT NULL); +CREATE UNIQUE INDEX "index_receipt_counters_on_year_and_sequence_category" ON "receipt_counters" ("year", "sequence_category"); +CREATE TABLE IF NOT EXISTS "activity_logs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "action" varchar NOT NULL, "changes_set" json DEFAULT '{}', "created_at" datetime(6) NOT NULL, "subject_id" integer NOT NULL, "subject_type" varchar NOT NULL, "updated_at" datetime(6) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "fk_rails_c9badf82db" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ); -CREATE INDEX "index_sessions_on_user_id" ON "sessions" ("user_id") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "disciplines" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "requires_medical_certificate" boolean DEFAULT TRUE NOT NULL, "requires_membership" boolean DEFAULT TRUE NOT NULL, "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE INDEX "index_disciplines_on_discarded_at" ON "disciplines" ("discarded_at") /*application='ActiveCore'*/; -CREATE UNIQUE INDEX "index_disciplines_on_name" ON "disciplines" ("name") WHERE discarded_at IS NULL /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "products" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "price_cents" integer DEFAULT 0 NOT NULL, "duration_days" integer NOT NULL, "accounting_category" varchar DEFAULT 'institutional' NOT NULL, "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE INDEX "index_products_on_discarded_at" ON "products" ("discarded_at") /*application='ActiveCore'*/; -CREATE UNIQUE INDEX "index_products_on_name" ON "products" ("name") WHERE discarded_at IS NULL /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "product_disciplines" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "product_id" integer NOT NULL, "discipline_id" integer NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_3e95f394f9" -FOREIGN KEY ("product_id") - REFERENCES "products" ("id") -, CONSTRAINT "fk_rails_78b6087a54" +CREATE INDEX "index_activity_logs_on_subject" ON "activity_logs" ("subject_type", "subject_id"); +CREATE INDEX "index_activity_logs_on_user_id_and_created_at" ON "activity_logs" ("user_id", "created_at"); +CREATE INDEX "index_activity_logs_on_user_id" ON "activity_logs" ("user_id"); +CREATE TABLE IF NOT EXISTS "feedbacks" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "admin_notes" text, "browser_info" varchar, "created_at" datetime(6) NOT NULL, "message" text NOT NULL, "page_url" varchar, "status" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "fk_rails_c57bb6cf28" +FOREIGN KEY ("user_id") + REFERENCES "users" ("id") +); +CREATE INDEX "index_feedbacks_on_status" ON "feedbacks" ("status"); +CREATE INDEX "index_feedbacks_on_user_id" ON "feedbacks" ("user_id"); +CREATE TABLE IF NOT EXISTS "product_disciplines" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discipline_id" integer NOT NULL, "product_id" integer NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_78b6087a54" FOREIGN KEY ("discipline_id") REFERENCES "disciplines" ("id") -); -CREATE INDEX "index_product_disciplines_on_product_id" ON "product_disciplines" ("product_id") /*application='ActiveCore'*/; -CREATE INDEX "index_product_disciplines_on_discipline_id" ON "product_disciplines" ("discipline_id") /*application='ActiveCore'*/; -CREATE UNIQUE INDEX "index_product_disciplines_on_product_id_and_discipline_id" ON "product_disciplines" ("product_id", "discipline_id") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "sales" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "user_id" integer NOT NULL, "product_name_snapshot" varchar NOT NULL, "amount_cents" integer DEFAULT 0 NOT NULL, "payment_method" integer DEFAULT 0 NOT NULL, "sold_on" date NOT NULL, "notes" text, "receipt_sequence" varchar, "receipt_number" integer, "receipt_year" integer, "receipt_code" varchar GENERATED ALWAYS AS (receipt_year || '-' || receipt_sequence || '-' || receipt_number) STORED, "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_935e249f94" -FOREIGN KEY ("member_id") - REFERENCES "members" ("id") -, CONSTRAINT "fk_rails_afd82832c8" +, CONSTRAINT "fk_rails_3e95f394f9" FOREIGN KEY ("product_id") REFERENCES "products" ("id") -, CONSTRAINT "fk_rails_8e94f16ccc" -FOREIGN KEY ("user_id") - REFERENCES "users" ("id") ); -CREATE INDEX "index_sales_on_member_id" ON "sales" ("member_id") /*application='ActiveCore'*/; -CREATE INDEX "index_sales_on_product_id" ON "sales" ("product_id") /*application='ActiveCore'*/; -CREATE INDEX "index_sales_on_user_id" ON "sales" ("user_id") /*application='ActiveCore'*/; -CREATE INDEX "index_sales_on_discarded_at" ON "sales" ("discarded_at") /*application='ActiveCore'*/; -CREATE UNIQUE INDEX "idx_on_receipt_year_receipt_sequence_receipt_number_3689acdaf9" ON "sales" ("receipt_year", "receipt_sequence", "receipt_number") WHERE receipt_number IS NOT NULL /*application='ActiveCore'*/; -CREATE INDEX "index_sales_on_receipt_code" ON "sales" ("receipt_code") /*application='ActiveCore'*/; -CREATE INDEX "index_sales_on_sold_on" ON "sales" ("sold_on") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "subscriptions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "sale_id" integer NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "suspension_days_count" integer DEFAULT 0 NOT NULL, "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_bfac3ecd2f" -FOREIGN KEY ("member_id") - REFERENCES "members" ("id") -, CONSTRAINT "fk_rails_52a3b81fce" +CREATE INDEX "index_product_disciplines_on_discipline_id" ON "product_disciplines" ("discipline_id"); +CREATE UNIQUE INDEX "index_product_disciplines_on_product_id_and_discipline_id" ON "product_disciplines" ("product_id", "discipline_id"); +CREATE INDEX "index_product_disciplines_on_product_id" ON "product_disciplines" ("product_id"); +CREATE TABLE IF NOT EXISTS "sales" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "amount_cents" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "member_id" integer NOT NULL, "notes" text, "payment_method" integer DEFAULT 0 NOT NULL, "product_id" integer NOT NULL, "product_name_snapshot" varchar NOT NULL, "receipt_code" varchar GENERATED ALWAYS AS (receipt_year || '-' || receipt_sequence || '-' || receipt_number) STORED, "receipt_number" integer, "receipt_sequence" varchar, "receipt_year" integer, "sold_on" date NOT NULL, "updated_at" datetime(6) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "fk_rails_afd82832c8" FOREIGN KEY ("product_id") REFERENCES "products" ("id") -, CONSTRAINT "fk_rails_bb36d9c2a0" -FOREIGN KEY ("sale_id") - REFERENCES "sales" ("id") -); -CREATE INDEX "index_subscriptions_on_member_id" ON "subscriptions" ("member_id") /*application='ActiveCore'*/; -CREATE INDEX "index_subscriptions_on_product_id" ON "subscriptions" ("product_id") /*application='ActiveCore'*/; -CREATE INDEX "index_subscriptions_on_sale_id" ON "subscriptions" ("sale_id") /*application='ActiveCore'*/; -CREATE INDEX "index_subscriptions_on_discarded_at" ON "subscriptions" ("discarded_at") /*application='ActiveCore'*/; -CREATE INDEX "index_subscriptions_on_member_id_and_end_date" ON "subscriptions" ("member_id", "end_date") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "activity_logs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "action" varchar NOT NULL, "subject_type" varchar NOT NULL, "subject_id" integer NOT NULL, "changes_set" json DEFAULT '{}', "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_c9badf82db" +, CONSTRAINT "fk_rails_935e249f94" +FOREIGN KEY ("member_id") + REFERENCES "members" ("id") +, CONSTRAINT "fk_rails_8e94f16ccc" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ); -CREATE INDEX "index_activity_logs_on_user_id" ON "activity_logs" ("user_id") /*application='ActiveCore'*/; -CREATE INDEX "index_activity_logs_on_subject" ON "activity_logs" ("subject_type", "subject_id") /*application='ActiveCore'*/; -CREATE INDEX "index_activity_logs_on_user_id_and_created_at" ON "activity_logs" ("user_id", "created_at") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "feedbacks" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "message" text NOT NULL, "page_url" varchar, "browser_info" varchar, "status" integer DEFAULT 0 NOT NULL, "admin_notes" text, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_c57bb6cf28" +CREATE INDEX "index_sales_on_discarded_at" ON "sales" ("discarded_at"); +CREATE INDEX "index_sales_on_member_id" ON "sales" ("member_id"); +CREATE INDEX "index_sales_on_product_id" ON "sales" ("product_id"); +CREATE INDEX "index_sales_on_receipt_code" ON "sales" ("receipt_code"); +CREATE UNIQUE INDEX "idx_on_receipt_year_receipt_sequence_receipt_number_3689acdaf9" ON "sales" ("receipt_year", "receipt_sequence", "receipt_number") WHERE receipt_number IS NOT NULL; +CREATE INDEX "index_sales_on_sold_on" ON "sales" ("sold_on"); +CREATE INDEX "index_sales_on_user_id" ON "sales" ("user_id"); +CREATE TABLE IF NOT EXISTS "sessions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "ip_address" varchar, "updated_at" datetime(6) NOT NULL, "user_agent" varchar, "user_id" integer NOT NULL, CONSTRAINT "fk_rails_758836b4f0" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ); -CREATE INDEX "index_feedbacks_on_user_id" ON "feedbacks" ("user_id") /*application='ActiveCore'*/; -CREATE INDEX "index_feedbacks_on_status" ON "feedbacks" ("status") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "receipt_counters" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "year" integer NOT NULL, "sequence_category" varchar NOT NULL, "last_number" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE UNIQUE INDEX "index_receipt_counters_on_year_and_sequence_category" ON "receipt_counters" ("year", "sequence_category") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "gym_profiles" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar, "address_line_1" varchar, "address_line_2" varchar, "zip_code" varchar, "city" varchar, "vat_number" varchar, "email" varchar, "phone" varchar, "bank_iban" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS "access_logs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "member_id" integer NOT NULL, "subscription_id" integer, "checkin_by_user_id" integer NOT NULL, "entered_at" datetime(6) NOT NULL, "medical_certificate_valid" boolean DEFAULT FALSE NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "discipline_id" integer, "status" integer DEFAULT 0 NOT NULL /*application='ActiveCore'*/, CONSTRAINT "fk_rails_21592df11b" +CREATE INDEX "index_sessions_on_user_id" ON "sessions" ("user_id"); +CREATE TABLE IF NOT EXISTS "subscriptions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "end_date" date NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "sale_id" integer NOT NULL, "start_date" date NOT NULL, "suspension_days_count" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_52a3b81fce" +FOREIGN KEY ("product_id") + REFERENCES "products" ("id") +, CONSTRAINT "fk_rails_bfac3ecd2f" FOREIGN KEY ("member_id") REFERENCES "members" ("id") -, CONSTRAINT "fk_rails_df50081f1b" +, CONSTRAINT "fk_rails_bb36d9c2a0" +FOREIGN KEY ("sale_id") + REFERENCES "sales" ("id") +); +CREATE INDEX "index_subscriptions_on_discarded_at" ON "subscriptions" ("discarded_at"); +CREATE INDEX "index_subscriptions_on_member_id_and_end_date" ON "subscriptions" ("member_id", "end_date"); +CREATE INDEX "index_subscriptions_on_member_id" ON "subscriptions" ("member_id"); +CREATE INDEX "index_subscriptions_on_product_id" ON "subscriptions" ("product_id"); +CREATE INDEX "index_subscriptions_on_sale_id" ON "subscriptions" ("sale_id"); +CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY); +CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE TABLE IF NOT EXISTS "access_logs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "checkin_by_user_id" integer NOT NULL, "created_at" datetime(6) NOT NULL, "entered_at" datetime(6) NOT NULL, "medical_certificate_valid" boolean DEFAULT FALSE NOT NULL, "member_id" integer NOT NULL, "subscription_id" integer, "updated_at" datetime(6) NOT NULL, "discipline_id" integer, "status" integer DEFAULT 0 NOT NULL /*application='ActiveCore'*/, CONSTRAINT "fk_rails_df50081f1b" FOREIGN KEY ("subscription_id") REFERENCES "subscriptions" ("id") +, CONSTRAINT "fk_rails_21592df11b" +FOREIGN KEY ("member_id") + REFERENCES "members" ("id") , CONSTRAINT "fk_rails_1f32fe057e" FOREIGN KEY ("checkin_by_user_id") REFERENCES "users" ("id") @@ -82,49 +82,54 @@ FOREIGN KEY ("checkin_by_user_id") FOREIGN KEY ("discipline_id") REFERENCES "disciplines" ("id") ); -CREATE INDEX "index_access_logs_on_member_id" ON "access_logs" ("member_id") /*application='ActiveCore'*/; -CREATE INDEX "index_access_logs_on_subscription_id" ON "access_logs" ("subscription_id") /*application='ActiveCore'*/; CREATE INDEX "index_access_logs_on_checkin_by_user_id" ON "access_logs" ("checkin_by_user_id") /*application='ActiveCore'*/; CREATE INDEX "index_access_logs_on_entered_at" ON "access_logs" ("entered_at") /*application='ActiveCore'*/; CREATE INDEX "index_access_logs_on_member_id_and_entered_at" ON "access_logs" ("member_id", "entered_at") /*application='ActiveCore'*/; +CREATE INDEX "index_access_logs_on_member_id" ON "access_logs" ("member_id") /*application='ActiveCore'*/; +CREATE INDEX "index_access_logs_on_subscription_id" ON "access_logs" ("subscription_id") /*application='ActiveCore'*/; CREATE INDEX "index_access_logs_on_discipline_id" ON "access_logs" ("discipline_id") /*application='ActiveCore'*/; CREATE INDEX "index_access_logs_on_status" ON "access_logs" ("status") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email_address" varchar NOT NULL, "password_digest" varchar NOT NULL, "username" varchar NOT NULL, "first_name" varchar NOT NULL, "last_name" varchar NOT NULL, "full_name" varchar GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL, "role" integer DEFAULT 0 NOT NULL, "preferences" json DEFAULT '{}', "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE INDEX "index_users_on_preferences" ON "users" ("preferences") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "email_address" varchar NOT NULL, "first_name" varchar NOT NULL, "full_name" varchar GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL, "last_name" varchar NOT NULL, "password_digest" varchar NOT NULL, "preferences" json DEFAULT '{}', "role" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, "username" varchar NOT NULL); CREATE INDEX "index_users_on_discarded_at" ON "users" ("discarded_at") /*application='ActiveCore'*/; CREATE UNIQUE INDEX "index_users_on_email_address" ON "users" ("email_address") WHERE discarded_at IS NULL /*application='ActiveCore'*/; -CREATE UNIQUE INDEX "index_users_on_username" ON "users" ("username") WHERE discarded_at IS NULL /*application='ActiveCore'*/; +CREATE INDEX "index_users_on_preferences" ON "users" ("preferences") /*application='ActiveCore'*/; CREATE INDEX "index_users_on_role" ON "users" ("role") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "members" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "first_name" varchar NOT NULL, "last_name" varchar NOT NULL, "full_name" varchar GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL, "fiscal_code" varchar NOT NULL, "birth_date" date NOT NULL, "email_address" varchar, "phone" varchar, "address" varchar, "city" varchar, "zip_code" varchar, "full_address" varchar GENERATED ALWAYS AS (address || ', ' || city || ' (' || zip_code || ')') VIRTUAL, "medical_certificate_expiry" date, "discarded_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); -CREATE INDEX "index_members_on_medical_certificate_expiry" ON "members" ("medical_certificate_expiry") /*application='ActiveCore'*/; +CREATE UNIQUE INDEX "index_users_on_username" ON "users" ("username") WHERE discarded_at IS NULL /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "members" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "address" varchar, "birth_date" date NOT NULL, "city" varchar, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "email_address" varchar, "first_name" varchar NOT NULL, "fiscal_code" varchar NOT NULL, "full_address" varchar GENERATED ALWAYS AS (address || ', ' || city || ' (' || zip_code || ')') VIRTUAL, "full_name" varchar GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL, "last_name" varchar NOT NULL, "medical_certificate_expiry" date, "phone" varchar, "updated_at" datetime(6) NOT NULL, "zip_code" varchar); CREATE INDEX "index_members_on_discarded_at" ON "members" ("discarded_at") /*application='ActiveCore'*/; CREATE UNIQUE INDEX "index_members_on_fiscal_code" ON "members" ("fiscal_code") WHERE discarded_at IS NULL /*application='ActiveCore'*/; -CREATE INDEX "index_members_on_full_name" ON "members" ("full_name") /*application='ActiveCore'*/; CREATE INDEX "index_members_on_full_address" ON "members" ("full_address") /*application='ActiveCore'*/; +CREATE INDEX "index_members_on_full_name" ON "members" ("full_name") /*application='ActiveCore'*/; +CREATE INDEX "index_members_on_medical_certificate_expiry" ON "members" ("medical_certificate_expiry") /*application='ActiveCore'*/; CREATE VIRTUAL TABLE members_fts USING fts5( first_name, last_name, fiscal_code, email_address, phone, + birth_date, content='members', content_rowid='id' ) -/* members_fts(first_name,last_name,fiscal_code,email_address,phone) */; +/* members_fts(first_name,last_name,fiscal_code,email_address,phone,birth_date) */; +CREATE TABLE IF NOT EXISTS 'members_fts_data'(id INTEGER PRIMARY KEY, block BLOB); +CREATE TABLE IF NOT EXISTS 'members_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID; +CREATE TABLE IF NOT EXISTS 'members_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB); +CREATE TABLE IF NOT EXISTS 'members_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID; CREATE TRIGGER members_ai AFTER INSERT ON members BEGIN - INSERT INTO members_fts(rowid, first_name, last_name, fiscal_code, email_address, phone) - VALUES (new.id, new.first_name, new.last_name, new.fiscal_code, new.email_address, new.phone); + INSERT INTO members_fts(rowid, first_name, last_name, fiscal_code, email_address, phone, birth_date) + VALUES (new.id, new.first_name, new.last_name, new.fiscal_code, new.email_address, new.phone, new.birth_date); END; CREATE TRIGGER members_ad AFTER DELETE ON members BEGIN - INSERT INTO members_fts(members_fts, rowid, first_name, last_name, fiscal_code, email_address, phone) - VALUES ('delete', old.id, old.first_name, old.last_name, old.fiscal_code, old.email_address, old.phone); + INSERT INTO members_fts(members_fts, rowid, first_name, last_name, fiscal_code, email_address, phone, birth_date) + VALUES ('delete', old.id, old.first_name, old.last_name, old.fiscal_code, old.email_address, old.phone, old.birth_date); END; CREATE TRIGGER members_au AFTER UPDATE ON members BEGIN - INSERT INTO members_fts(members_fts, rowid, first_name, last_name, fiscal_code, email_address, phone) - VALUES ('delete', old.id, old.first_name, old.last_name, old.fiscal_code, old.email_address, old.phone); - - INSERT INTO members_fts(rowid, first_name, last_name, fiscal_code, email_address, phone) - VALUES (new.id, new.first_name, new.last_name, new.fiscal_code, new.email_address, new.phone); + INSERT INTO members_fts(members_fts, rowid, first_name, last_name, fiscal_code, email_address, phone, birth_date) + VALUES ('delete', old.id, old.first_name, old.last_name, old.fiscal_code, old.email_address, old.phone, old.birth_date); + + INSERT INTO members_fts(rowid, first_name, last_name, fiscal_code, email_address, phone, birth_date) + VALUES (new.id, new.first_name, new.last_name, new.fiscal_code, new.email_address, new.phone, new.birth_date); END; INSERT INTO "schema_migrations" (version) VALUES ('20260323183701'), diff --git a/lib/tasks/fts.rake b/lib/tasks/fts.rake new file mode 100644 index 0000000..7c28a0e --- /dev/null +++ b/lib/tasks/fts.rake @@ -0,0 +1,14 @@ +namespace :fts do + desc "Ricostruisce gli indici di ricerca Full-Text (FTS5) in SQLite" + task rebuild: :environment do + puts "🔄 Ricostruzione dell'indice FTS per i Membri in corso..." + + start_time = Time.current + + ActiveRecord::Base.connection.execute("INSERT INTO members_fts(members_fts) VALUES('rebuild');") + + elapsed = Time.current - start_time + + puts "✅ FTS ricostruito con successo in #{elapsed.round(2)} secondi!" + end +end From 70e3ebc8f129706b2aa20457dfb3cc8a6ca11621 Mon Sep 17 00:00:00 2001 From: jcostd Date: Wed, 1 Apr 2026 15:41:05 +0200 Subject: [PATCH 13/34] Updated UI --- app/models/concerns/personable.rb | 4 ---- app/models/discipline.rb | 2 -- app/queries/disciplines_query.rb | 6 ++++++ app/queries/sales_query.rb | 1 - app/queries/users_query.rb | 5 +++-- app/views/members/searches/index.html.erb | 15 ++++++--------- test/models/concerns/personable_test.rb | 5 ----- 7 files changed, 15 insertions(+), 23 deletions(-) diff --git a/app/models/concerns/personable.rb b/app/models/concerns/personable.rb index 599166e..7ee73f8 100644 --- a/app/models/concerns/personable.rb +++ b/app/models/concerns/personable.rb @@ -9,8 +9,4 @@ module Personable validates :first_name, :last_name, presence: true validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true end - - def full_name_ruby - "#{first_name} #{last_name}" - end end diff --git a/app/models/discipline.rb b/app/models/discipline.rb index d68baee..329cd76 100644 --- a/app/models/discipline.rb +++ b/app/models/discipline.rb @@ -8,8 +8,6 @@ class Discipline < ApplicationRecord normalizes :name, with: ->(n) { n.squish.titleize } validates :name, presence: true, uniqueness: { conditions: -> { kept } } - scope :search_text, ->(query) { where("name LIKE ?", "%#{query}%") } - def recent_subscriptions Subscription.kept .where(product_id: product_ids) diff --git a/app/queries/disciplines_query.rb b/app/queries/disciplines_query.rb index 844a4b8..af490bd 100644 --- a/app/queries/disciplines_query.rb +++ b/app/queries/disciplines_query.rb @@ -4,6 +4,12 @@ def default_relation Discipline.all end + def filter_by_search(scope) + return scope if @params[:query].blank? + + scope.where("disciplines.name LIKE ?", "%#{@params[:query]}%") + end + def apply_sorting(scope) case @params[:sort] when "name_desc" then scope.order(name: :desc) diff --git a/app/queries/sales_query.rb b/app/queries/sales_query.rb index f9bbecf..783a2bc 100644 --- a/app/queries/sales_query.rb +++ b/app/queries/sales_query.rb @@ -12,7 +12,6 @@ def filter_by_search(scope) return scope if @params[:query].blank? term = "%#{@params[:query]}%" - scope.joins(:member).where( "CAST(sales.receipt_number AS TEXT) LIKE :q OR members.first_name LIKE :q OR members.last_name LIKE :q", q: term diff --git a/app/queries/users_query.rb b/app/queries/users_query.rb index ebec98a..721a96b 100644 --- a/app/queries/users_query.rb +++ b/app/queries/users_query.rb @@ -7,9 +7,10 @@ def default_relation def filter_by_search(scope) return scope if @params[:query].blank? + term = "%#{@params[:query]}%" scope.where( - "users.first_name LIKE :term OR users.last_name LIKE :term OR users.email_address LIKE :term OR users.username LIKE :term", - term: term + "users.first_name LIKE :q OR users.last_name LIKE :q OR users.email_address LIKE :q OR users.username LIKE :q", + q: term ) end diff --git a/app/views/members/searches/index.html.erb b/app/views/members/searches/index.html.erb index 135136b..102a8ac 100644 --- a/app/views/members/searches/index.html.erb +++ b/app/views/members/searches/index.html.erb @@ -3,15 +3,14 @@
      <% @members.each do |member| %> -
    • -
    • <% end %> diff --git a/test/models/concerns/personable_test.rb b/test/models/concerns/personable_test.rb index 547cc7a..424b7ac 100644 --- a/test/models/concerns/personable_test.rb +++ b/test/models/concerns/personable_test.rb @@ -35,9 +35,4 @@ class PersonableTest < ActiveSupport::TestCase user.validate assert_not user.errors[:email_address].present? end - - test "full_name_ruby helper works" do - user = User.new(first_name: "Mario", last_name: "Rossi") - assert_equal "Mario Rossi", user.full_name_ruby - end end From 1b9a40478be679745f8898ed4c49f4c51a02e36c Mon Sep 17 00:00:00 2001 From: jcostd Date: Wed, 1 Apr 2026 17:54:11 +0200 Subject: [PATCH 14/34] Updated UI --- app/views/sales/index.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/sales/index.html.erb b/app/views/sales/index.html.erb index 93dd709..dbe2672 100644 --- a/app/views/sales/index.html.erb +++ b/app/views/sales/index.html.erb @@ -9,7 +9,7 @@ class: "btn btn-primary gap-2", data: { turbo_frame: "modal" } do %> <%= icon("add") %> - + <% end %> <% end %> From 0cf8d98146ac0709af42dfa7a418c2e24c3cadc9 Mon Sep 17 00:00:00 2001 From: jcostd Date: Wed, 1 Apr 2026 19:47:48 +0200 Subject: [PATCH 15/34] Updated Schema --- app/controllers/kiosk/base_controller.rb | 3 + .../kiosk/disciplines_controller.rb | 17 ++++ app/models/access_log.rb | 23 ++--- app/models/access_policy.rb | 91 +++++++++++++++++++ app/models/concerns/subscription_issuer.rb | 20 ++-- app/models/daily_cash.rb | 14 +-- app/models/member.rb | 20 ++-- app/models/product.rb | 6 ++ app/models/receipt_counter.rb | 7 +- app/models/sale.rb | 40 +++++--- app/models/subscription.rb | 24 ++++- app/views/kiosk/disciplines/index.html.erb | 29 ++++++ app/views/kiosk/disciplines/show.html.erb | 54 +++++++++++ app/views/kiosk/members/_card.html.erb | 26 ++++++ app/views/layouts/kiosk.html.erb | 57 ++++++++++++ config/routes.rb | 9 ++ ...try_limit_to_products_and_subscriptions.rb | 6 ++ ...5_invert_subscription_sale_relationship.rb | 19 ++++ db/structure.sql | 72 +++++++-------- 19 files changed, 436 insertions(+), 101 deletions(-) create mode 100644 app/controllers/kiosk/base_controller.rb create mode 100644 app/controllers/kiosk/disciplines_controller.rb create mode 100644 app/models/access_policy.rb create mode 100644 app/views/kiosk/disciplines/index.html.erb create mode 100644 app/views/kiosk/disciplines/show.html.erb create mode 100644 app/views/kiosk/members/_card.html.erb create mode 100644 app/views/layouts/kiosk.html.erb create mode 100644 db/migrate/20260401155446_add_entry_limit_to_products_and_subscriptions.rb create mode 100644 db/migrate/20260401155455_invert_subscription_sale_relationship.rb diff --git a/app/controllers/kiosk/base_controller.rb b/app/controllers/kiosk/base_controller.rb new file mode 100644 index 0000000..113dd83 --- /dev/null +++ b/app/controllers/kiosk/base_controller.rb @@ -0,0 +1,3 @@ +class Kiosk::BaseController < ApplicationController + layout "kiosk" +end diff --git a/app/controllers/kiosk/disciplines_controller.rb b/app/controllers/kiosk/disciplines_controller.rb new file mode 100644 index 0000000..d375502 --- /dev/null +++ b/app/controllers/kiosk/disciplines_controller.rb @@ -0,0 +1,17 @@ +class Kiosk::DisciplinesController < Kiosk::BaseController + def index + @disciplines = Discipline.kept.order(:name) + end + + def show + @discipline = Discipline.kept.find(params[:id]) + + @expected_members = Member.kept + .joins(subscriptions: { product: :disciplines }) + .where(disciplines: { id: @discipline.id }) + .where("subscriptions.start_date <= :today AND subscriptions.end_date >= :today", today: Date.current) + .where(subscriptions: { discarded_at: nil }) + .distinct + .order(:first_name, :last_name) + end +end diff --git a/app/models/access_log.rb b/app/models/access_log.rb index fbe35b4..97062e9 100644 --- a/app/models/access_log.rb +++ b/app/models/access_log.rb @@ -6,35 +6,26 @@ class AccessLog < ApplicationRecord belongs_to :checkin_by_user, class_name: "User" belongs_to :discipline, optional: true + enum :status, { ok: 0, warning: 1, error: 2 }, default: :ok, validate: true + before_validation :set_defaults - validates :member, :subscription, :checkin_by_user, presence: true - validates :entered_at, presence: true + validates :member, :checkin_by_user, :entered_at, presence: true validate :subscription_belongs_to_member - validate :subscription_must_be_active, on: :create + validate :subscription_must_be_active, on: :create, if: -> { status == "ok" } - private + scope :valid_entries, -> { where(status: :ok) } + private def set_defaults self.entered_at ||= Time.current end def subscription_belongs_to_member return unless subscription && member - if subscription.member_id != member_id - errors.add(:subscription, "does not belong to this member") - end - end - - def subscription_must_be_active - return unless subscription - - check_date = entered_at&.to_date || Date.current - - unless subscription.active?(check_date) - errors.add(:subscription, "is not active for date #{check_date}") + errors.add(:subscription, "non appartiene a questo socio") end end end diff --git a/app/models/access_policy.rb b/app/models/access_policy.rb new file mode 100644 index 0000000..de8a515 --- /dev/null +++ b/app/models/access_policy.rb @@ -0,0 +1,91 @@ +# app/models/access_policy.rb +class AccessPolicy + include ActiveModel::Model + + attr_accessor :member, :discipline + attr_reader :subscription, :warnings + + validate :check_medical_certificate + validate :check_membership + validate :check_subscription + validate :check_entry_limits + + def initialize(attributes = {}) + super + @warnings = [] + @subscription = member.valid_subscription_for(discipline) + end + + def evaluate! + valid? + evaluate_warnings if errors.empty? + self + end + + def granted? + errors.empty? + end + + def status + return :error if errors.any? + return :warning if warnings.any? + :ok + end + + private + def check_medical_certificate + if discipline.requires_medical_certificate? && !member.medical_certificate_valid? + errors.add(:base, "Certificato Medico scaduto o mancante.") + end + end + + def check_membership + if discipline.requires_membership? && !member.membership_valid? + errors.add(:base, "Quota Associativa scaduta o mancante.") + end + end + + def check_subscription + unless subscription + errors.add(:base, "Nessun abbonamento attivo per '#{discipline.name}'.") + end + end + + def check_entry_limits + return unless subscription && entry_limit_applies? + + # Nota: qui diamo per scontato che tu abbia access_logs_count o un metodo simile in Subscription + used_entries = subscription.access_logs.valid_entries.count + if used_entries >= subscription.entry_limit + errors.add(:base, "Ingressi esauriti (#{used_entries}/#{subscription.entry_limit}).") + end + end + + def evaluate_warnings + if subscription_expiring_soon? + days_left = (subscription.end_date - Date.current).to_i + @warnings << "Abbonamento in scadenza tra #{days_left} giorni." + end + + if entry_limit_applies? + used = subscription.access_logs.valid_entries.count + remaining = subscription.entry_limit - used + if remaining <= 2 + @warnings << "Rimangono solo #{remaining} ingressi." + end + end + end + + # --- Metodi Helper Interni --- + + def entry_limit_applies? + # Dal tuo schema DB, l'entry_limit potrebbe essere su Subscription o Product + subscription.entry_limit.present? && subscription.entry_limit > 0 + end + + def subscription_expiring_soon? + return false unless subscription.end_date + # Consideriamo "in scadenza" se mancano meno di 7 giorni + subscription.end_date <= 7.days.from_now.to_date + end +end diff --git a/app/models/concerns/subscription_issuer.rb b/app/models/concerns/subscription_issuer.rb index a704044..7378a1e 100644 --- a/app/models/concerns/subscription_issuer.rb +++ b/app/models/concerns/subscription_issuer.rb @@ -2,10 +2,10 @@ module SubscriptionIssuer extend ActiveSupport::Concern included do - has_one :subscription, dependent: :destroy, inverse_of: :sale - accepts_nested_attributes_for :subscription, allow_destroy: true + belongs_to :subscription, optional: true, autosave: true + accepts_nested_attributes_for :subscription, reject_if: :all_blank - after_discard :discard_subscription + after_discard :discard_subscription_if_empty after_undiscard :undiscard_subscription validate :require_active_membership_for_courses, on: :create @@ -13,8 +13,12 @@ module SubscriptionIssuer end private - def discard_subscription - subscription.discard! if subscription.present? && !subscription.discarded? + def discard_subscription_if_empty + return unless subscription.present? + + if subscription.sales.kept.where.not(id: id).empty? + subscription.discard! unless subscription.discarded? + end end def undiscard_subscription @@ -31,12 +35,12 @@ def require_active_membership_for_courses end def prevent_overlapping_subscriptions - return unless member && product && subscription + return unless member && product && subscription && subscription.new_record? return unless subscription.start_date && subscription.end_date overlapping = member.subscriptions.kept - .where(product_id: product.id) - .where("start_date <= ? AND end_date >= ?", subscription.end_date, subscription.start_date) + .where(product_id: product.id) + .where("start_date <= ? AND end_date >= ?", subscription.end_date, subscription.start_date) if overlapping.exists? errors.add(:base, "Attenzione: Il socio ha già un abbonamento per '#{product.name}' che si sovrappone a queste date (dal #{I18n.l(subscription.start_date)} al #{I18n.l(subscription.end_date)}).") diff --git a/app/models/daily_cash.rb b/app/models/daily_cash.rb index f7fdf57..a98ce84 100644 --- a/app/models/daily_cash.rb +++ b/app/models/daily_cash.rb @@ -52,21 +52,11 @@ def afternoon_sales # --- LOGICA DI AGGREGAZIONE --- def morning_cents - # Se abbiamo i dati in memoria, usiamo Ruby (SUM veloce su Array) - if @preloaded_sales - @morning_cents ||= @preloaded_sales.select { |s| s.created_at < split_time }.sum(&:amount_cents) - else - # Altrimenti facciamo la query SQL - @morning_cents ||= base_scope.where("created_at < ?", split_time).sum(:amount_cents) - end + @morning_cents ||= base_scope.to_a.select { |s| s.created_at.in_time_zone.hour < SPLIT_HOUR }.sum(&:amount_cents) end def afternoon_cents - if @preloaded_sales - @afternoon_cents ||= @preloaded_sales.select { |s| s.created_at >= split_time }.sum(&:amount_cents) - else - @afternoon_cents ||= base_scope.where("created_at >= ?", split_time).sum(:amount_cents) - end + @afternoon_cents ||= base_scope.to_a.select { |s| s.created_at.in_time_zone.hour >= SPLIT_HOUR }.sum(&:amount_cents) end def total_cents diff --git a/app/models/member.rb b/app/models/member.rb index b5424a3..a25aac6 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -30,14 +30,18 @@ def compliant?(date = Date.current) end def membership_valid?(date = Date.current) - expiry = subscriptions - .reject(&:discarded?) - .select { |s| s.product&.associative? } - .map(&:end_date) - .compact - .max - - expiry.present? && expiry >= date + if memberships.loaded? + expiry = memberships.reject(&:discarded?).filter_map(&:end_date).max + expiry.present? && expiry >= date + else + memberships.kept.where("end_date >= ?", date).exists? + end + end + + def valid_subscription_for(discipline) + active_subscriptions + .joins(product: :disciplines) + .find_by(disciplines: { id: discipline.id }) end def relevant_subscriptions(date = Date.current) diff --git a/app/models/product.rb b/app/models/product.rb index 97abc9b..970947b 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -6,6 +6,7 @@ class Product < ApplicationRecord has_many :product_disciplines, dependent: :destroy has_many :disciplines, through: :product_disciplines has_many :sales, dependent: :restrict_with_error + has_many :subscriptions, dependent: :restrict_with_error enum :accounting_category, { institutional: "institutional", @@ -17,6 +18,7 @@ class Product < ApplicationRecord validates :name, presence: true, uniqueness: { conditions: -> { kept } } validates :duration_days, numericality: { greater_than: 0, only_integer: true } validates :price_cents, numericality: { greater_than_or_equal_to: 0, only_integer: true } + validates :entry_limit, numericality: { greater_than: 0, only_integer: true, allow_nil: true } def membership? associative? @@ -25,4 +27,8 @@ def membership? def course? institutional? end + + def carnet_or_pt? + entry_limit.present? + end end diff --git a/app/models/receipt_counter.rb b/app/models/receipt_counter.rb index 46c6474..db91817 100644 --- a/app/models/receipt_counter.rb +++ b/app/models/receipt_counter.rb @@ -3,10 +3,11 @@ class ReceiptCounter < ApplicationRecord def self.next_number(year, category) transaction do - counter = lock.find_or_create_by!(year: year, sequence_category: category) - counter.increment!(:last_number) + counter = create_or_find_by!(year: year, sequence_category: category) - counter.last_number + ReceiptCounter.update_counters(counter.id, last_number: 1) + + counter.reload.last_number end end end diff --git a/app/models/sale.rb b/app/models/sale.rb index 569ad0e..c8cc60c 100644 --- a/app/models/sale.rb +++ b/app/models/sale.rb @@ -8,8 +8,6 @@ class Sale < ApplicationRecord belongs_to :user belongs_to :product - has_many :subscriptions - enum :payment_method, { cash: 1, credit_card: 2, bank_transfer: 3, other: 4 }, default: :credit_card, validate: true @@ -27,20 +25,34 @@ def prepare_draft(options = {}) self.sold_on ||= Date.current self.member_id ||= options[:preset_member_id] - build_subscription unless subscription + if options[:installment_for_subscription_id].present? + self.subscription = Subscription.find_by(id: options[:installment_for_subscription_id]) - if options[:autosubmit] - reset_draft_if_changed(options[:previous_product_id], options[:previous_member_id]) - elsif options[:renew_subscription_id].present? - apply_renewal_template(options[:renew_subscription_id]) - end + if self.subscription.present? + self.member_id ||= self.subscription.member_id + self.product_id ||= self.subscription.product_id - if product_id.present? && (amount.blank? || amount.zero?) - self.amount = product.price - end + if amount.blank? || amount.zero? + missing_cents = product.price_cents - self.subscription.amount_paid + self.amount_cents = [ missing_cents, 0 ].max + end + end + else + build_subscription unless subscription - sync_subscription_data - subscription.assign_smart_dates(manual_start_date: options[:manual_start_date]) + if options[:autosubmit] + reset_draft_if_changed(options[:previous_product_id], options[:previous_member_id]) + elsif options[:renew_subscription_id].present? + apply_renewal_template(options[:renew_subscription_id]) + end + + if product_id.present? && (amount.blank? || amount.zero?) + self.amount = product.price + end + + sync_subscription_data + subscription.assign_smart_dates(manual_start_date: options[:manual_start_date]) if subscription.new_record? + end end private @@ -62,7 +74,7 @@ def apply_renewal_template(renew_id) end def sync_subscription_data - return unless subscription.present? && member.present? && product.present? + return unless subscription.present? && subscription.new_record? && member.present? && product.present? subscription.member ||= self.member subscription.product ||= self.product end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 31e097d..4db8f2d 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -2,10 +2,10 @@ class Subscription < ApplicationRecord include SoftDeletable, DateRangeable belongs_to :member, touch: true - belongs_to :sale, inverse_of: :subscription, touch: true belongs_to :product - validates :member, :product, :sale, presence: true + has_many :sales, inverse_of: :subscription, dependent: :nullify + has_many :access_logs, dependent: :nullify before_validation :apply_business_rules, on: :create @@ -13,6 +13,10 @@ def days_left (end_date - Date.current).to_i end + def active?(date = Date.current) + start_date <= date && end_date >= date + end + def future? start_date > Date.current end @@ -34,14 +38,28 @@ def assign_smart_dates(manual_start_date: nil) apply_business_rules end + def amount_paid + sales.reject(&:discarded?).sum(&:amount_cents) + end + + def fully_paid? + amount_paid >= product.price_cents + end + + def unlimited_entries? + entry_limit.nil? || entry_limit.zero? + end + private def apply_business_rules return unless product.present? && member.present? + self.entry_limit ||= product.respond_to?(:entry_limit) ? product.entry_limit : nil + return if end_date.present? if start_date.blank? - reference_date = sale&.sold_on || Date.current + reference_date = sales.first&.sold_on || Date.current self.start_date = RenewalCalculator.new(member, product, reference_date).call end diff --git a/app/views/kiosk/disciplines/index.html.erb b/app/views/kiosk/disciplines/index.html.erb new file mode 100644 index 0000000..a4e7ded --- /dev/null +++ b/app/views/kiosk/disciplines/index.html.erb @@ -0,0 +1,29 @@ +
      + +
      +

      Seleziona il Corso

      +

      Tocca il corso che stai per iniziare per fare l'appello.

      +
      + +
      + <% @disciplines.each do |discipline| %> + <%= link_to kiosk_discipline_path(discipline), + class: "card bg-base-100 hover:bg-primary hover:text-primary-content transition-all duration-200 shadow-sm hover:shadow-xl hover:-translate-y-1 group border border-base-200" do %> + +
      + <%# Cambia l'icona in base al nome o mettine una generica %> +
      + <%= icon("sports_gymnastics", classes: "size-16") %> +
      + +

      <%= discipline.name %>

      + +
      + Inizia Appello <%= icon("arrow_forward") %> +
      +
      + <% end %> + <% end %> +
      + +
      diff --git a/app/views/kiosk/disciplines/show.html.erb b/app/views/kiosk/disciplines/show.html.erb new file mode 100644 index 0000000..56addd5 --- /dev/null +++ b/app/views/kiosk/disciplines/show.html.erb @@ -0,0 +1,54 @@ +<%# app/views/kiosk/disciplines/show.html.erb %> + +
      + <%= link_to kiosk_root_path, class: "btn btn-circle btn-ghost bg-base-100 text-base-content/50" do %> + <%= icon("arrow_back", classes: "size-6") %> + <% end %> +

      <%= @discipline.name %>

      +
      + +
      + + <%# COLONNA SINISTRA: La Ricerca Veloce (Autocomplete) %> +
      +
      +
      +

      Ricerca Rapida

      +

      Cerca un socio non in lista (es. per recuperi o PT).

      + + <%# Usiamo il tuo controller autocomplete esistente! %> +
      + + + +
      +
      +
      +
      + + <%# COLONNA DESTRA: I Soci Attesi %> +
      +

      Soci Attesi Oggi

      + +
      + <% @expected_members.each do |member| %> + <%= render "kiosk/members/card", member: member, discipline: @discipline %> + <% end %> + + <% if @expected_members.empty? %> +
      + Nessun socio previsto in lista per oggi. +
      + <% end %> +
      +
      + +
      diff --git a/app/views/kiosk/members/_card.html.erb b/app/views/kiosk/members/_card.html.erb new file mode 100644 index 0000000..79d2caa --- /dev/null +++ b/app/views/kiosk/members/_card.html.erb @@ -0,0 +1,26 @@ +<%# app/views/kiosk/members/_card.html.erb %> +
      +
      + +
      + <%= ui_avatar(member, size: "size-12", text_size: "text-md") %> +
      +

      <%= member.full_name %>

      + <% if !member.medical_certificate_valid? %> + + <%= icon("warning", classes: "size-3") %> Cert. Scaduto + + <% end %> +
      +
      + + <%# IL BOTTONE DI CHECK-IN (Invia la POST ad AccessLogsController) %> + <%= button_to kiosk_discipline_access_logs_path(discipline), + params: { member_id: member.id }, + class: "btn btn-circle btn-primary btn-lg shadow-md", + data: { turbo: true } do %> + <%= icon("check", classes: "size-8") %> + <% end %> + +
      +
      diff --git a/app/views/layouts/kiosk.html.erb b/app/views/layouts/kiosk.html.erb new file mode 100644 index 0000000..5a2a5dd --- /dev/null +++ b/app/views/layouts/kiosk.html.erb @@ -0,0 +1,57 @@ + + + + Kiosk - <%= content_for(:title) || "Active Core" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + + + + + <%= turbo_refreshes_with method: :morph, scroll: :preserve %> + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + + <%# HEADER MINIMALE KIOSK %> +
      +
      + <%= icon("fitness_center", classes: "size-10 text-primary") %> +
      +

      Kiosk Accessi

      +

      <%= GymProfile.current&.name || "La Tua Palestra" %>

      +
      +
      + +
      + <%# Pulsante GIGANTE per uscire (lontano da click accidentali) %> + <%= link_to root_path, class: "btn btn-ghost btn-circle btn-lg text-base-content/50 hover:text-error hover:bg-error/10", title: "Esci dal Kiosk" do %> + <%= icon("logout", classes: "size-8") %> + <% end %> +
      +
      + + <%# CONTENITORE PRINCIPALE %> +
      + <%= yield %> +
      + + <%# FLASH MESSAGES FLOTTANTI %> +
      + <%= render "shared/flash" %> +
      + + <%= turbo_frame_tag "modal" %> + + diff --git a/config/routes.rb b/config/routes.rb index 04c9a66..cde9ff4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -60,4 +60,13 @@ # ============================================================================ get "up" => "rails/health#show", as: :rails_health_check root "dashboard#index" + + # --- KIOSK MODE (iPad Appello) --- + namespace :kiosk do + root to: "disciplines#index" + + resources :disciplines, only: [ :index, :show ] do + resources :access_logs, only: [ :create ] + end + end end diff --git a/db/migrate/20260401155446_add_entry_limit_to_products_and_subscriptions.rb b/db/migrate/20260401155446_add_entry_limit_to_products_and_subscriptions.rb new file mode 100644 index 0000000..2c2efc1 --- /dev/null +++ b/db/migrate/20260401155446_add_entry_limit_to_products_and_subscriptions.rb @@ -0,0 +1,6 @@ +class AddEntryLimitToProductsAndSubscriptions < ActiveRecord::Migration[8.1] + def change + add_column :products, :entry_limit, :integer, null: true + add_column :subscriptions, :entry_limit, :integer, null: true + end +end diff --git a/db/migrate/20260401155455_invert_subscription_sale_relationship.rb b/db/migrate/20260401155455_invert_subscription_sale_relationship.rb new file mode 100644 index 0000000..3e76159 --- /dev/null +++ b/db/migrate/20260401155455_invert_subscription_sale_relationship.rb @@ -0,0 +1,19 @@ +class InvertSubscriptionSaleRelationship < ActiveRecord::Migration[8.1] + def up + add_reference :sales, :subscription, foreign_key: true, null: true + + Subscription.find_each do |sub| + Sale.where(id: sub.sale_id).update_all(subscription_id: sub.id) + end + + remove_reference :subscriptions, :sale, foreign_key: true + end + + def down + add_reference :subscriptions, :sale, foreign_key: true, null: true + Sale.where.not(subscription_id: nil).find_each do |sale| + Subscription.where(id: sale.subscription_id).update_all(sale_id: sale.id) + end + remove_reference :sales, :subscription, foreign_key: true + end +end diff --git a/db/structure.sql b/db/structure.sql index a0208fb..e6a6b38 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS "disciplines" ("id" integer PRIMARY KEY AUTOINCREMENT CREATE INDEX "index_disciplines_on_discarded_at" ON "disciplines" ("discarded_at"); CREATE UNIQUE INDEX "index_disciplines_on_name" ON "disciplines" ("name") WHERE discarded_at IS NULL; CREATE TABLE IF NOT EXISTS "gym_profiles" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "address_line_1" varchar, "address_line_2" varchar, "bank_iban" varchar, "city" varchar, "created_at" datetime(6) NOT NULL, "email" varchar, "name" varchar, "phone" varchar, "updated_at" datetime(6) NOT NULL, "vat_number" varchar, "zip_code" varchar); -CREATE TABLE IF NOT EXISTS "products" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "accounting_category" varchar DEFAULT 'institutional' NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "duration_days" integer NOT NULL, "name" varchar NOT NULL, "price_cents" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL); +CREATE TABLE IF NOT EXISTS "products" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "accounting_category" varchar DEFAULT 'institutional' NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "duration_days" integer NOT NULL, "name" varchar NOT NULL, "price_cents" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, "entry_limit" integer /*application='ActiveCore'*/); CREATE INDEX "index_products_on_discarded_at" ON "products" ("discarded_at"); CREATE UNIQUE INDEX "index_products_on_name" ON "products" ("name") WHERE discarded_at IS NULL; CREATE TABLE IF NOT EXISTS "receipt_counters" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "last_number" integer DEFAULT 0 NOT NULL, "sequence_category" varchar NOT NULL, "updated_at" datetime(6) NOT NULL, "year" integer NOT NULL); @@ -30,43 +30,11 @@ FOREIGN KEY ("product_id") CREATE INDEX "index_product_disciplines_on_discipline_id" ON "product_disciplines" ("discipline_id"); CREATE UNIQUE INDEX "index_product_disciplines_on_product_id_and_discipline_id" ON "product_disciplines" ("product_id", "discipline_id"); CREATE INDEX "index_product_disciplines_on_product_id" ON "product_disciplines" ("product_id"); -CREATE TABLE IF NOT EXISTS "sales" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "amount_cents" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "member_id" integer NOT NULL, "notes" text, "payment_method" integer DEFAULT 0 NOT NULL, "product_id" integer NOT NULL, "product_name_snapshot" varchar NOT NULL, "receipt_code" varchar GENERATED ALWAYS AS (receipt_year || '-' || receipt_sequence || '-' || receipt_number) STORED, "receipt_number" integer, "receipt_sequence" varchar, "receipt_year" integer, "sold_on" date NOT NULL, "updated_at" datetime(6) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "fk_rails_afd82832c8" -FOREIGN KEY ("product_id") - REFERENCES "products" ("id") -, CONSTRAINT "fk_rails_935e249f94" -FOREIGN KEY ("member_id") - REFERENCES "members" ("id") -, CONSTRAINT "fk_rails_8e94f16ccc" -FOREIGN KEY ("user_id") - REFERENCES "users" ("id") -); -CREATE INDEX "index_sales_on_discarded_at" ON "sales" ("discarded_at"); -CREATE INDEX "index_sales_on_member_id" ON "sales" ("member_id"); -CREATE INDEX "index_sales_on_product_id" ON "sales" ("product_id"); -CREATE INDEX "index_sales_on_receipt_code" ON "sales" ("receipt_code"); -CREATE UNIQUE INDEX "idx_on_receipt_year_receipt_sequence_receipt_number_3689acdaf9" ON "sales" ("receipt_year", "receipt_sequence", "receipt_number") WHERE receipt_number IS NOT NULL; -CREATE INDEX "index_sales_on_sold_on" ON "sales" ("sold_on"); -CREATE INDEX "index_sales_on_user_id" ON "sales" ("user_id"); CREATE TABLE IF NOT EXISTS "sessions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "ip_address" varchar, "updated_at" datetime(6) NOT NULL, "user_agent" varchar, "user_id" integer NOT NULL, CONSTRAINT "fk_rails_758836b4f0" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ); CREATE INDEX "index_sessions_on_user_id" ON "sessions" ("user_id"); -CREATE TABLE IF NOT EXISTS "subscriptions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "end_date" date NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "sale_id" integer NOT NULL, "start_date" date NOT NULL, "suspension_days_count" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_52a3b81fce" -FOREIGN KEY ("product_id") - REFERENCES "products" ("id") -, CONSTRAINT "fk_rails_bfac3ecd2f" -FOREIGN KEY ("member_id") - REFERENCES "members" ("id") -, CONSTRAINT "fk_rails_bb36d9c2a0" -FOREIGN KEY ("sale_id") - REFERENCES "sales" ("id") -); -CREATE INDEX "index_subscriptions_on_discarded_at" ON "subscriptions" ("discarded_at"); -CREATE INDEX "index_subscriptions_on_member_id_and_end_date" ON "subscriptions" ("member_id", "end_date"); -CREATE INDEX "index_subscriptions_on_member_id" ON "subscriptions" ("member_id"); -CREATE INDEX "index_subscriptions_on_product_id" ON "subscriptions" ("product_id"); -CREATE INDEX "index_subscriptions_on_sale_id" ON "subscriptions" ("sale_id"); CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY); CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); CREATE TABLE IF NOT EXISTS "access_logs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "checkin_by_user_id" integer NOT NULL, "created_at" datetime(6) NOT NULL, "entered_at" datetime(6) NOT NULL, "medical_certificate_valid" boolean DEFAULT FALSE NOT NULL, "member_id" integer NOT NULL, "subscription_id" integer, "updated_at" datetime(6) NOT NULL, "discipline_id" integer, "status" integer DEFAULT 0 NOT NULL /*application='ActiveCore'*/, CONSTRAINT "fk_rails_df50081f1b" @@ -112,10 +80,6 @@ CREATE VIRTUAL TABLE members_fts USING fts5( content_rowid='id' ) /* members_fts(first_name,last_name,fiscal_code,email_address,phone,birth_date) */; -CREATE TABLE IF NOT EXISTS 'members_fts_data'(id INTEGER PRIMARY KEY, block BLOB); -CREATE TABLE IF NOT EXISTS 'members_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID; -CREATE TABLE IF NOT EXISTS 'members_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB); -CREATE TABLE IF NOT EXISTS 'members_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID; CREATE TRIGGER members_ai AFTER INSERT ON members BEGIN INSERT INTO members_fts(rowid, first_name, last_name, fiscal_code, email_address, phone, birth_date) VALUES (new.id, new.first_name, new.last_name, new.fiscal_code, new.email_address, new.phone, new.birth_date); @@ -131,7 +95,41 @@ CREATE TRIGGER members_au AFTER UPDATE ON members BEGIN INSERT INTO members_fts(rowid, first_name, last_name, fiscal_code, email_address, phone, birth_date) VALUES (new.id, new.first_name, new.last_name, new.fiscal_code, new.email_address, new.phone, new.birth_date); END; +CREATE TABLE IF NOT EXISTS "sales" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "amount_cents" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "member_id" integer NOT NULL, "notes" text, "payment_method" integer DEFAULT 0 NOT NULL, "product_id" integer NOT NULL, "product_name_snapshot" varchar NOT NULL, "receipt_code" varchar GENERATED ALWAYS AS (receipt_year || '-' || receipt_sequence || '-' || receipt_number) STORED, "receipt_number" integer, "receipt_sequence" varchar, "receipt_year" integer, "sold_on" date NOT NULL, "updated_at" datetime(6) NOT NULL, "user_id" integer NOT NULL, "subscription_id" integer, CONSTRAINT "fk_rails_8e94f16ccc" +FOREIGN KEY ("user_id") + REFERENCES "users" ("id") +, CONSTRAINT "fk_rails_935e249f94" +FOREIGN KEY ("member_id") + REFERENCES "members" ("id") +, CONSTRAINT "fk_rails_afd82832c8" +FOREIGN KEY ("product_id") + REFERENCES "products" ("id") +, CONSTRAINT "fk_rails_26eed2fa3b" +FOREIGN KEY ("subscription_id") + REFERENCES "subscriptions" ("id") +); +CREATE INDEX "index_sales_on_discarded_at" ON "sales" ("discarded_at") /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_member_id" ON "sales" ("member_id") /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_product_id" ON "sales" ("product_id") /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_receipt_code" ON "sales" ("receipt_code") /*application='ActiveCore'*/; +CREATE UNIQUE INDEX "idx_on_receipt_year_receipt_sequence_receipt_number_3689acdaf9" ON "sales" ("receipt_year", "receipt_sequence", "receipt_number") WHERE receipt_number IS NOT NULL /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_sold_on" ON "sales" ("sold_on") /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_user_id" ON "sales" ("user_id") /*application='ActiveCore'*/; +CREATE INDEX "index_sales_on_subscription_id" ON "sales" ("subscription_id") /*application='ActiveCore'*/; +CREATE TABLE IF NOT EXISTS "subscriptions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "end_date" date NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "start_date" date NOT NULL, "suspension_days_count" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, "entry_limit" integer, CONSTRAINT "fk_rails_52a3b81fce" +FOREIGN KEY ("product_id") + REFERENCES "products" ("id") +, CONSTRAINT "fk_rails_bfac3ecd2f" +FOREIGN KEY ("member_id") + REFERENCES "members" ("id") +); +CREATE INDEX "index_subscriptions_on_discarded_at" ON "subscriptions" ("discarded_at") /*application='ActiveCore'*/; +CREATE INDEX "index_subscriptions_on_member_id_and_end_date" ON "subscriptions" ("member_id", "end_date") /*application='ActiveCore'*/; +CREATE INDEX "index_subscriptions_on_member_id" ON "subscriptions" ("member_id") /*application='ActiveCore'*/; +CREATE INDEX "index_subscriptions_on_product_id" ON "subscriptions" ("product_id") /*application='ActiveCore'*/; INSERT INTO "schema_migrations" (version) VALUES +('20260401155455'), +('20260401155446'), ('20260323183701'), ('20260323182713'), ('20260323182158'), From f26b190cf1d72f28d837ba8164696ce09f942da9 Mon Sep 17 00:00:00 2001 From: jcostd Date: Wed, 1 Apr 2026 20:14:11 +0200 Subject: [PATCH 16/34] Updated Controllers --- app/controllers/concerns/authentication.rb | 15 +-------------- app/controllers/dashboard_controller.rb | 3 ++- app/controllers/members/searches_controller.rb | 2 -- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 2154b17..890c69c 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -33,20 +33,7 @@ def resume_session end def find_session_by_cookie - session = Session.find_by(id: cookies.signed[:session_id]) - return nil unless session - - if session.user.discarded? - session.destroy - return nil - end - - if session.updated_at < 30.days.ago - session.destroy - return nil - end - - session + Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] end def current_user diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 25dfde4..99defe2 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -9,7 +9,8 @@ def index .where(end_date: Date.current..7.days.from_now) .order(end_date: :asc) .limit(5) - @expiring_count = @expiring_subscriptions.except(:limit).count + + @expiring_count = Subscription.kept.where(end_date: Date.current..7.days.from_now).count @recent_accesses = AccessLog.includes(:member, :discipline) .order(entered_at: :desc) diff --git a/app/controllers/members/searches_controller.rb b/app/controllers/members/searches_controller.rb index 1e80e19..89894a7 100644 --- a/app/controllers/members/searches_controller.rb +++ b/app/controllers/members/searches_controller.rb @@ -3,7 +3,5 @@ class Members::SearchesController < ApplicationController def index @members = params[:query].present? ? Member.search_text(params[:query]).limit(10) : Member.none - - render layout: false end end From b95536d36721cf10ed9d27d48b2da1822435dee2 Mon Sep 17 00:00:00 2001 From: jcostd Date: Sat, 11 Apr 2026 00:50:48 +0200 Subject: [PATCH 17/34] Updated UI and refactored Pos Draft Builder --- Gemfile.lock | 38 +++--- app/controllers/sales_controller.rb | 30 +++-- app/models/concerns/date_rangeable.rb | 25 +++- app/models/pos_draft_builder.rb | 107 +++++++++++++++ app/models/sale.rb | 51 -------- app/models/subscription.rb | 37 ++---- app/views/dashboard/index.html.erb | 2 +- app/views/members/searches/index.html.erb | 3 +- app/views/sales/_form.html.erb | 122 ++---------------- .../sales/form_sections/_customer.html.erb | 24 ++++ .../sales/form_sections/_product.html.erb | 49 +++++++ .../form_sections/_subscription.html.erb | 59 +++++++++ .../sales/form_sections/_summary.html.erb | 49 +++++++ ...81021_add_agreed_price_to_subscriptions.rb | 5 + db/structure.sql | 3 +- 15 files changed, 377 insertions(+), 227 deletions(-) create mode 100644 app/models/pos_draft_builder.rb create mode 100644 app/views/sales/form_sections/_customer.html.erb create mode 100644 app/views/sales/form_sections/_product.html.erb create mode 100644 app/views/sales/form_sections/_subscription.html.erb create mode 100644 app/views/sales/form_sections/_summary.html.erb create mode 100644 db/migrate/20260410181021_add_agreed_price_to_subscriptions.rb diff --git a/Gemfile.lock b/Gemfile.lock index 147d630..9ee4769 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,7 +75,7 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.9) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) @@ -207,16 +207,16 @@ GEM nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) ostruct (0.6.3) - pagy (43.4.4) + pagy (43.5.0) json uri yaml - parallel (1.27.0) + parallel (2.0.1) parser (3.3.11.1) ast (~> 2.4.1) racc pdf-core (0.10.0) - phonelib (0.10.17) + phonelib (0.10.18) pp (0.6.3) prettyprint prawn (2.5.0) @@ -235,12 +235,12 @@ GEM date stringio public_suffix (7.0.5) - puma (7.2.0) + puma (8.0.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) rack (3.2.6) - rack-session (2.1.1) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -286,15 +286,15 @@ GEM erb psych (>= 4.0.0) tsort - regexp_parser (2.11.3) + regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) rexml (3.4.4) - rubocop (1.86.0) + rubocop (1.86.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) - parallel (~> 1.10) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) @@ -324,7 +324,7 @@ GEM logger rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.41.0) + selenium-webdriver (4.43.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -461,7 +461,7 @@ CHECKSUMS activerecord (8.1.3) sha256=8003be7b2466ba0a2a670e603eeb0a61dd66058fccecfc49901e775260ac70ab activestorage (8.1.3) sha256=0564ce9309143951a67615e1bb4e090ee54b8befed417133cae614479b46384d activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e - addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 @@ -528,11 +528,11 @@ CHECKSUMS nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8 ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 - pagy (43.4.4) sha256=b41a57328a0aabfd222266a89e9de3dc3a735c17bd57f8113829c95fece5bef6 - parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + pagy (43.5.0) sha256=58885d5f659e8db5b92cf35eeba674113e4e7bda12649b603c2d6908402570a4 + parallel (2.0.1) sha256=337782d3e39f4121e67563bf91dd8ece67f48923d90698614773a0ec9a5b2c7d parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 pdf-core (0.10.0) sha256=0a5d101e2063c01e3f941e1ee47cbb97f1adfc1395b58372f4f65f1300f3ce91 - phonelib (0.10.17) sha256=8c97b6abc1877a8313ef32f9438cd35e24a943f9a70666af85eb69ab81caf4e3 + phonelib (0.10.18) sha256=2096b127bfbd4fef58eddb4dc916bb285495855b6673648719dff76cd8c32007 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prawn (2.5.0) sha256=f4e20e3b4f30bf5b9ae37dad15eb421831594553aa930b2391b0fa0a99c43cb6 prawn-table (0.2.2) sha256=336d46e39e003f77bf973337a958af6a68300b941c85cb22288872dc2b36addb @@ -541,11 +541,11 @@ CHECKSUMS propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392 psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 - puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8 + puma (8.0.0) sha256=1681050b8b60fab1d3033255ab58b6aec64cd063e43fc6f8204bcb8bf9364b88 raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 - rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3 @@ -556,10 +556,10 @@ CHECKSUMS rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 - regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 - rubocop (1.86.0) sha256=4ff1186fe16ebe9baff5e7aad66bb0ad4cabf5cdcd419f773146dbba2565d186 + rubocop (1.86.1) sha256=44415f3f01d01a21e01132248d2fd0867572475b566ca188a0a42133a08d4531 rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2 @@ -568,7 +568,7 @@ CHECKSUMS ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - selenium-webdriver (4.41.0) sha256=cdc1173cd55cf186022cea83156cc2d0bec06d337e039b02ad25d94e41bedd22 + selenium-webdriver (4.43.0) sha256=a634377b964b701c6ac0a009ce3a08fa34ec1e1e7fe9a6d57e3088d14529a65c solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a diff --git a/app/controllers/sales_controller.rb b/app/controllers/sales_controller.rb index 25456a5..5b34ccf 100644 --- a/app/controllers/sales_controller.rb +++ b/app/controllers/sales_controller.rb @@ -29,15 +29,10 @@ def show end def new - @sale = Sale.new(sale_params_for_build) - - @sale.prepare_draft( - autosubmit: params.has_key?(:sale), - preset_member_id: params[:member_id], - renew_subscription_id: params[:renew_subscription_id], - previous_product_id: params[:previous_product_id], - previous_member_id: params[:previous_member_id] - ) + @sale = PosDraftBuilder.new( + sale_params: sale_params_for_build, + context_params: params + ).build end def create @@ -47,9 +42,11 @@ def create if @sale.save redirect_to sale_path(@sale), notice: t(".created", default: "Vendita registrata con successo.") else - @sale.prepare_draft( - manual_start_date: params.dig(:sale, :subscription_attributes, :start_date) - ) + @sale = PosDraftBuilder.new( + sale_params: sale_params, + context_params: params, + existing_sale: @sale + ).build respond_to do |format| format.turbo_stream do @@ -82,9 +79,16 @@ def sale_params_for_build end def sale_params + permitted_sub_attrs = [ :start_date, :agreed_price ] + + if current_user.respond_to?(:admin?) && current_user.admin? + permitted_sub_attrs << :end_date + end + params.require(:sale).permit( :member_id, :product_id, :amount, :payment_method, - :sold_on, :notes, subscription_attributes: [ :start_date ] + :sold_on, :notes, :subscription_id, + subscription_attributes: permitted_sub_attrs ) end diff --git a/app/models/concerns/date_rangeable.rb b/app/models/concerns/date_rangeable.rb index 2406e17..ee6d02f 100644 --- a/app/models/concerns/date_rangeable.rb +++ b/app/models/concerns/date_rangeable.rb @@ -16,11 +16,26 @@ def active?(date = Date.current) date.between?(start_date, end_date) end - private + def future? + start_date > Date.current + end - def end_date_after_start_date - if start_date && end_date && end_date < start_date - errors.add(:end_date, "must be after or equal to start date") - end + def expired?(date = Date.current) + end_date < date + end + + def days_left + (end_date - Date.current).to_i end + + def expiring_soon? + !future? && days_left.between?(0, 7) + end + + private + def end_date_after_start_date + if start_date && end_date && end_date < start_date + errors.add(:end_date, "deve essere successiva o uguale alla data di inizio") + end + end end diff --git a/app/models/pos_draft_builder.rb b/app/models/pos_draft_builder.rb new file mode 100644 index 0000000..52b1789 --- /dev/null +++ b/app/models/pos_draft_builder.rb @@ -0,0 +1,107 @@ +class PosDraftBuilder + attr_reader :context_params, :sale + + def initialize(sale_params:, context_params:, existing_sale: nil) + @context_params = context_params + + @sale = existing_sale || Sale.new(sale_params) + @sale.build_subscription unless @sale.subscription.present? + end + + def build + setup_base_defaults + + if context_params[:installment_for_subscription_id].present? + apply_installment_logic + else + handle_new_subscription_flow + end + + sync_nested_data + sale + end + + private + def setup_base_defaults + sale.sold_on ||= Date.current + sale.member_id ||= context_params[:preset_member_id] || context_params[:member_id] + end + + def apply_installment_logic + sub = Subscription.find_by(id: context_params[:installment_for_subscription_id]) + return unless sub + + sale.subscription = sub + sale.member_id ||= sub.member_id + sale.product_id ||= sub.product_id + + if sale.amount.blank? || sale.amount.zero? + missing_cents = sub.agreed_price_cents - sub.amount_paid + sale.amount_cents = [ missing_cents, 0 ].max + end + end + + def handle_new_subscription_flow + if autosubmit? + reset_draft_if_changed + elsif context_params[:renew_subscription_id].present? + apply_renewal_template + end + + apply_default_price + end + + def apply_default_price + if sale.product_id.present? + sale.subscription.agreed_price ||= sale.product.price + + if sale.amount.blank? || sale.amount.zero? + sale.amount = sale.subscription.agreed_price + end + end + end + + def sync_nested_data + return unless sale.subscription.new_record? && sale.member.present? && sale.product.present? + + sale.subscription.member ||= sale.member + sale.subscription.product ||= sale.product + + manual_start = context_params.dig(:sale, :subscription_attributes, :start_date) + + if context_params[:override_end_date] == "1" + manual_end = context_params.dig(:sale, :subscription_attributes, :end_date) + sale.subscription.end_date = manual_end if manual_end.present? + else + sale.subscription.end_date = nil + end + + sale.subscription.assign_smart_dates(manual_start_date: manual_start) + end + + def reset_draft_if_changed + prev_product = context_params[:previous_product_id] + prev_member = context_params[:previous_member_id] + + if prev_product.to_s != sale.product_id.to_s || prev_member.to_s != sale.member_id.to_s + sale.amount = nil + sale.subscription.start_date = nil + sale.subscription.end_date = nil + sale.subscription.agreed_price = nil + end + end + + def apply_renewal_template + old_sub = Subscription.find_by(id: context_params[:renew_subscription_id]) + return unless old_sub + + sale.product_id ||= old_sub.product_id + sale.member_id ||= old_sub.member_id + + sale.subscription.start_date = old_sub.end_date >= Date.current ? (old_sub.end_date + 1.day) : Date.current + end + + def autosubmit? + context_params.has_key?(:sale) + end +end diff --git a/app/models/sale.rb b/app/models/sale.rb index c8cc60c..4ecb0cd 100644 --- a/app/models/sale.rb +++ b/app/models/sale.rb @@ -21,58 +21,7 @@ class Sale < ApplicationRecord before_validation :sync_subscription_data before_validation :assign_receipt_number, on: :create - def prepare_draft(options = {}) - self.sold_on ||= Date.current - self.member_id ||= options[:preset_member_id] - - if options[:installment_for_subscription_id].present? - self.subscription = Subscription.find_by(id: options[:installment_for_subscription_id]) - - if self.subscription.present? - self.member_id ||= self.subscription.member_id - self.product_id ||= self.subscription.product_id - - if amount.blank? || amount.zero? - missing_cents = product.price_cents - self.subscription.amount_paid - self.amount_cents = [ missing_cents, 0 ].max - end - end - else - build_subscription unless subscription - - if options[:autosubmit] - reset_draft_if_changed(options[:previous_product_id], options[:previous_member_id]) - elsif options[:renew_subscription_id].present? - apply_renewal_template(options[:renew_subscription_id]) - end - - if product_id.present? && (amount.blank? || amount.zero?) - self.amount = product.price - end - - sync_subscription_data - subscription.assign_smart_dates(manual_start_date: options[:manual_start_date]) if subscription.new_record? - end - end - private - def reset_draft_if_changed(prev_product, prev_member) - if prev_product.to_s != product_id.to_s || prev_member.to_s != member_id.to_s - self.amount = nil - subscription.start_date = nil - end - end - - def apply_renewal_template(renew_id) - old_sub = Subscription.find_by(id: renew_id) - return unless old_sub - - self.product_id ||= old_sub.product_id - self.member_id ||= old_sub.member_id - - subscription.start_date = old_sub.end_date >= Date.current ? (old_sub.end_date + 1.day) : Date.current - end - def sync_subscription_data return unless subscription.present? && subscription.new_record? && member.present? && product.present? subscription.member ||= self.member diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 4db8f2d..5f78d58 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -1,5 +1,7 @@ class Subscription < ApplicationRecord - include SoftDeletable, DateRangeable + include SoftDeletable, DateRangeable, Monetizable + + monetize :agreed_price belongs_to :member, touch: true belongs_to :product @@ -7,32 +9,9 @@ class Subscription < ApplicationRecord has_many :sales, inverse_of: :subscription, dependent: :nullify has_many :access_logs, dependent: :nullify + before_validation :set_default_agreed_price, on: :create before_validation :apply_business_rules, on: :create - def days_left - (end_date - Date.current).to_i - end - - def active?(date = Date.current) - start_date <= date && end_date >= date - end - - def future? - start_date > Date.current - end - - def expired?(date = Date.current) - end_date < date - end - - def days_difference(date = Date.current) - (date - end_date).to_i.abs - end - - def expiring_soon? - !future? && days_left.between?(0, 7) - end - def assign_smart_dates(manual_start_date: nil) self.start_date = manual_start_date if manual_start_date.present? apply_business_rules @@ -43,7 +22,7 @@ def amount_paid end def fully_paid? - amount_paid >= product.price_cents + amount_paid >= agreed_price_cents end def unlimited_entries? @@ -51,6 +30,12 @@ def unlimited_entries? end private + def set_default_agreed_price + if (agreed_price_cents.nil? || agreed_price_cents.zero?) && product.present? + self.agreed_price_cents = product.price_cents + end + end + def apply_business_rules return unless product.present? && member.present? diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 5cc9947..dfc35e9 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -140,7 +140,7 @@ <% end %> <% else %>
      - <%= icon("done_all", classes: "size-10 opacity-20") %> + <%= icon("success", classes: "size-10 opacity-20") %> Nessuna urgenza.
      <% end %> diff --git a/app/views/members/searches/index.html.erb b/app/views/members/searches/index.html.erb index 102a8ac..4ed58eb 100644 --- a/app/views/members/searches/index.html.erb +++ b/app/views/members/searches/index.html.erb @@ -3,7 +3,8 @@
        <% @members.each do |member| %> -
      • diff --git a/app/views/sales/_form.html.erb b/app/views/sales/_form.html.erb index e44549d..d47237e 100644 --- a/app/views/sales/_form.html.erb +++ b/app/views/sales/_form.html.erb @@ -5,125 +5,27 @@ data: { controller: "autosubmit" }, class: "flex flex-col md:flex-row gap-8 lg:gap-12" do |f| %> + <% is_installment = sale.subscription&.persisted? %> + <%= hidden_field_tag :previous_member_id, sale.member_id %> <%= hidden_field_tag :previous_product_id, sale.product_id %> <%= hidden_field_tag :renew_subscription_id, params[:renew_subscription_id] %> -
        + <% if is_installment %> + <%= hidden_field_tag :installment_for_subscription_id, params[:installment_for_subscription_id] %> + <% end %> +
        <%= render "shared/form_errors", model: sale %> - <%# SEZIONE 1: Cliente %> -
        -
        - <%= icon("person_search", classes: "size-6 opacity-40") %> Anagrafica Cliente -
        - -
        - <%= f.hidden_field :member_id, data: { autocomplete_target: "hidden", action: "change->autosubmit#submit" } %> - - - - -
        -
        - - <%# SEZIONE 2: Prodotto %> -
        -
        - <%= icon("shopping_bag", classes: "size-6 opacity-40") %> Dettagli Acquisto -
        - -
        -
        - <%= f.label :product_id, "Prodotto Selezionato", class: "label text-xs font-semibold uppercase opacity-70" %> - <%= f.collection_select :product_id, Product.all, :id, :name, - { prompt: "Scegli un prodotto..." }, - { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> -
        - -
        - <%= f.label :sold_on, "Data Contabile", class: "label text-xs font-semibold uppercase opacity-70" %> - <%= f.date_field :sold_on, class: "input w-full", data: { action: "change->autosubmit#submit" } %> -
        - -
        - <%= f.label :payment_method, "Metodo di Pagamento", class: "label text-xs font-semibold uppercase opacity-70" %> - <%= f.select :payment_method, Sale.payment_methods.keys, {}, class: "select w-full", data: { action: "change->autosubmit#submit" } %> -
        -
        -
        - - <%# SEZIONE 3: Iscrizione %> -
        -
        - <%= icon("calendar_today", classes: "size-6 opacity-40") %> Validità Iscrizione -
        - - <%= f.fields_for :subscription do |sub_f| %> -
        -
        - <%= sub_f.label :start_date, "Decorrenza (Modificabile)", class: "label text-xs font-semibold uppercase opacity-70" %> - <%= sub_f.date_field :start_date, class: "input w-full", data: { action: "change->autosubmit#submit" } %> -
        -
        - - -
        -
        - <% end %> -
        - + <%# I 3 BLOCCHI PRINCIPALI DIVISI IN PARTIALS %> + <%= render "sales/form_sections/customer", f: f, sale: sale, is_installment: is_installment %> + <%= render "sales/form_sections/product", f: f, sale: sale, is_installment: is_installment %> + <%= render "sales/form_sections/subscription", f: f, sale: sale, is_installment: is_installment %>
        - - <%# COLONNA DESTRA: L'unica vera "Isola" %> -
        - -
        - -

        - <%= icon("receipt") %> Riepilogo Scontrino -

        - -
        -
        - Socio - <%= sale.member&.full_name || "---" %> -
        - -
        - Prodotto - <%= sale.product&.name || "---" %> -
        - -
        - <%= f.label :amount, "Importo da Incassare (€)", class: "text-[10px] font-bold uppercase tracking-wider opacity-50" %> - <%= f.text_field :amount, - class: "input input-lg w-full text-right font-mono text-3xl font-black focus:border-primary focus:ring-1 focus:ring-primary", - data: { action: "change->autosubmit#submit" } %> -
        -
        - -
        - <%= f.button formmethod: "post", - formaction: sales_path, - data: { turbo_frame: "_top" }, - disabled: sale.product.nil? || sale.member.nil?, - class: "btn btn-primary btn-block btn-lg shadow-sm" do %> - <%= icon("sale", classes: "size-6") %> Conferma e Incassa - <% end %> -
        - -
        -
        + <%# LA COLONNA DI DESTRA %> + <%= render "sales/form_sections/summary", f: f, sale: sale, is_installment: is_installment %> <% end %> diff --git a/app/views/sales/form_sections/_customer.html.erb b/app/views/sales/form_sections/_customer.html.erb new file mode 100644 index 0000000..10a5b67 --- /dev/null +++ b/app/views/sales/form_sections/_customer.html.erb @@ -0,0 +1,24 @@ +
        +
        + <%= icon("person_search", classes: "size-6 opacity-40") %> Anagrafica Cliente +
        + +
        + <% if is_installment %> + <%= f.hidden_field :member_id %> + +
        Socio bloccato (Pagamento rata)
        + <% else %> + <%= f.hidden_field :member_id, data: { autocomplete_target: "hidden", action: "change->autosubmit#submit" } %> + + + <% end %> +
        +
        diff --git a/app/views/sales/form_sections/_product.html.erb b/app/views/sales/form_sections/_product.html.erb new file mode 100644 index 0000000..05f127c --- /dev/null +++ b/app/views/sales/form_sections/_product.html.erb @@ -0,0 +1,49 @@ +
        +
        + <%= icon("shopping_bag", classes: "size-6 opacity-40") %> Dettagli Acquisto +
        + +
        +
        + <%= f.label :product_id, "Prodotto Selezionato", class: "label text-xs font-semibold uppercase opacity-70" %> + <% if is_installment %> + <%= f.hidden_field :product_id %> + + <% else %> + <%= f.collection_select :product_id, Product.all, :id, :name, + { prompt: "Scegli un prodotto..." }, + { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> + <% end %> +
        + +
        + <%= f.label :sold_on, "Data Contabile", class: "label text-xs font-semibold uppercase opacity-70" %> + <%= f.date_field :sold_on, class: "input w-full", data: { action: "change->autosubmit#submit" } %> +
        + +
        + <%= f.label :payment_method, "Metodo di Pagamento", class: "label text-xs font-semibold uppercase opacity-70" %> + <%= f.select :payment_method, Sale.payment_methods.keys, {}, class: "select w-full", data: { action: "change->autosubmit#submit" } %> +
        +
        + + <% unless is_installment %> + <%= f.fields_for :subscription do |sub_f| %> +
        +
        +
        + <%= icon("euro_symbol", classes: "size-4 text-warning") %> Prezzo Concordato +
        +
        Modifica questo valore solo per applicare uno sconto.
        +
        +
        + <%= sub_f.text_field :agreed_price, + value: sale.subscription&.agreed_price || sale.product&.price, + class: "input input-sm input-bordered w-24 font-bold text-right rounded-r-none focus:border-warning focus:ring-1 focus:ring-warning", + data: { action: "change->autosubmit#submit" } %> + +
        +
        + <% end %> + <% end %> +
        diff --git a/app/views/sales/form_sections/_subscription.html.erb b/app/views/sales/form_sections/_subscription.html.erb new file mode 100644 index 0000000..5ce0dbb --- /dev/null +++ b/app/views/sales/form_sections/_subscription.html.erb @@ -0,0 +1,59 @@ +
        +
        + <%= icon("calendar_today", classes: "size-6 opacity-40") %> Validità Iscrizione +
        + + <% if is_installment %> +
        +
        + + <%= date_field_tag nil, sale.subscription.start_date, class: "input w-full bg-base-200 text-base-content/70 cursor-not-allowed", disabled: true %> +
        +
        + + <%= date_field_tag nil, sale.subscription.end_date, class: "input w-full bg-base-200 text-base-content/70 cursor-not-allowed", disabled: true %> +
        +
        +
        Le date non sono modificabili durante il saldo di una rata.
        + <% else %> + <%= f.fields_for :subscription do |sub_f| %> +
        + + <%# COLONNA SINISTRA %> +
        + + <%= sub_f.date_field :start_date, class: "input w-full", data: { action: "change->autosubmit#submit" } %> +
        + + <%# COLONNA DESTRA %> +
        + <% if current_user.try(:admin?) %> + <% override_active = params[:override_end_date] == "1" %> + + + + <% if override_active %> + <%= sub_f.date_field :end_date, class: "input w-full border-warning text-warning focus:ring-warning", data: { action: "change->autosubmit#submit" } %> + <% else %> + <%= sub_f.date_field :end_date, class: "input w-full bg-base-200 text-base-content/50 cursor-not-allowed", disabled: true %> + <% end %> + + <% else %> + + <%= sub_f.date_field :end_date, class: "input w-full bg-transparent border-dashed text-base-content/50", disabled: true %> + <% end %> +
        + +
        + <% end %> + <% end %> +
        diff --git a/app/views/sales/form_sections/_summary.html.erb b/app/views/sales/form_sections/_summary.html.erb new file mode 100644 index 0000000..e7e0406 --- /dev/null +++ b/app/views/sales/form_sections/_summary.html.erb @@ -0,0 +1,49 @@ +
        +
        +

        + <%= icon("receipt") %> Riepilogo Scontrino +

        + +
        +
        + Socio + <%= sale.member&.full_name || "---" %> +
        + +
        + Prodotto + <%= sale.product&.name || "---" %> +
        + + <% if is_installment %> +
        +
        + Costo Totale: + <%= format_money(sale.subscription.agreed_price) rescue "---" %> +
        +
        + Già versato: + <%= format_cents(sale.subscription.amount_paid) rescue "---" %> +
        +
        + <% end %> + +
        + <%= f.label :amount, "Importo da Incassare (€)", class: "text-[10px] font-bold uppercase tracking-wider opacity-50" %> + <%= f.text_field :amount, + class: "input input-lg w-full text-right font-mono text-3xl font-black focus:border-primary focus:ring-1 focus:ring-primary", + data: { action: "change->autosubmit#submit" } %> +
        +
        + +
        + <%= f.button formmethod: "post", + formaction: sales_path, + data: { turbo_frame: "_top" }, + disabled: sale.product.nil? || sale.member.nil?, + class: "btn btn-primary btn-block btn-lg shadow-sm" do %> + <%= icon("sale", classes: "size-6") %> <%= is_installment ? "Incassa Rata" : "Conferma e Incassa" %> + <% end %> +
        +
        +
        diff --git a/db/migrate/20260410181021_add_agreed_price_to_subscriptions.rb b/db/migrate/20260410181021_add_agreed_price_to_subscriptions.rb new file mode 100644 index 0000000..2ea007a --- /dev/null +++ b/db/migrate/20260410181021_add_agreed_price_to_subscriptions.rb @@ -0,0 +1,5 @@ +class AddAgreedPriceToSubscriptions < ActiveRecord::Migration[8.1] + def change + add_column :subscriptions, :agreed_price_cents, :integer, default: 0, null: false + end +end diff --git a/db/structure.sql b/db/structure.sql index e6a6b38..a7e137c 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -116,7 +116,7 @@ CREATE UNIQUE INDEX "idx_on_receipt_year_receipt_sequence_receipt_number_3689acd CREATE INDEX "index_sales_on_sold_on" ON "sales" ("sold_on") /*application='ActiveCore'*/; CREATE INDEX "index_sales_on_user_id" ON "sales" ("user_id") /*application='ActiveCore'*/; CREATE INDEX "index_sales_on_subscription_id" ON "sales" ("subscription_id") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "subscriptions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "end_date" date NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "start_date" date NOT NULL, "suspension_days_count" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, "entry_limit" integer, CONSTRAINT "fk_rails_52a3b81fce" +CREATE TABLE IF NOT EXISTS "subscriptions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "end_date" date NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "start_date" date NOT NULL, "suspension_days_count" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, "entry_limit" integer, "agreed_price_cents" integer DEFAULT 0 NOT NULL /*application='ActiveCore'*/, CONSTRAINT "fk_rails_52a3b81fce" FOREIGN KEY ("product_id") REFERENCES "products" ("id") , CONSTRAINT "fk_rails_bfac3ecd2f" @@ -128,6 +128,7 @@ CREATE INDEX "index_subscriptions_on_member_id_and_end_date" ON "subscriptions" CREATE INDEX "index_subscriptions_on_member_id" ON "subscriptions" ("member_id") /*application='ActiveCore'*/; CREATE INDEX "index_subscriptions_on_product_id" ON "subscriptions" ("product_id") /*application='ActiveCore'*/; INSERT INTO "schema_migrations" (version) VALUES +('20260410181021'), ('20260401155455'), ('20260401155446'), ('20260323183701'), From 7f6cccc68a90ca40ad815bdf7bc141758657b1be Mon Sep 17 00:00:00 2001 From: jcostd Date: Sat, 11 Apr 2026 10:20:36 +0200 Subject: [PATCH 18/34] Updated UI --- app/models/concerns/subscription_issuer.rb | 2 +- app/models/subscription.rb | 4 + app/models/subscription_status.rb | 62 +++++++++++++++ app/views/members/_member_row.html.erb | 75 ------------------- app/views/members/_row.html.erb | 60 +++++++++++++++ app/views/members/index.html.erb | 2 +- app/views/sales/_form.html.erb | 8 +- .../form_sections/_subscription.html.erb | 27 +++---- .../sales/form_sections/_summary.html.erb | 17 +++-- app/views/subscriptions/_badge.html.erb | 8 ++ app/views/subscriptions/_card.html.erb | 53 +++++++++++++ app/views/subscriptions/_compact.html.erb | 42 +++++++++++ app/views/subscriptions/_row.html.erb | 14 ++++ lib/tasks/import_legacy.rake | 3 - 14 files changed, 270 insertions(+), 107 deletions(-) create mode 100644 app/models/subscription_status.rb delete mode 100644 app/views/members/_member_row.html.erb create mode 100644 app/views/members/_row.html.erb create mode 100644 app/views/subscriptions/_badge.html.erb create mode 100644 app/views/subscriptions/_card.html.erb create mode 100644 app/views/subscriptions/_compact.html.erb create mode 100644 app/views/subscriptions/_row.html.erb diff --git a/app/models/concerns/subscription_issuer.rb b/app/models/concerns/subscription_issuer.rb index 7378a1e..3ec1eb5 100644 --- a/app/models/concerns/subscription_issuer.rb +++ b/app/models/concerns/subscription_issuer.rb @@ -2,7 +2,7 @@ module SubscriptionIssuer extend ActiveSupport::Concern included do - belongs_to :subscription, optional: true, autosave: true + belongs_to :subscription, optional: true, autosave: true, touch: true accepts_nested_attributes_for :subscription, reject_if: :all_blank after_discard :discard_subscription_if_empty diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 5f78d58..3a47d1e 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -12,6 +12,10 @@ class Subscription < ApplicationRecord before_validation :set_default_agreed_price, on: :create before_validation :apply_business_rules, on: :create + def status + @status ||= SubscriptionStatus.new(self) + end + def assign_smart_dates(manual_start_date: nil) self.start_date = manual_start_date if manual_start_date.present? apply_business_rules diff --git a/app/models/subscription_status.rb b/app/models/subscription_status.rb new file mode 100644 index 0000000..698a561 --- /dev/null +++ b/app/models/subscription_status.rb @@ -0,0 +1,62 @@ +class SubscriptionStatus + attr_reader :subscription + + def initialize(subscription) + @subscription = subscription + end + + def requires_attention? + [:pending_payment, :expired, :expiring_soon].include?(key) + end + + def key + if !subscription.fully_paid? + :pending_payment + elsif subscription.end_date.present? && subscription.end_date < Date.current + :expired + elsif subscription.start_date.present? && subscription.start_date > Date.current + :future + elsif expiring_soon? + :expiring_soon + else + :active + end + end + + def label + case key + when :pending_payment then "Da Saldare" + when :expired then "Scaduto" + when :future then "Futuro" + when :expiring_soon then "In Scadenza" + when :active then "Attivo" + end + end + + def color + case key + when :pending_payment then "error" + when :expired then "neutral" + when :future then "info" + when :expiring_soon then "warning" + when :active then "success" + end + end + + def icon + case key + when :pending_payment then "payments" + when :expired then "event_busy" + when :future then "event_upcoming" + when :expiring_soon then "notification_important" + when :active then "check_circle" + end + end + + private + def expiring_soon? + return false unless subscription.end_date + + subscription.end_date.between?(Date.current, 14.days.from_now.to_date) + end +end diff --git a/app/views/members/_member_row.html.erb b/app/views/members/_member_row.html.erb deleted file mode 100644 index a3924ea..0000000 --- a/app/views/members/_member_row.html.erb +++ /dev/null @@ -1,75 +0,0 @@ -
      • - - <%# -- Colonna 1: Avatar con Helper -- %> -
        - <%= ui_avatar(member, size: "size-10", text_size: "text-md") %> -
        - - <%# -- Colonna 2: Contenuto centrale -- %> -
        - <%# Riga A: Nome %> -
        - <%= link_to member.full_name, member, - data: { turbo_frame: "_top" }, - class: "hover:underline hover:text-primary transition-colors" %> - <%= ui_badge("Archiviato") if member.discarded? %> -
        - -
        - <%= member.fiscal_code %> - - - <%= ui_status_badge(member.membership_valid?, - valid_text: "Tessera Attiva", invalid_text: "Tessera Scaduta") %> - - <% unless member.medical_certificate_valid? %> - - <%= ui_status_badge(false, - valid_text: "", invalid_text: "Cert. Medico", invalid_class: "badge-warning badge-soft") %> - <% end %> -
        - - <%# Riga C: Abbonamenti & Quick Actions %> -
        - <% if member.relevant_subscriptions.any? %> - <% member.relevant_subscriptions.each do |sub| %> - <% if sub.expired? || sub.expiring_soon? %> -
        - <%= sub.product.name %> - - <%= sub.expired? ? "SCADUTO" : "-#{sub.days_left}gg" %> - - <%= link_to new_sale_path(member_id: member.id, renew_subscription_id: sub.id), - class: "btn btn-ghost btn-xs btn-circle ml-0.5 hover:bg-current/20 text-current", - data: { turbo_frame: "modal" }, title: "Rinnova al volo" do %> - <%= icon("reset", classes: "size-4") %> - <% end %> -
        - <% else %> -
        - <%= sub.product.name %> - <%= sub.days_left %>gg -
        - <% end %> - <% end %> - <% else %> - - Nessuna attività - - <% end %> -
        -
        - - <%# -- Colonne 3: Azioni -- %> - <% unless member.discarded? %> -
        - <%= ui_row_edit_button(edit_member_path(member)) %> - - <% if current_user.admin? %> - <%= ui_row_delete_button(member, confirm: "Sei sicuro di voler archiviare questo membro?") %> - <% end %> -
        - <% end %> -
      • diff --git a/app/views/members/_row.html.erb b/app/views/members/_row.html.erb new file mode 100644 index 0000000..49d9bf6 --- /dev/null +++ b/app/views/members/_row.html.erb @@ -0,0 +1,60 @@ +<%# locals: (member:) %> + +
      • + + <%# -- Colonna 1: Avatar con Helper -- %> +
        + <%= ui_avatar(member, size: "size-10", text_size: "text-md") %> +
        + + <%# -- Colonna 2: Contenuto centrale -- %> +
        + <%# Riga A: Nome %> +
        + <%= link_to member.full_name, member, + data: { turbo_frame: "_top" }, + class: "hover:underline hover:text-primary transition-colors" %> + <%= ui_badge("Archiviato") if member.discarded? %> +
        + +
        + <%# Sfruttiamo il tuo FormatHelper per gestire i CF vuoti %> + <%= display_value(member.fiscal_code) %> + + + <%= ui_status_badge(member.membership_valid?, + valid_text: "Tessera Attiva", invalid_text: "Tessera Scaduta") %> + + <% unless member.medical_certificate_valid? %> + + <%= ui_status_badge(false, + valid_text: "", invalid_text: "Cert. Medico", invalid_class: "badge-warning badge-soft") %> + <% end %> +
        + + <%# Riga C: Abbonamenti & Quick Actions %> +
        + <% if member.relevant_subscriptions.any? %> + <%# MAGIA RAILS: iteriamo sfruttando la collection e il nostro nuovo partial %> + <%= render partial: "subscriptions/compact", collection: member.relevant_subscriptions, as: :subscription %> + <% else %> + + Nessuna attività + + <% end %> +
        +
        + + <%# -- Colonne 3: Azioni -- %> + <% unless member.discarded? %> +
        + <%= ui_row_edit_button(edit_member_path(member)) %> + + <% if current_user.try(:admin?) %> + <%= ui_row_delete_button(member, confirm: "Sei sicuro di voler archiviare questo socio?") %> + <% end %> +
        + <% end %> +
      • diff --git a/app/views/members/index.html.erb b/app/views/members/index.html.erb index 70b1484..bdb92eb 100644 --- a/app/views/members/index.html.erb +++ b/app/views/members/index.html.erb @@ -96,7 +96,7 @@ <%= filtered_results_counter(@pagy) %>
          - <%= render partial: "member_row", collection: @members, as: :member %> + <%= render partial: "members/row", collection: @members, as: :member %>
        <%= render "shared/pagination" %> diff --git a/app/views/sales/_form.html.erb b/app/views/sales/_form.html.erb index d47237e..35c3b51 100644 --- a/app/views/sales/_form.html.erb +++ b/app/views/sales/_form.html.erb @@ -3,10 +3,12 @@ url: new_sale_path, method: :get, data: { controller: "autosubmit" }, - class: "flex flex-col md:flex-row gap-8 lg:gap-12" do |f| %> + class: "flex flex-col md:flex-row gap-4" do |f| %> <% is_installment = sale.subscription&.persisted? %> + <%# === VARIABILI DI STATO DEL FORM === %> + <%= f.hidden_field :subscription_id %> <%= hidden_field_tag :previous_member_id, sale.member_id %> <%= hidden_field_tag :previous_product_id, sale.product_id %> <%= hidden_field_tag :renew_subscription_id, params[:renew_subscription_id] %> @@ -15,16 +17,14 @@ <%= hidden_field_tag :installment_for_subscription_id, params[:installment_for_subscription_id] %> <% end %> -
        +
        <%= render "shared/form_errors", model: sale %> - <%# I 3 BLOCCHI PRINCIPALI DIVISI IN PARTIALS %> <%= render "sales/form_sections/customer", f: f, sale: sale, is_installment: is_installment %> <%= render "sales/form_sections/product", f: f, sale: sale, is_installment: is_installment %> <%= render "sales/form_sections/subscription", f: f, sale: sale, is_installment: is_installment %>
        - <%# LA COLONNA DI DESTRA %> <%= render "sales/form_sections/summary", f: f, sale: sale, is_installment: is_installment %> <% end %> diff --git a/app/views/sales/form_sections/_subscription.html.erb b/app/views/sales/form_sections/_subscription.html.erb index 5ce0dbb..c0ccfbd 100644 --- a/app/views/sales/form_sections/_subscription.html.erb +++ b/app/views/sales/form_sections/_subscription.html.erb @@ -1,20 +1,20 @@ -
        +
        <%= icon("calendar_today", classes: "size-6 opacity-40") %> Validità Iscrizione
        <% if is_installment %>
        -
        - +
        + <%= date_field_tag nil, sale.subscription.start_date, class: "input w-full bg-base-200 text-base-content/70 cursor-not-allowed", disabled: true %> -
        -
        -
        +
        Le date non sono modificabili durante il saldo di una rata.
        <% else %> @@ -23,7 +23,8 @@ <%# COLONNA SINISTRA %>
        - + <%= sub_f.date_field :start_date, class: "input w-full", data: { action: "change->autosubmit#submit" } %>
        @@ -32,11 +33,9 @@ <% if current_user.try(:admin?) %> <% override_active = params[:override_end_date] == "1" %> -
        <% if is_installment %> -
        +
        Costo Totale: - <%= format_money(sale.subscription.agreed_price) rescue "---" %> + <%= format_money(sale.subscription.agreed_price) %>
        Già versato: - <%= format_cents(sale.subscription.amount_paid) rescue "---" %> + <%= format_cents(sale.subscription.amount_paid) %>
        <% end %>
        - <%= f.label :amount, "Importo da Incassare (€)", class: "text-[10px] font-bold uppercase tracking-wider opacity-50" %> + <%= f.label :amount, "Importo da Incassare (€)", class: "label text-[10px] font-bold uppercase tracking-wider opacity-50" %> <%= f.text_field :amount, - class: "input input-lg w-full text-right font-mono text-3xl font-black focus:border-primary focus:ring-1 focus:ring-primary", - data: { action: "change->autosubmit#submit" } %> + autocomplete: "off", + class: "input input-lg w-full text-right font-mono text-3xl", + data: { action: "change->autosubmit#submit" } %>
        <%= f.button formmethod: "post", - formaction: sales_path, - data: { turbo_frame: "_top" }, + formaction: sales_path, + data: { turbo_frame: "_top" }, disabled: sale.product.nil? || sale.member.nil?, class: "btn btn-primary btn-block btn-lg shadow-sm" do %> <%= icon("sale", classes: "size-6") %> <%= is_installment ? "Incassa Rata" : "Conferma e Incassa" %> diff --git a/app/views/subscriptions/_badge.html.erb b/app/views/subscriptions/_badge.html.erb new file mode 100644 index 0000000..f7445c6 --- /dev/null +++ b/app/views/subscriptions/_badge.html.erb @@ -0,0 +1,8 @@ +<%# locals: (subscription:) %> + +
        + <%= icon(subscription.status.icon, classes: "size-3.5") %> + + <%= subscription.status.label %> + +
        diff --git a/app/views/subscriptions/_card.html.erb b/app/views/subscriptions/_card.html.erb new file mode 100644 index 0000000..f4aa6bb --- /dev/null +++ b/app/views/subscriptions/_card.html.erb @@ -0,0 +1,53 @@ +<%# locals: (subscription:) %> + +
        +
        + + <%# Intestazione: Nome Prodotto e Badge %> +
        +
        +

        <%= subscription.product.name %>

        +

        + Dal <%= subscription.start_date&.strftime("%d %b %Y") %> al <%= subscription.end_date&.strftime("%d %b %Y") %> +

        +
        + <%= render "subscriptions/badge", subscription: subscription %> +
        + +
        + + <%# Sezione Finanziaria %> +
        +
        +
        Stato Pagamento
        +
        + <%= humanized_money_with_symbol(subscription.amount_paid) %> / <%= humanized_money_with_symbol(subscription.agreed_price) %> +
        +
        + + <% unless subscription.fully_paid? %> +
        + Resta: <%= humanized_money_with_symbol(subscription.agreed_price - (subscription.amount_paid || 0)) %> +
        + <% end %> +
        + + <%# La barra di progresso DaisyUI prende lo stesso colore dello stato! %> + + + + <%# Bottoni d'azione in base allo stato %> +
        + <%= link_to "Dettagli", subscription_path(subscription), class: "btn btn-sm btn-ghost" %> + + <% if subscription.status.key == :pending_payment %> + <%# Qui si chiude il cerchio col tuo POS! %> + <%= link_to "Incassa Rata", new_member_sale_path(subscription.member, installment_for_subscription_id: subscription.id), class: "btn btn-sm btn-primary" %> + <% end %> +
        + +
        +
        diff --git a/app/views/subscriptions/_compact.html.erb b/app/views/subscriptions/_compact.html.erb new file mode 100644 index 0000000..40a924e --- /dev/null +++ b/app/views/subscriptions/_compact.html.erb @@ -0,0 +1,42 @@ +<%# locals: (subscription:) %> + +<% status = subscription.status %> + +<% bg_class = status.requires_attention? ? "badge-#{status.color} badge-soft" : "badge-ghost opacity-60" %> +<% padding_class = status.requires_attention? ? "pl-2 pr-0.5 py-3" : "py-3" %> + +
        + + <%= subscription.product.name %> + + + + + <% if status.key == :pending_payment %> + -<%= format_cents(subscription.agreed_price_cents - subscription.amount_paid) %> + <% elsif status.key == :expired %> + SCADUTO + <% elsif subscription.respond_to?(:days_left) && subscription.days_left.present? %> + -<%= subscription.days_left %>gg + <% else %> + <%= format_date(subscription.end_date, format: :short) %> + <% end %> + + + <% if status.key == :pending_payment %> + <%# AZIONE A: Paga la rata %> + <%= link_to new_sale_path(member_id: subscription.member_id, installment_for_subscription_id: subscription.id), + class: "btn btn-ghost btn-xs btn-circle ml-0.5 hover:bg-current/20 text-current", + data: { turbo_frame: "modal" }, title: "Incassa Rata" do %> + <%= icon("payments", classes: "size-4") %> + <% end %> + + <% elsif [:expired, :expiring_soon].include?(status.key) %> + <%# AZIONE B: Rinnova abbonamento %> + <%= link_to new_sale_path(member_id: subscription.member_id, renew_subscription_id: subscription.id), + class: "btn btn-ghost btn-xs btn-circle ml-0.5 hover:bg-current/20 text-current", + data: { turbo_frame: "modal" }, title: "Rinnova al volo" do %> + <%= icon("reset", classes: "size-4") %> + <% end %> + <% end %> +
        diff --git a/app/views/subscriptions/_row.html.erb b/app/views/subscriptions/_row.html.erb new file mode 100644 index 0000000..8726070 --- /dev/null +++ b/app/views/subscriptions/_row.html.erb @@ -0,0 +1,14 @@ +<%# locals: (subscription:) %> + +
        +
        + <%= subscription.product.name %> + + <%= subscription.start_date&.strftime("%d/%m/%Y") %> → <%= subscription.end_date&.strftime("%d/%m/%Y") %> + +
        + +
        + <%= render "subscriptions/badge", subscription: subscription %> +
        +
        diff --git a/lib/tasks/import_legacy.rake b/lib/tasks/import_legacy.rake index 03f3635..3b2a985 100644 --- a/lib/tasks/import_legacy.rake +++ b/lib/tasks/import_legacy.rake @@ -1,6 +1,3 @@ -# lib/tasks/import_legacy.rake - -# Definiamo il modulo FUORI dal namespace per chiarezza e per evitare problemi di scope module Source DB_FILE = "legacy.sqlite3" From 9033c99526f7f6d276bc87bbef44a386daa5c010 Mon Sep 17 00:00:00 2001 From: jcostd Date: Sat, 11 Apr 2026 10:36:56 +0200 Subject: [PATCH 19/34] Updated UI --- app/views/sales/_form.html.erb | 2 +- app/views/sales/form_sections/_customer.html.erb | 1 + app/views/sales/form_sections/_product.html.erb | 8 +++++--- app/views/sales/form_sections/_subscription.html.erb | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/views/sales/_form.html.erb b/app/views/sales/_form.html.erb index 35c3b51..c33672c 100644 --- a/app/views/sales/_form.html.erb +++ b/app/views/sales/_form.html.erb @@ -17,7 +17,7 @@ <%= hidden_field_tag :installment_for_subscription_id, params[:installment_for_subscription_id] %> <% end %> -
        +
        <%= render "shared/form_errors", model: sale %> <%= render "sales/form_sections/customer", f: f, sale: sale, is_installment: is_installment %> diff --git a/app/views/sales/form_sections/_customer.html.erb b/app/views/sales/form_sections/_customer.html.erb index 10a5b67..6bd041e 100644 --- a/app/views/sales/form_sections/_customer.html.erb +++ b/app/views/sales/form_sections/_customer.html.erb @@ -11,6 +11,7 @@ <% else %> <%= f.hidden_field :member_id, data: { autocomplete_target: "hidden", action: "change->autosubmit#submit" } %>
        <%= sub_f.text_field :agreed_price, - value: sale.subscription&.agreed_price || sale.product&.price, - class: "input input-sm input-bordered w-24 font-bold text-right rounded-r-none focus:border-warning focus:ring-1 focus:ring-warning", - data: { action: "change->autosubmit#submit" } %> + autocomplete: "off", + name: "agreed_price_selection", + value: sale.subscription&.agreed_price || sale.product&.price, + class: "input input-sm w-24 font-bold text-right rounded-r-none focus:border-warning focus:ring-1 focus:ring-warning", + data: { action: "change->autosubmit#submit" } %>
        diff --git a/app/views/sales/form_sections/_subscription.html.erb b/app/views/sales/form_sections/_subscription.html.erb index c0ccfbd..8a853b2 100644 --- a/app/views/sales/form_sections/_subscription.html.erb +++ b/app/views/sales/form_sections/_subscription.html.erb @@ -1,4 +1,4 @@ -
        +
        <%= icon("calendar_today", classes: "size-6 opacity-40") %> Validità Iscrizione
        @@ -16,7 +16,7 @@ <%= date_field_tag nil, sale.subscription.end_date, class: "input w-full bg-base-200 text-base-content/70 cursor-not-allowed", disabled: true %>
        -
        Le date non sono modificabili durante il saldo di una rata.
        +
        Le date non sono modificabili durante il saldo di una rata.
        <% else %> <%= f.fields_for :subscription do |sub_f| %>
        From fd11e81e9cd6fbf9b7f31bc9ac5a6c7d73d8fab2 Mon Sep 17 00:00:00 2001 From: jcostd Date: Sat, 11 Apr 2026 12:00:34 +0200 Subject: [PATCH 20/34] Updated UI --- app/helpers/sales_helper.rb | 42 +++++++---------- app/models/subscription_status.rb | 8 ++-- .../{_sale_row.html.erb => _row.html.erb} | 47 ++++++++++++------- app/views/members/sales/index.html.erb | 2 +- app/views/subscriptions/_card.html.erb | 6 +-- app/views/subscriptions/_row.html.erb | 14 ------ 6 files changed, 55 insertions(+), 64 deletions(-) rename app/views/members/sales/{_sale_row.html.erb => _row.html.erb} (54%) delete mode 100644 app/views/subscriptions/_row.html.erb diff --git a/app/helpers/sales_helper.rb b/app/helpers/sales_helper.rb index 5e99d38..019aea2 100644 --- a/app/helpers/sales_helper.rb +++ b/app/helpers/sales_helper.rb @@ -1,33 +1,25 @@ module SalesHelper - def payment_method_badge(method) - base_class = "badge badge-sm badge-soft gap-1 text-[10px] uppercase font-bold tracking-wider" + PAYMENT_METHODS = { + "cash" => { label: "Contanti", icon: "payments", color: "badge-success" }, + "credit_card" => { label: "Carta / POS", icon: "credit_card", color: "badge-info" }, + "bank_transfer" => { label: "Bonifico", icon: "account_balance", color: "badge-warning" }, + "other" => { label: "Altro", icon: "receipt", color: "badge-ghost" } + }.freeze - case method.to_s - when "cash" - content_tag(:div, class: "#{base_class} badge-success") do - icon("payments", classes: "size-3") + " Contanti" - end - when "credit_card" - content_tag(:div, class: "#{base_class} badge-info") do - icon("credit_card", classes: "size-3") + " Carta" - end - when "bank_transfer" - content_tag(:div, class: "#{base_class} badge-warning") do - icon("account_balance", classes: "size-3") + " Bonifico" - end - else - content_tag(:div, class: "#{base_class} badge-ghost") do - method.to_s.humanize - end - end + # PER I FORM: f.select :payment_method, payment_method_options + def payment_method_options + PAYMENT_METHODS.map { |key, data| [ data[:label], key ] } end def payment_method_icon(method, classes: "size-6") - case method.to_s - when "cash" then icon("payments", classes: classes) - when "credit_card" then icon("credit_card", classes: classes) - when "bank_transfer" then icon("account_balance", classes: classes) - else icon("paid", classes: classes) + data = PAYMENT_METHODS[method.to_s] || PAYMENT_METHODS["other"] + icon(data[:icon], classes: classes) + end + + def payment_method_badge(method) + data = PAYMENT_METHODS[method.to_s] || PAYMENT_METHODS["other"] + content_tag(:div, class: "badge badge-sm badge-soft gap-1 text-[10px] uppercase font-bold tracking-wider #{data[:color]}") do + icon(data[:icon], classes: "size-3") + " #{data[:label]}" end end diff --git a/app/models/subscription_status.rb b/app/models/subscription_status.rb index 698a561..9ea2a05 100644 --- a/app/models/subscription_status.rb +++ b/app/models/subscription_status.rb @@ -6,7 +6,7 @@ def initialize(subscription) end def requires_attention? - [:pending_payment, :expired, :expiring_soon].include?(key) + [ :pending_payment, :expired, :expiring_soon ].include?(key) end def key @@ -46,10 +46,10 @@ def color def icon case key when :pending_payment then "payments" - when :expired then "event_busy" - when :future then "event_upcoming" + when :expired then "history" + when :future then "calendar_today" when :expiring_soon then "notification_important" - when :active then "check_circle" + when :active then "success" end end diff --git a/app/views/members/sales/_sale_row.html.erb b/app/views/members/sales/_row.html.erb similarity index 54% rename from app/views/members/sales/_sale_row.html.erb rename to app/views/members/sales/_row.html.erb index 2ba45cc..cf2d81f 100644 --- a/app/views/members/sales/_sale_row.html.erb +++ b/app/views/members/sales/_row.html.erb @@ -2,25 +2,44 @@ <%# -- Icona Metodo di Pagamento -- %>
        - <% icon_name = case sale.payment_method - when "cash" then "payments" - when "credit_card" then "credit_card" - when "bank_transfer" then "account_balance" - else "receipt" end %> -
        <%= icon(icon_name) %>
        +
        + <%= payment_method_icon(sale.payment_method, classes: "size-5") %> +
        <%# -- Dati Principali -- %>
        -
        - <%= sale.product_name_snapshot %> + + <%# NOME PRODOTTO E BADGE %> +
        +
        + <%= sale.product_name_snapshot %> +
        + + <% if sale.subscription.present? %> + <%= render "subscriptions/badge", subscription: sale.subscription %> + <% end %>
        + + <%# DATA INCASSO & RICEVUTA %>
        <%= format_date(sale.sold_on) %> <% if sale.receipt_number.present? %> <%= sale.receipt_code %> <% end %>
        + + <%# CONTESTO ABBONAMENTO (Nessun if inutile, andiamo dritti al punto) %> + <% if sale.subscription.present? %> +
        + <%= icon("calendar_today", classes: "size-3") %> + + Valido: <%= format_date(sale.subscription.start_date) %> → <%= format_date(sale.subscription.end_date) %> + +
        + <% end %> + + <%# NOTE %> <% if sale.notes.present? %>
        <%= sale.notes %>
        <% end %> @@ -35,19 +54,15 @@ <%# -- Azioni -- %>
        <%= format_money(sale.amount) %>
        + <%= link_to [sale], class: "btn btn-square btn-xs btn-ghost", title: "Dettaglio" do %> <%= icon("view") %> <% end %> <% if sale.created_at > 24.hours.ago %> - <%= link_to [sale], class: "btn btn-square btn-xs btn-ghost text-error hover:bg-error/20", - data: { - turbo_method: :delete, - turbo_confirm: "Annullare VENDITA e abbonamento? Irreversibile." - }, - title: "Annulla" do %> - <%= icon("delete") %> - <% end %> + <%= ui_row_delete_button(sale, + confirm: "Annullare VENDITA e abbonamento? Irreversibile.", + title: "Annulla") %> <% end %>
        diff --git a/app/views/members/sales/index.html.erb b/app/views/members/sales/index.html.erb index b633630..c4ee690 100644 --- a/app/views/members/sales/index.html.erb +++ b/app/views/members/sales/index.html.erb @@ -16,7 +16,7 @@
          <% if @sales.any? %> - <%= render partial: "sale_row", collection: @sales, as: :sale %> + <%= render partial: "members/sales/row", collection: @sales, as: :sale %> <% else %>
        • Nessun acquisto registrato.
        • <% end %> diff --git a/app/views/subscriptions/_card.html.erb b/app/views/subscriptions/_card.html.erb index f4aa6bb..bde2698 100644 --- a/app/views/subscriptions/_card.html.erb +++ b/app/views/subscriptions/_card.html.erb @@ -39,13 +39,11 @@ max="<%= subscription.agreed_price_cents.to_i %>"> - <%# Bottoni d'azione in base allo stato %>
          - <%= link_to "Dettagli", subscription_path(subscription), class: "btn btn-sm btn-ghost" %> + <%= link_to "Dettagli", member_subscription_path(subscription.member, subscription), class: "btn btn-sm btn-ghost" %> <% if subscription.status.key == :pending_payment %> - <%# Qui si chiude il cerchio col tuo POS! %> - <%= link_to "Incassa Rata", new_member_sale_path(subscription.member, installment_for_subscription_id: subscription.id), class: "btn btn-sm btn-primary" %> + <%= link_to "Incassa Rata", new_sale_path(member_id: subscription.member_id, installment_for_subscription_id: subscription.id), class: "btn btn-sm btn-primary" %> <% end %>
          diff --git a/app/views/subscriptions/_row.html.erb b/app/views/subscriptions/_row.html.erb deleted file mode 100644 index 8726070..0000000 --- a/app/views/subscriptions/_row.html.erb +++ /dev/null @@ -1,14 +0,0 @@ -<%# locals: (subscription:) %> - -
          -
          - <%= subscription.product.name %> - - <%= subscription.start_date&.strftime("%d/%m/%Y") %> → <%= subscription.end_date&.strftime("%d/%m/%Y") %> - -
          - -
          - <%= render "subscriptions/badge", subscription: subscription %> -
          -
          From f69d45c58ead3d62b355222201fc9ae0d79b6621 Mon Sep 17 00:00:00 2001 From: jcostd Date: Sat, 11 Apr 2026 19:58:38 +0200 Subject: [PATCH 21/34] Updated POS --- app/controllers/sales_controller.rb | 3 +- app/helpers/members_helper.rb | 29 +++++++++ app/helpers/sales_helper.rb | 8 +++ app/models/pos_draft_builder.rb | 4 +- app/views/members/_row.html.erb | 51 ++++++--------- app/views/members/index.html.erb | 2 +- .../sales/form_sections/_product.html.erb | 64 +++++++++++++------ .../form_sections/_subscription.html.erb | 8 +-- .../sales/form_sections/_summary.html.erb | 16 +++-- 9 files changed, 119 insertions(+), 66 deletions(-) diff --git a/app/controllers/sales_controller.rb b/app/controllers/sales_controller.rb index 5b34ccf..2e8849c 100644 --- a/app/controllers/sales_controller.rb +++ b/app/controllers/sales_controller.rb @@ -79,10 +79,11 @@ def sale_params_for_build end def sale_params - permitted_sub_attrs = [ :start_date, :agreed_price ] + permitted_sub_attrs = [ :start_date ] if current_user.respond_to?(:admin?) && current_user.admin? permitted_sub_attrs << :end_date + permitted_sub_attrs << :agreed_price end params.require(:sale).permit( diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index f31db6b..e73eae7 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -24,4 +24,33 @@ def member_status_color_class(member) "text-error" end end + + def member_status_badges(member) + badges = [] + + # Badge Tessera + badges << ui_status_badge( + member.membership_valid?, + valid_text: "Tessera Attiva", + invalid_text: "Tessera Scaduta" + ) + + # Badge Certificato Medico + unless member.medical_certificate_valid? + badges << ui_status_badge( + false, + valid_text: "", + invalid_text: "Cert. Medico", + invalid_class: "badge-warning badge-soft" + ) + end + + safe_join(badges, content_tag(:span, nil, class: "w-1 h-1 rounded-full bg-base-content/30 hidden sm:block")) + end + + def member_empty_subscriptions_badge + content_tag(:span, class: "text-[10px] uppercase font-bold tracking-wider opacity-40 flex items-center gap-1") do + content_tag(:span, nil, class: "size-1.5 rounded-full bg-current") + " Nessun abbonamento attivo" + end + end end diff --git a/app/helpers/sales_helper.rb b/app/helpers/sales_helper.rb index 019aea2..0768700 100644 --- a/app/helpers/sales_helper.rb +++ b/app/helpers/sales_helper.rb @@ -6,6 +6,14 @@ module SalesHelper "other" => { label: "Altro", icon: "receipt", color: "badge-ghost" } }.freeze + def grouped_product_options + Discipline.kept.order(:name).includes(:products).map do |discipline| + active_products = discipline.products.kept.order(:name).pluck(:name, :id) + + [discipline.name, active_products] + end.reject { |_, products| products.empty? } + end + # PER I FORM: f.select :payment_method, payment_method_options def payment_method_options PAYMENT_METHODS.map { |key, data| [ data[:label], key ] } diff --git a/app/models/pos_draft_builder.rb b/app/models/pos_draft_builder.rb index 52b1789..6997932 100644 --- a/app/models/pos_draft_builder.rb +++ b/app/models/pos_draft_builder.rb @@ -53,7 +53,9 @@ def handle_new_subscription_flow def apply_default_price if sale.product_id.present? - sale.subscription.agreed_price ||= sale.product.price + if sale.subscription.agreed_price.blank? || sale.subscription.agreed_price.zero? + sale.subscription.agreed_price = sale.product.price + end if sale.amount.blank? || sale.amount.zero? sale.amount = sale.subscription.agreed_price diff --git a/app/views/members/_row.html.erb b/app/views/members/_row.html.erb index 49d9bf6..897d832 100644 --- a/app/views/members/_row.html.erb +++ b/app/views/members/_row.html.erb @@ -1,58 +1,43 @@ <%# locals: (member:) %> -
        • +
        • "> - <%# -- Colonna 1: Avatar con Helper -- %> -
          - <%= ui_avatar(member, size: "size-10", text_size: "text-md") %> + <%# -- Colonna 1: Avatar -- %> +
          + <%= link_to member, data: { turbo_frame: "_top" }, class: "block hover:opacity-80 transition-opacity focus:outline-none focus:ring-2 focus:ring-primary rounded-box" do %> + <%= ui_avatar(member, size: "size-10", text_size: "text-md") %> + <% end %>
          <%# -- Colonna 2: Contenuto centrale -- %> -
          +
          + <%# Riga A: Nome %>
          - <%= link_to member.full_name, member, - data: { turbo_frame: "_top" }, - class: "hover:underline hover:text-primary transition-colors" %> + <%= link_to member.full_name, member, data: { turbo_frame: "_top" }, class: "hover:text-primary transition-colors focus:outline-none" %> <%= ui_badge("Archiviato") if member.discarded? %>
          -
          - <%# Sfruttiamo il tuo FormatHelper per gestire i CF vuoti %> + <%# Riga B: Dettagli Fiscali e Status %> +
          <%= display_value(member.fiscal_code) %> - - - <%= ui_status_badge(member.membership_valid?, - valid_text: "Tessera Attiva", invalid_text: "Tessera Scaduta") %> - <% unless member.medical_certificate_valid? %> - - <%= ui_status_badge(false, - valid_text: "", invalid_text: "Cert. Medico", invalid_class: "badge-warning badge-soft") %> - <% end %> + <%= member_status_badges(member) %>
          <%# Riga C: Abbonamenti & Quick Actions %> -
          - <% if member.relevant_subscriptions.any? %> - <%# MAGIA RAILS: iteriamo sfruttando la collection e il nostro nuovo partial %> - <%= render partial: "subscriptions/compact", collection: member.relevant_subscriptions, as: :subscription %> - <% else %> - - Nessuna attività - - <% end %> +
          + <%= render(partial: "subscriptions/compact", collection: member.relevant_subscriptions, as: :subscription) || member_empty_subscriptions_badge %>
          +
          - <%# -- Colonne 3: Azioni -- %> + <%# -- Colonna 3: Azioni -- %> <% unless member.discarded? %> -
          +
          <%= ui_row_edit_button(edit_member_path(member)) %> - <% if current_user.try(:admin?) %> + <% if current_user&.admin? %> <%= ui_row_delete_button(member, confirm: "Sei sicuro di voler archiviare questo socio?") %> <% end %>
          diff --git a/app/views/members/index.html.erb b/app/views/members/index.html.erb index bdb92eb..be45aa5 100644 --- a/app/views/members/index.html.erb +++ b/app/views/members/index.html.erb @@ -28,7 +28,7 @@
          -
          +
          @@ -23,29 +24,52 @@
          <%= f.label :payment_method, "Metodo di Pagamento", class: "label text-xs font-semibold uppercase opacity-70" %> - <%= f.select :payment_method, Sale.payment_methods.keys, {}, class: "select w-full", data: { action: "change->autosubmit#submit" } %> + <%= f.select :payment_method, + payment_method_options, + { prompt: "Seleziona metodo..." }, + class: "select w-full", + data: { action: "change->autosubmit#submit" } %>
          <% unless is_installment %> <%= f.fields_for :subscription do |sub_f| %> -
          -
          -
          - <%= icon("euro_symbol", classes: "size-4 text-warning") %> Prezzo Concordato +
          + + <%# --- SINISTRA: Titolo e Descrizione --- %> +
          + + <%= current_user&.admin? ? "Prezzo Concordato (Admin)" : "Prezzo di Listino" %> + +
          + <%= current_user&.admin? ? "Applica uno sconto o sovrapprezzo manuale per questo acquisto." : "Questo valore è bloccato e non può essere modificato." %>
          -
          Modifica questo valore solo per applicare uno sconto.
          -
          -
          - <%= sub_f.text_field :agreed_price, - autocomplete: "off", - name: "agreed_price_selection", - value: sale.subscription&.agreed_price || sale.product&.price, - class: "input input-sm w-24 font-bold text-right rounded-r-none focus:border-warning focus:ring-1 focus:ring-warning", - data: { action: "change->autosubmit#submit" } %> - -
          -
          +
          + + <%# --- DESTRA: Campo (Editabile o Bloccato) --- %> +
          + <% if current_user&.admin? %> + + + + <% else %> + +
          + <%= icon("euro_symbol", classes: "size-4 opacity-50") %> + <%= format_money(sale.subscription&.agreed_price || sale.product&.price) %> +
          + + <% end %> +
          + + <% end %> <% end %>
          diff --git a/app/views/sales/form_sections/_subscription.html.erb b/app/views/sales/form_sections/_subscription.html.erb index 8a853b2..fc834e8 100644 --- a/app/views/sales/form_sections/_subscription.html.erb +++ b/app/views/sales/form_sections/_subscription.html.erb @@ -7,13 +7,13 @@
          - <%= date_field_tag nil, sale.subscription.start_date, class: "input w-full bg-base-200 text-base-content/70 cursor-not-allowed", disabled: true %> + <%= date_field_tag nil, sale.subscription.start_date, class: "input w-full bg-base-200 text-base-content/70 cursor-not-allowed focus:outline-none", readonly: true %>
          - <%= date_field_tag nil, sale.subscription.end_date, class: "input w-full bg-base-200 text-base-content/70 cursor-not-allowed", disabled: true %> + <%= date_field_tag nil, sale.subscription.end_date, class: "input w-full bg-base-200 text-base-content/70 cursor-not-allowed focus:outline-none", readonly: true %>
          Le date non sono modificabili durante il saldo di una rata.
          @@ -41,12 +41,12 @@ <% if override_active %> <%= sub_f.date_field :end_date, class: "input w-full border-warning text-warning focus:ring-warning", data: { action: "change->autosubmit#submit" } %> <% else %> - <%= sub_f.date_field :end_date, class: "input w-full bg-base-200 text-base-content/50 cursor-not-allowed", disabled: true %> + <%= sub_f.date_field :end_date, class: "input w-full bg-base-200 text-base-content/50 cursor-not-allowed focus:outline-none", readonly: true %> <% end %> <% else %> - <%= sub_f.date_field :end_date, class: "input w-full bg-transparent border-dashed text-base-content/50", disabled: true %> + <%= sub_f.date_field :end_date, class: "input w-full bg-transparent border-dashed text-base-content/50 focus:outline-none", readonly: true %> <% end %> diff --git a/app/views/sales/form_sections/_summary.html.erb b/app/views/sales/form_sections/_summary.html.erb index 37086f6..9642d89 100644 --- a/app/views/sales/form_sections/_summary.html.erb +++ b/app/views/sales/form_sections/_summary.html.erb @@ -28,12 +28,16 @@
          <% end %> -
          - <%= f.label :amount, "Importo da Incassare (€)", class: "label text-[10px] font-bold uppercase tracking-wider opacity-50" %> - <%= f.text_field :amount, - autocomplete: "off", - class: "input input-lg w-full text-right font-mono text-3xl", - data: { action: "change->autosubmit#submit" } %> +
          + <%= f.label :amount, "Importo da Incassare", class: "label text-sm font-bold uppercase tracking-wider opacity-50" %> + +
          From 3b9e7bead29c36b6d4d48f5d2678ce9cd0af4290 Mon Sep 17 00:00:00 2001 From: jcostd Date: Sun, 12 Apr 2026 13:14:06 +0200 Subject: [PATCH 22/34] Updated KIOSK ui --- Gemfile.lock | 24 +-- .../kiosk/access_logs_controller.rb | 33 ++++ .../kiosk/disciplines_controller.rb | 27 ++- .../kiosk/member_searches_controller.rb | 16 ++ app/helpers/sales_helper.rb | 2 +- .../controllers/autocomplete_controller.js | 16 +- app/models/access_log.rb | 32 ++++ app/models/discipline.rb | 1 + app/views/kiosk/disciplines/show.html.erb | 84 ++++---- .../kiosk/member_searches/index.html.erb | 37 ++++ .../kiosk/members/_checked_in_card.html.erb | 44 +++++ app/views/members/_form.html.erb | 51 ++--- app/views/members/_member.html.erb | 173 +++++++++++++++++ app/views/members/searches/index.html.erb | 8 +- app/views/members/show.html.erb | 180 +----------------- config/routes.rb | 3 +- 16 files changed, 454 insertions(+), 277 deletions(-) create mode 100644 app/controllers/kiosk/access_logs_controller.rb create mode 100644 app/controllers/kiosk/member_searches_controller.rb create mode 100644 app/views/kiosk/member_searches/index.html.erb create mode 100644 app/views/kiosk/members/_checked_in_card.html.erb create mode 100644 app/views/members/_member.html.erb diff --git a/Gemfile.lock b/Gemfile.lock index 9ee4769..e3d438f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -366,12 +366,12 @@ GEM tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.2.1) - tailwindcss-ruby (4.2.1-aarch64-linux-gnu) - tailwindcss-ruby (4.2.1-aarch64-linux-musl) - tailwindcss-ruby (4.2.1-arm64-darwin) - tailwindcss-ruby (4.2.1-x86_64-linux-gnu) - tailwindcss-ruby (4.2.1-x86_64-linux-musl) + tailwindcss-ruby (4.2.2) + tailwindcss-ruby (4.2.2-aarch64-linux-gnu) + tailwindcss-ruby (4.2.2-aarch64-linux-musl) + tailwindcss-ruby (4.2.2-arm64-darwin) + tailwindcss-ruby (4.2.2-x86_64-linux-gnu) + tailwindcss-ruby (4.2.2-x86_64-linux-musl) thor (1.5.0) thruster (0.1.20) thruster (0.1.20-aarch64-linux) @@ -583,12 +583,12 @@ CHECKSUMS stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 tailwindcss-rails (4.4.0) sha256=efa2961351a52acebe616e645a81a30bb4f27fde46cc06ce7688d1cd1131e916 - tailwindcss-ruby (4.2.1) sha256=95886a1e24b42d76792c787d34e47098b53cb3b5a6363845bca4486f52b2e66a - tailwindcss-ruby (4.2.1-aarch64-linux-gnu) sha256=de457ddfc999c6bbbe1a59fbc11eb2168d619f6e0cb72d8d3334d372b331e36f - tailwindcss-ruby (4.2.1-aarch64-linux-musl) sha256=e6ed27704263201f8366316354aa45f9016cc9378ce8fac46fbbe65fafd4da5e - tailwindcss-ruby (4.2.1-arm64-darwin) sha256=bcf222fb8542cf5433925623e5e7b257897fbb8291a2350daae870a32f2eeb91 - tailwindcss-ruby (4.2.1-x86_64-linux-gnu) sha256=201d0e5e5d4aba52cae4ee4bd1acd497d2790c83e7f15da964aab8ec93876831 - tailwindcss-ruby (4.2.1-x86_64-linux-musl) sha256=79fa48ad51e533545f9fdbb04227e1342a65a42c2bd1314118b95473d5612007 + tailwindcss-ruby (4.2.2) sha256=ce66da7b01fb6ef1ad6485b4b8c3476fac959f3324894fd26ec7c67ab3996d30 + tailwindcss-ruby (4.2.2-aarch64-linux-gnu) sha256=8656621046bb54c9c368cd1d2f03f7bfaf6046a4fe7060c574b9958043f1deeb + tailwindcss-ruby (4.2.2-aarch64-linux-musl) sha256=3dbaa653a5e9cddbb6bc73598a566d7172a91724463000cd594624dfe5b0eaec + tailwindcss-ruby (4.2.2-arm64-darwin) sha256=2d66feba0c1ffca5b79246bd881bfb9a6b2298d57c4bc83ee3a8c3233df79d41 + tailwindcss-ruby (4.2.2-x86_64-linux-gnu) sha256=7f5e7cdd697ff25600d684cedb4df4a56736633c231ee03c7148992c62fd228f + tailwindcss-ruby (4.2.2-x86_64-linux-musl) sha256=676b802dafc677983d471f3acf2dddbddea4e978ea0300bfa21ebd6ab167d6a8 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thruster (0.1.20) sha256=c05f2fbcae527bbe093a6e6d84fb12d9d680617e7c162325d9b97e8e9d4b5201 thruster (0.1.20-aarch64-linux) sha256=754f1701061235235165dde31e7a3bc87ec88066a02981ff4241fcda0d76d397 diff --git a/app/controllers/kiosk/access_logs_controller.rb b/app/controllers/kiosk/access_logs_controller.rb new file mode 100644 index 0000000..401755b --- /dev/null +++ b/app/controllers/kiosk/access_logs_controller.rb @@ -0,0 +1,33 @@ +class Kiosk::AccessLogsController < Kiosk::BaseController + def create + @discipline = Discipline.find(params[:discipline_id]) + @member = Member.find(params[:member_id]) + + @access_log = AccessLog.new( + member: @member, + discipline: @discipline, + checkin_by_user: current_user + ) + + if @access_log.save + if @access_log.status == "ok" + flash[:success] = "Check-in registrato per #{@member.first_name}" + else + flash[:warning] = "Check-in forzato per #{@member.first_name} (Verificare anagrafica)" + end + else + flash[:error] = @access_log.errors.full_messages.to_sentence + end + + redirect_to kiosk_discipline_path(@discipline) + end + + def destroy + @discipline = Discipline.find(params[:discipline_id]) + @access_log = @discipline.access_logs.find(params[:id]) + + @access_log.destroy + + redirect_to kiosk_discipline_path(@discipline), notice: "Check-in annullato per #{@access_log.member.first_name}" + end +end diff --git a/app/controllers/kiosk/disciplines_controller.rb b/app/controllers/kiosk/disciplines_controller.rb index d375502..fc86b8b 100644 --- a/app/controllers/kiosk/disciplines_controller.rb +++ b/app/controllers/kiosk/disciplines_controller.rb @@ -6,12 +6,25 @@ def index def show @discipline = Discipline.kept.find(params[:id]) - @expected_members = Member.kept - .joins(subscriptions: { product: :disciplines }) - .where(disciplines: { id: @discipline.id }) - .where("subscriptions.start_date <= :today AND subscriptions.end_date >= :today", today: Date.current) - .where(subscriptions: { discarded_at: nil }) - .distinct - .order(:first_name, :last_name) + @today_accesses = @discipline.access_logs + .where(entered_at: Time.current.all_day) + .includes(:member) + .order(entered_at: :desc) + + checked_in_member_ids = @today_accesses.map(&:member_id) + + + @pending_members = Member.kept + .joins(subscriptions: { product: :disciplines }) + .where(disciplines: { id: @discipline.id }) + .where("subscriptions.start_date <= :today AND subscriptions.end_date >= :today", today: Date.current) + .where(subscriptions: { discarded_at: nil }) + .distinct + + if checked_in_member_ids.any? + @pending_members = @pending_members.where.not(id: checked_in_member_ids) + end + + @pending_members = @pending_members.order(:first_name, :last_name) end end diff --git a/app/controllers/kiosk/member_searches_controller.rb b/app/controllers/kiosk/member_searches_controller.rb new file mode 100644 index 0000000..4f30605 --- /dev/null +++ b/app/controllers/kiosk/member_searches_controller.rb @@ -0,0 +1,16 @@ +class Kiosk::MemberSearchesController < Kiosk::BaseController + layout false + + def index + @discipline = Discipline.find(params[:discipline_id]) + + if params[:query].present? + @members = Member.search_text(params[:query]).limit(10) + @checked_in_ids = @discipline.access_logs + .where(entered_at: Time.current.all_day) + .pluck(:member_id) + else + @members = Member.none + end + end +end diff --git a/app/helpers/sales_helper.rb b/app/helpers/sales_helper.rb index 0768700..5096249 100644 --- a/app/helpers/sales_helper.rb +++ b/app/helpers/sales_helper.rb @@ -10,7 +10,7 @@ def grouped_product_options Discipline.kept.order(:name).includes(:products).map do |discipline| active_products = discipline.products.kept.order(:name).pluck(:name, :id) - [discipline.name, active_products] + [ discipline.name, active_products ] end.reject { |_, products| products.empty? } end diff --git a/app/javascript/controllers/autocomplete_controller.js b/app/javascript/controllers/autocomplete_controller.js index d4d6a10..1847e01 100644 --- a/app/javascript/controllers/autocomplete_controller.js +++ b/app/javascript/controllers/autocomplete_controller.js @@ -25,27 +25,31 @@ export default class extends Controller { const url = new URL(urlString, window.location.origin) url.searchParams.set("query", query) - url.searchParams.set("frame_id", this.frameTarget.id) - this.frameTarget.src = url.toString() + if (this.hasFrameTarget) { + url.searchParams.set("frame_id", this.frameTarget.id) + this.frameTarget.src = url.toString() + } } select(event) { event.preventDefault() const button = event.currentTarget - this.hiddenTarget.value = button.dataset.id this.inputTarget.value = button.dataset.name + if (this.hasHiddenTarget) { + this.hiddenTarget.value = button.dataset.id + this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true })) + } + this.closeFrame() this.inputTarget.blur() - - this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true })) } clearIfEmpty() { if (this.inputTarget.value.trim() === "") { - if (this.hiddenTarget.value !== "") { + if (this.hasHiddenTarget && this.hiddenTarget.value !== "") { this.hiddenTarget.value = "" this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true })) } diff --git a/app/models/access_log.rb b/app/models/access_log.rb index 97062e9..d1fa518 100644 --- a/app/models/access_log.rb +++ b/app/models/access_log.rb @@ -9,9 +9,11 @@ class AccessLog < ApplicationRecord enum :status, { ok: 0, warning: 1, error: 2 }, default: :ok, validate: true before_validation :set_defaults + before_validation :evaluate_access_policy, on: :create validates :member, :checkin_by_user, :entered_at, presence: true + validate :prevent_double_tap, on: :create validate :subscription_belongs_to_member validate :subscription_must_be_active, on: :create, if: -> { status == "ok" } @@ -22,6 +24,36 @@ def set_defaults self.entered_at ||= Time.current end + def evaluate_access_policy + return unless member && discipline + + policy = AccessPolicy.new(member: member, discipline: discipline) + policy.evaluate! + + self.status = policy.status + self.subscription = policy.subscription + end + + def prevent_double_tap + return unless member_id && discipline_id + + recent_entry = AccessLog.where(member_id: member_id, discipline_id: discipline_id) + .where("entered_at >= ?", 10.minutes.ago) + .exists? + + if recent_entry + errors.add(:base, "Check-in già effettuato negli ultimi 10 minuti.") + end + end + + def subscription_must_be_active + if subscription.blank? + errors.add(:base, "Impossibile registrare un accesso regolare senza un abbonamento attivo.") + elsif subscription.end_date.present? && subscription.end_date < Date.current + errors.add(:subscription, "risulta scaduto.") + end + end + def subscription_belongs_to_member return unless subscription && member if subscription.member_id != member_id diff --git a/app/models/discipline.rb b/app/models/discipline.rb index 329cd76..df30be7 100644 --- a/app/models/discipline.rb +++ b/app/models/discipline.rb @@ -4,6 +4,7 @@ class Discipline < ApplicationRecord has_many :product_disciplines, dependent: :destroy has_many :products, through: :product_disciplines + has_many :access_logs, dependent: :nullify normalizes :name, with: ->(n) { n.squish.titleize } validates :name, presence: true, uniqueness: { conditions: -> { kept } } diff --git a/app/views/kiosk/disciplines/show.html.erb b/app/views/kiosk/disciplines/show.html.erb index 56addd5..6b21d51 100644 --- a/app/views/kiosk/disciplines/show.html.erb +++ b/app/views/kiosk/disciplines/show.html.erb @@ -1,54 +1,70 @@ <%# app/views/kiosk/disciplines/show.html.erb %>
          - <%= link_to kiosk_root_path, class: "btn btn-circle btn-ghost bg-base-100 text-base-content/50" do %> + <%= link_to kiosk_root_path, class: "btn btn-circle btn-ghost bg-base-100 text-base-content/50", data: { turbo_action: "replace" } do %> <%= icon("arrow_back", classes: "size-6") %> <% end %>

          <%= @discipline.name %>

          -
          - - <%# COLONNA SINISTRA: La Ricerca Veloce (Autocomplete) %> -
          -
          -
          -

          Ricerca Rapida

          -

          Cerca un socio non in lista (es. per recuperi o PT).

          - - <%# Usiamo il tuo controller autocomplete esistente! %> -
          - - - -
          +<%# LA RICERCA A TUTTA LARGHEZZA IN ALTO %> +
          +
          +
          + +
          + + +
          +
          +
          + +<%# LO SPLIT A DUE COLONNE %> +
          - <%# COLONNA DESTRA: I Soci Attesi %> -
          -

          Soci Attesi Oggi

          + <%# COLONNA SINISTRA: DA SMARCARE %> +
          +

          + Da Smarcare (<%= @pending_members.size %>) +

          -
          - <% @expected_members.each do |member| %> + <% if @pending_members.empty? %> +
          + Nessun socio da smarcare. +
          + <% else %> + <% @pending_members.each do |member| %> <%= render "kiosk/members/card", member: member, discipline: @discipline %> <% end %> + <% end %> +
          + + <%# COLONNA DESTRA: PRESENTI IN SALA %> +
          +

          + Oggi in Sala (<%= @today_accesses.size %>) +

          - <% if @expected_members.empty? %> -
          - Nessun socio previsto in lista per oggi. -
          + <% if @today_accesses.empty? %> +
          + Nessuno ha ancora fatto check-in. +
          + <% else %> + <% @today_accesses.each do |log| %> + <%# ATTENZIONE: Qui passiamo il LOG, non il member, per avere il design verde/rosso %> + <%= render "kiosk/members/checked_in_card", log: log %> <% end %> -
          + <% end %>
          diff --git a/app/views/kiosk/member_searches/index.html.erb b/app/views/kiosk/member_searches/index.html.erb new file mode 100644 index 0000000..3bce0b3 --- /dev/null +++ b/app/views/kiosk/member_searches/index.html.erb @@ -0,0 +1,37 @@ +<%# app/views/kiosk/member_searches/index.html.erb %> + + + <% if @members.any? %> +
            + + <% @members.each do |member| %> +
          • + +
            + <%= member.full_name %> + + <% unless member.medical_certificate_valid? %> + <%= icon("warning", classes: "size-3 inline") %> Cert. Scad. + <% end %> +
            + +
            + CF: <%= display_value(member.fiscal_code, placeholder: "N/A") %> + + BIRTH: <%= format_date(member.birth_date) %> +
            +
          • + <% end %> + +
          + <% else %> +
          + Nessun socio trovato con questa ricerca. +
          + <% end %> +
          diff --git a/app/views/kiosk/members/_checked_in_card.html.erb b/app/views/kiosk/members/_checked_in_card.html.erb new file mode 100644 index 0000000..5eecb02 --- /dev/null +++ b/app/views/kiosk/members/_checked_in_card.html.erb @@ -0,0 +1,44 @@ +<%# app/views/kiosk/members/_checked_in_card.html.erb %> +<%# locals: (log:) %> + +<% + member = log.member + is_warning = log.status != "ok" + bg_class = is_warning ? "bg-warning/10 border-warning/50 text-warning-content" : "bg-success/10 border-success/50 text-success-content" +%> + +
          +
          + +
          + <%= ui_avatar(member, size: "size-12", text_size: "text-md") %> + +
          +

          <%= member.full_name %>

          + +
          + + <%= icon("clock", classes: "size-3") %> + <%= log.entered_at.strftime("%H:%M") %> + + + <% if is_warning %> + + <%= icon("warning", classes: "size-3") %> Forzato + + <% end %> +
          +
          +
          + + <%# IL BOTTONE DI ANNULLAMENTO (UNDO) %> + <%= button_to kiosk_discipline_access_log_path(log.discipline_id, log), + method: :delete, + class: "btn btn-circle btn-ghost hover:bg-error/20 hover:text-error transition-colors", + title: "Annulla ingresso", + data: { turbo: true } do %> + <%= icon("close", classes: "size-8") %> + <% end %> + +
          +
          diff --git a/app/views/members/_form.html.erb b/app/views/members/_form.html.erb index 136546d..036bfa1 100644 --- a/app/views/members/_form.html.erb +++ b/app/views/members/_form.html.erb @@ -1,25 +1,22 @@ -<%= form_with(model: member, class: "space-y-6", data: { turbo_frame: "_top" }) do |f| %> +<%= form_with(model: member, class: "space-y-6") do |f| %> <%= render "shared/form_errors", model: member %> <%# --- SEZIONE 1: DATI ANAGRAFICI --- %>
          - - <%= icon("badge", size: 14) %> - <%= t("members.form.sections.personal", default: "Dati Anagrafici") %> - + <%= icon("badge", classes: "size-4") %>Dati Anagrafici <%# RIGA 1: Nome e Cognome %>
          - <%= f.label :first_name, "Nome", class: "label" %> + <%= f.label :first_name, "Nome*", class: "label" %> <%= f.text_field :first_name, required: true, class: "input w-full", placeholder: "Mario" %>
          - <%= f.label :last_name, "Cognome", class: "label" %> + <%= f.label :last_name, "Cognome*", class: "label" %> <%= f.text_field :last_name, required: true, class: "input w-full", placeholder: "Rossi" %>
          @@ -27,8 +24,7 @@ <%# RIGA 2: Codice Fiscale e Data Nascita %>
          - <%= f.label :fiscal_code, "Codice Fiscale", class: "label" %> - + <%= f.label :fiscal_code, "Codice Fiscale*", class: "label" %> <%= f.text_field :fiscal_code, required: true, class: "input w-full uppercase font-mono tracking-wide", @@ -40,24 +36,18 @@
          - <%= f.label :birth_date, "Data di Nascita", class: "label" %> - + <%= f.label :birth_date, "Data di Nascita*", class: "label" %> <%= f.date_field :birth_date, - required: true, - class: "input w-full transition-colors duration-300", - data: { - fiscal_code_target: "birthDate" - } %> + required: true, + class: "input w-full transition-colors duration-300", + data: { fiscal_code_target: "birthDate" } %>
          - <%# --- SEZIONE 2: RESIDENZA (Novità Schema) --- %> -
          - - <%= icon("home", size: 14) %> - <%= t("members.form.sections.address", default: "Residenza") %> - + <%# --- SEZIONE 2: RESIDENZA --- %> +
          + <%= icon("home", classes: "size-4") %>Residenza <%# Indirizzo Completo %>
          @@ -80,11 +70,8 @@
          <%# --- SEZIONE 3: CONTATTI E INFO MEDICHE --- %> -
          - - <%= icon("contact_mail", size: 14) %> - <%= t("members.form.sections.details", default: "Contatti & Salute") %> - +
          + <%= icon("contact_mail", classes: "size-4") %>Contatti & Salute
          @@ -101,23 +88,21 @@
          <%# Certificato Medico %> -
          +
          <%= f.label :medical_certificate_expiry, "Data Scadenza Certificato Medico", class: "label" %> <%= f.date_field :medical_certificate_expiry, class: "input w-full" %>

          Lasciare vuoto se non presente.

          -
          +
          diff --git a/app/views/members/_member.html.erb b/app/views/members/_member.html.erb new file mode 100644 index 0000000..c3be7f6 --- /dev/null +++ b/app/views/members/_member.html.erb @@ -0,0 +1,173 @@ +<%# locals: (member:) %> + +
          + + <%# ========================================== %> + <%# COLONNA SINISTRA: DATI & STATO %> + <%# ========================================== %> +
          + + <%# Certificato Medico %> +
          +
          +

          + <%= icon("med", classes: "size-4") %> Certificato Medico +

          + +
          + <% if member.medical_certificate_valid? %> + <%= ui_status_badge(true, valid_text: "Valido fino al #{format_date(member.medical_certificate_expiry)}", invalid_text: "", icon_name: "success") %> + <% else %> + <%= ui_status_badge(false, valid_text: "", invalid_text: "Scaduto o Mancante", icon_name: "warning") %> +
          Accesso limitato.
          + <% end %> +
          + +
          + <%= link_to "Aggiorna Data", edit_member_path(member), class: "btn btn-xs btn-outline w-full", data: { turbo_frame: "modal" } %> +
          +
          + + <%# Anagrafica %> +
          +
          +

          + <%= icon("badge", classes: "size-4") %> Anagrafica +

          +
            +
          • + <%= icon("phone") %> + <%= display_value(format_phone(member.phone)) %> +
          • +
          • + <%= icon("mail") %> + <%= format_email(member.email_address) %> +
          • +
          • + <%= icon("home") %> + <%= display_value(member.full_address) %> +
          • +
          • + <%= icon("cake") %> + + <%= format_date(member.birth_date) %> + (<%= format_time_ago(member.birth_date) %>) + +
          • +
          • + <%= icon("fingerprint") %> + <%= display_value(member.fiscal_code) %> +
          • +
          +
          +
          +
          + + <%# ========================================== %> + <%# COLONNA DESTRA: OPERATIVITÀ %> + <%# ========================================== %> +
          + + <%# Abbonamenti %> +
          +
          +

          + <%= icon("credit_card", classes: "size-4") %> Abbonamenti +

          + <%= link_to "Vedi Tutti", member_subscriptions_path(member), class: "link link-hover text-xs opacity-60" %> +
          + + <% valid_subs = member.subscriptions.select { |s| s.kept? && s.end_date >= Date.current }.sort_by(&:start_date) %> + + <% if valid_subs.any? %> +
            + <% valid_subs.each do |sub| %> + <% + is_future = sub.start_date > Date.current + days_left = (sub.end_date - Date.current).to_i + is_expiring_soon = !is_future && days_left.between?(0, 7) + %> +
          • +
            +
            + <%= icon(is_future ? "clock" : "success") %> +
            +
            +
            +
            <%= display_value(sub.product&.name) %>
            +
            + <% if is_future %> + Inizia il <%= format_date(sub.start_date) %> + <% else %> + Scade il <%= format_date(sub.end_date) %> + <% end %> +
            +
            +
            + <% if is_future %> + <%= ui_badge("Futuro", style: "info") %> + <% else %> + + <%= link_to new_sale_path(member_id: member.id, renew_subscription_id: sub.id), + class: "btn btn-sm btn-square #{is_expiring_soon ? 'btn-primary' : 'btn-ghost'}", + data: { turbo_frame: "modal" }, title: "Rinnova" do %> + <%= icon("reset") %> + <% end %> + <% end %> +
            +
          • + <% end %> +
          + <% else %> +
          +
          + <%= icon("shopping_cart") %> +
          +

          <%= member_empty_subscriptions_badge %>

          +
          + <%= link_to "Vendi Abbonamento", new_sale_path(member_id: member.id), class: "btn btn-primary btn-sm btn-outline", data: { turbo_frame: "modal" } %> +
          +
          + <% end %> +
          + + <%# ACQUISTI RECENTI %> +
          +
          +

          + <%= icon("receipt", classes: "size-4") %> Storico Acquisti +

          + <%= link_to "Vedi Tutti", member_sales_path(member), class: "link link-hover text-xs opacity-60" %> +
          + + <% recent_sales = member.sales.order(created_at: :desc).limit(5) %> + <% if recent_sales.any? %> +
          + + + <% recent_sales.each do |sale| %> + + + + + + <% end %> + +
          <%= format_date(sale.sold_on) %> +
          <%= display_value(sale.product_name_snapshot) %>
          +
          <%= display_value(sale.receipt_code) %>
          +
          + <%# Supporta sia se hai amount sia se hai solo amount_cents %> + <%= format_money(sale.try(:amount) || (sale.amount_cents / 100.0)) %> +
          +
          + <% else %> +
          Nessun movimento registrato.
          + <% end %> +
          + +
          +
          diff --git a/app/views/members/searches/index.html.erb b/app/views/members/searches/index.html.erb index 4ed58eb..c9ed8b4 100644 --- a/app/views/members/searches/index.html.erb +++ b/app/views/members/searches/index.html.erb @@ -14,10 +14,10 @@ <%= member.full_name %>
          -
          - CF: <%= member.fiscal_code.presence || "N/A" %> - - BIRTH: <%= format_date(member.birth_date).presence || "N/A" %> +
          + CF: <%= display_value(member.fiscal_code, placeholder: "N/A") %> + + BIRTH: <%= format_date(member.birth_date) %>
        • <% end %> diff --git a/app/views/members/show.html.erb b/app/views/members/show.html.erb index 6de2d55..0d2e2a2 100644 --- a/app/views/members/show.html.erb +++ b/app/views/members/show.html.erb @@ -2,182 +2,4 @@ <%= render "members/context", member: @member %> -<%# --- 3. CONTENUTO PRINCIPALE (La Grid) --- %> -
          - - <%# -- COLONNA SINISTRA: DATI & STATO -- %> -
          - - <%# Certificato Medico %> -
          -
          -

          - <%= icon("med", classes: "size-4") %> Certificato Medico -

          - - <% if @member.medical_certificate_valid? %> -
          -
          - <%= icon("success") %> Valido -
          -
          -
          Scade il
          -
          <%= format_date(@member.medical_certificate_expiry) %>
          -
          -
          - <% else %> -
          - <%= icon("warning") %> -
          -
          Scaduto o Mancante
          -
          Accesso limitato.
          -
          -
          - <% end %> - -
          - <%= link_to edit_member_path(@member), class: "btn btn-xs btn-outline w-full", data: { turbo_frame: "modal" } do %> - Aggiorna Data - <% end %> -
          -
          - - <%# Anagrafica %> -
          -
          -

          - <%= icon("badge") %> Anagrafica -

          -
            -
          • - <%= icon("phone") %> - <%= @member.phone.presence || "-" %> -
          • -
          • - <%= icon("mail") %> - - <%= format_email(@member.email_address) %> - -
          • -
          • - <%= icon("home") %> - <%= display_value(@member.full_address) %> -
          • -
          • - <%= icon("cake") %> - <%= format_date(@member.birth_date) %> (<%= time_ago_in_words(@member.birth_date) %>) -
          • -
          • - <%= icon("fingerprint") %> - <%= @member.fiscal_code %> -
          • -
          -
          -
          -
          - - <%# -- COLONNA DESTRA: OPERATIVITÀ (2/3 width) -- %> -
          - - <%# Abbonamenti (Utilizzo del componente 'list' nativo di DaisyUI) %> -
          -
          -

          - <%= icon("credit_card") %> Abbonamenti -

          - <%= link_to "Vedi Tutti", member_subscriptions_path(@member), class: "link link-hover text-xs opacity-60" %> -
          - - <% valid_subs = @member.subscriptions.select { |s| s.kept? && s.end_date >= Date.current }.sort_by(&:start_date) %> - - <% if valid_subs.any? %> -
            - <% valid_subs.each do |sub| %> - <% - is_future = sub.start_date > Date.current - days_left = (sub.end_date - Date.current).to_i - is_expiring_soon = !is_future && days_left.between?(0, 7) - %> -
          • -
            -
            - <%= icon(is_future ? "clock" : "success") %> -
            -
            -
            -
            <%= sub.product&.name %>
            -
            - <% if is_future %> - Inizia il <%= l(sub.start_date) %> - <% else %> - Scade il <%= l(sub.end_date) %> - <% end %> -
            -
            -
            - <% if is_future %> - Futuro - <% else %> - - <%= link_to new_sale_path(member_id: @member.id, renew_subscription_id: sub.id), - class: "btn btn-sm btn-square #{is_expiring_soon ? 'btn-primary' : 'btn-ghost'}", - data: { turbo_frame: "modal" }, title: "Rinnova" do %> - <%= icon("reset") %> - <% end %> - <% end %> -
            -
          • - <% end %> -
          - <% else %> -
          -
          - <%= icon("shopping_cart") %> -
          -

          Nessun servizio attivo

          -
          - <%= link_to new_sale_path(member_id: @member.id), class: "btn btn-primary btn-sm btn-outline", data: { turbo_frame: "modal" } do %> - Vendi Abbonamento - <% end %> -
          -
          - <% end %> -
          - - <%# ACQUISTI RECENTI %> -
          -
          -

          - <%= icon("receipt") %> Storico Acquisti -

          - <%= link_to "Vedi Tutti", member_sales_path(@member), class: "link link-hover text-xs opacity-60" %> -
          - - <% recent_sales = @member.sales.order(created_at: :desc).limit(5) %> - <% if recent_sales.any? %> -
          - - - <% recent_sales.each do |sale| %> - - - - - - <% end %> - -
          <%= l(sale.sold_on) %> -
          <%= sale.product_name_snapshot %>
          -
          <%= sale.receipt_code %>
          -
          <%= number_to_currency(sale.try(:amount) || (sale.amount_cents / 100.0)) %>
          -
          - <% else %> -
          Nessun movimento registrato.
          - <% end %> -
          - -
          -
          +<%= render @member %> diff --git a/config/routes.rb b/config/routes.rb index cde9ff4..9b25878 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,7 +66,8 @@ root to: "disciplines#index" resources :disciplines, only: [ :index, :show ] do - resources :access_logs, only: [ :create ] + resources :access_logs, only: [ :create, :destroy ] + resources :member_searches, only: [ :index ] end end end From 8f2012d7777af7c42c8df30f7849b647fc22a630 Mon Sep 17 00:00:00 2001 From: jcostd Date: Sun, 12 Apr 2026 20:49:14 +0200 Subject: [PATCH 23/34] Updated KIOSK UI --- .../controllers/autocomplete_controller.js | 6 +- app/views/kiosk/disciplines/show.html.erb | 38 +++++----- .../kiosk/member_searches/index.html.erb | 45 ++++++------ app/views/kiosk/members/_card.html.erb | 42 +++++------ .../kiosk/members/_checked_in_card.html.erb | 71 ++++++++++--------- 5 files changed, 106 insertions(+), 96 deletions(-) diff --git a/app/javascript/controllers/autocomplete_controller.js b/app/javascript/controllers/autocomplete_controller.js index 1847e01..eed32d1 100644 --- a/app/javascript/controllers/autocomplete_controller.js +++ b/app/javascript/controllers/autocomplete_controller.js @@ -35,11 +35,11 @@ export default class extends Controller { select(event) { event.preventDefault() - const button = event.currentTarget - this.inputTarget.value = button.dataset.name + const item = event.currentTarget + this.inputTarget.value = item.dataset.name if (this.hasHiddenTarget) { - this.hiddenTarget.value = button.dataset.id + this.hiddenTarget.value = item.dataset.id this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true })) } diff --git a/app/views/kiosk/disciplines/show.html.erb b/app/views/kiosk/disciplines/show.html.erb index 6b21d51..894abc8 100644 --- a/app/views/kiosk/disciplines/show.html.erb +++ b/app/views/kiosk/disciplines/show.html.erb @@ -1,7 +1,9 @@ <%# app/views/kiosk/disciplines/show.html.erb %> +<%= turbo_stream_from "access_logs" %> +
          - <%= link_to kiosk_root_path, class: "btn btn-circle btn-ghost bg-base-100 text-base-content/50", data: { turbo_action: "replace" } do %> + <%= link_to kiosk_root_path, class: "btn btn-circle btn-ghost bg-base-100 text-base-content/50" do %> <%= icon("arrow_back", classes: "size-6") %> <% end %>

          <%= @discipline.name %>

          @@ -13,16 +15,16 @@
          - + - + <%# MAGIA 1: dom_id per il target del turbo frame %> +
          @@ -43,9 +45,11 @@ Nessun socio da smarcare.
          <% else %> - <% @pending_members.each do |member| %> - <%= render "kiosk/members/card", member: member, discipline: @discipline %> - <% end %> + <%# MAGIA 2: Rendering della collection! Rails renderizzerà tutte le card in un solo passaggio %> + <%= render partial: "kiosk/members/card", + collection: @pending_members, + as: :member, + locals: { discipline: @discipline } %> <% end %>
        @@ -60,10 +64,10 @@ Nessuno ha ancora fatto check-in.
        <% else %> - <% @today_accesses.each do |log| %> - <%# ATTENZIONE: Qui passiamo il LOG, non il member, per avere il design verde/rosso %> - <%= render "kiosk/members/checked_in_card", log: log %> - <% end %> + <%# MAGIA 2: Di nuovo collection rendering per i log %> + <%= render partial: "kiosk/members/checked_in_card", + collection: @today_accesses, + as: :log %> <% end %>
        diff --git a/app/views/kiosk/member_searches/index.html.erb b/app/views/kiosk/member_searches/index.html.erb index 3bce0b3..e362a42 100644 --- a/app/views/kiosk/member_searches/index.html.erb +++ b/app/views/kiosk/member_searches/index.html.erb @@ -1,30 +1,33 @@ <%# app/views/kiosk/member_searches/index.html.erb %> - + <% if @members.any? %>
          <% @members.each do |member| %> -
        • - -
          - <%= member.full_name %> - - <% unless member.medical_certificate_valid? %> - <%= icon("warning", classes: "size-3 inline") %> Cert. Scad. - <% end %> -
          - -
          - CF: <%= display_value(member.fiscal_code, placeholder: "N/A") %> - - BIRTH: <%= format_date(member.birth_date) %> -
          +
        • + + <%# IL VERO MODO RAILS: Un form POST nativo che dice a Turbo di aggiornare la pagina (_top) %> + <%= button_to kiosk_discipline_access_logs_path(@discipline, member_id: member.id), + method: :post, + class: "w-full text-left flex flex-col justify-center gap-1.5 px-4 py-3 hover:bg-base-200/60 transition-colors cursor-pointer", + data: { turbo_frame: "_top" } do %> + +
          + <%= member.full_name %> + + <% unless member.medical_certificate_valid? %> + <%= icon("warning", classes: "size-3 inline") %> Cert. Scad. + <% end %> +
          + +
          + CF: <%= display_value(member.fiscal_code, placeholder: "N/A") %> + + BIRTH: <%= format_date(member.birth_date) %> +
          + + <% end %>
        • <% end %> diff --git a/app/views/kiosk/members/_card.html.erb b/app/views/kiosk/members/_card.html.erb index 79d2caa..1580af9 100644 --- a/app/views/kiosk/members/_card.html.erb +++ b/app/views/kiosk/members/_card.html.erb @@ -1,26 +1,28 @@ <%# app/views/kiosk/members/_card.html.erb %> -
          -
          -
          - <%= ui_avatar(member, size: "size-12", text_size: "text-md") %> -
          -

          <%= member.full_name %>

          - <% if !member.medical_certificate_valid? %> - - <%= icon("warning", classes: "size-3") %> Cert. Scaduto - - <% end %> +<% cache [member, discipline] do %> +
          +
          + +
          + <%= ui_avatar(member, size: "size-12", text_size: "text-md") %> +
          +

          <%= member.full_name %>

          + <% if !member.medical_certificate_valid? %> + + <%= icon("warning", classes: "size-3") %> Cert. Scaduto + + <% end %> +
          -
          - <%# IL BOTTONE DI CHECK-IN (Invia la POST ad AccessLogsController) %> - <%= button_to kiosk_discipline_access_logs_path(discipline), - params: { member_id: member.id }, - class: "btn btn-circle btn-primary btn-lg shadow-md", - data: { turbo: true } do %> - <%= icon("check", classes: "size-8") %> - <% end %> + <%= button_to kiosk_discipline_access_logs_path(discipline), + params: { member_id: member.id }, + class: "btn btn-circle btn-primary btn-lg shadow-md", + data: { turbo: true } do %> + <%= icon("check", classes: "size-8") %> + <% end %> +
          -
          +<% end %> diff --git a/app/views/kiosk/members/_checked_in_card.html.erb b/app/views/kiosk/members/_checked_in_card.html.erb index 5eecb02..fcaaf06 100644 --- a/app/views/kiosk/members/_checked_in_card.html.erb +++ b/app/views/kiosk/members/_checked_in_card.html.erb @@ -1,44 +1,45 @@ <%# app/views/kiosk/members/_checked_in_card.html.erb %> <%# locals: (log:) %> -<% - member = log.member - is_warning = log.status != "ok" - bg_class = is_warning ? "bg-warning/10 border-warning/50 text-warning-content" : "bg-success/10 border-success/50 text-success-content" -%> - -
          -
          - -
          - <%= ui_avatar(member, size: "size-12", text_size: "text-md") %> - -
          -

          <%= member.full_name %>

          - -
          - - <%= icon("clock", classes: "size-3") %> - <%= log.entered_at.strftime("%H:%M") %> - - - <% if is_warning %> - - <%= icon("warning", classes: "size-3") %> Forzato +<% cache log do %> + <% + member = log.member + is_warning = log.status != "ok" + bg_class = is_warning ? "bg-warning/10 border-warning/50 text-warning-content" : "bg-success/10 border-success/50 text-success-content" + %> + +
          +
          + +
          + <%= ui_avatar(member, size: "size-12", text_size: "text-md") %> + +
          +

          <%= member.full_name %>

          + +
          + + <%= icon("clock", classes: "size-3") %> + <%= log.entered_at.strftime("%H:%M") %> - <% end %> + + <% if is_warning %> + + <%= icon("warning", classes: "size-3") %> Forzato + + <% end %> +
          -
          - <%# IL BOTTONE DI ANNULLAMENTO (UNDO) %> - <%= button_to kiosk_discipline_access_log_path(log.discipline_id, log), - method: :delete, - class: "btn btn-circle btn-ghost hover:bg-error/20 hover:text-error transition-colors", - title: "Annulla ingresso", - data: { turbo: true } do %> - <%= icon("close", classes: "size-8") %> - <% end %> + <%= button_to kiosk_discipline_access_log_path(log.discipline_id, log), + method: :delete, + class: "btn btn-circle btn-ghost hover:bg-error/20 hover:text-error transition-colors", + title: "Annulla ingresso", + data: { turbo: true } do %> + <%= icon("close", classes: "size-8") %> + <% end %> +
          -
          +<% end %> From 4114d1b7df1fde5897f535058c4bceba6dae8abf Mon Sep 17 00:00:00 2001 From: jcostd Date: Mon, 13 Apr 2026 13:41:15 +0200 Subject: [PATCH 24/34] Updated Product groups in POS terminal --- app/helpers/sales_helper.rb | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/helpers/sales_helper.rb b/app/helpers/sales_helper.rb index 5096249..9861e55 100644 --- a/app/helpers/sales_helper.rb +++ b/app/helpers/sales_helper.rb @@ -7,11 +7,18 @@ module SalesHelper }.freeze def grouped_product_options - Discipline.kept.order(:name).includes(:products).map do |discipline| + discipline_groups = Discipline.kept.order(:name).includes(:products).map do |discipline| active_products = discipline.products.kept.order(:name).pluck(:name, :id) - [ discipline.name, active_products ] end.reject { |_, products| products.empty? } + + generic_products = Product.kept.where.missing(:disciplines).order(:name).pluck(:name, :id) + + if generic_products.any? + discipline_groups.unshift([ "Quote e Varie", generic_products ]) + end + + discipline_groups end # PER I FORM: f.select :payment_method, payment_method_options From 56ab42d90f74685dbc4a939b1765dba7d3f424da Mon Sep 17 00:00:00 2001 From: jcostd Date: Mon, 13 Apr 2026 14:42:48 +0200 Subject: [PATCH 25/34] Updated Kiosk UI --- .../kiosk/disciplines/_discipline.html.erb | 16 ++++ app/views/kiosk/disciplines/index.html.erb | 29 ++----- app/views/kiosk/disciplines/show.html.erb | 84 ++++++++----------- app/views/layouts/kiosk.html.erb | 32 ++++--- 4 files changed, 72 insertions(+), 89 deletions(-) create mode 100644 app/views/kiosk/disciplines/_discipline.html.erb diff --git a/app/views/kiosk/disciplines/_discipline.html.erb b/app/views/kiosk/disciplines/_discipline.html.erb new file mode 100644 index 0000000..06ed65a --- /dev/null +++ b/app/views/kiosk/disciplines/_discipline.html.erb @@ -0,0 +1,16 @@ +<% cache discipline do %> + <%= link_to kiosk_discipline_path(discipline), + class: "card bg-base-100 border-2 border-base-300 active:border-primary active:bg-primary/5 active:scale-95 transition-all duration-200 ease-out shadow-sm touch-manipulation rounded-box group" do %> + +
          + + <%= icon("group", classes: "size-16 md:size-20 text-base-content/20 group-active:text-primary transition-colors duration-200") %> + +

          + <%= discipline.name %> +

          + +
          + + <% end %> +<% end %> diff --git a/app/views/kiosk/disciplines/index.html.erb b/app/views/kiosk/disciplines/index.html.erb index a4e7ded..0663425 100644 --- a/app/views/kiosk/disciplines/index.html.erb +++ b/app/views/kiosk/disciplines/index.html.erb @@ -1,29 +1,12 @@ -
          +
          -
          -

          Seleziona il Corso

          -

          Tocca il corso che stai per iniziare per fare l'appello.

          +
          +

          Seleziona il Corso

          +

          Tocca la disciplina per fare l'appello.

          -
          - <% @disciplines.each do |discipline| %> - <%= link_to kiosk_discipline_path(discipline), - class: "card bg-base-100 hover:bg-primary hover:text-primary-content transition-all duration-200 shadow-sm hover:shadow-xl hover:-translate-y-1 group border border-base-200" do %> - -
          - <%# Cambia l'icona in base al nome o mettine una generica %> -
          - <%= icon("sports_gymnastics", classes: "size-16") %> -
          - -

          <%= discipline.name %>

          - -
          - Inizia Appello <%= icon("arrow_forward") %> -
          -
          - <% end %> - <% end %> +
          + <%= render @disciplines %>
          diff --git a/app/views/kiosk/disciplines/show.html.erb b/app/views/kiosk/disciplines/show.html.erb index 894abc8..a45598c 100644 --- a/app/views/kiosk/disciplines/show.html.erb +++ b/app/views/kiosk/disciplines/show.html.erb @@ -1,73 +1,59 @@ -<%# app/views/kiosk/disciplines/show.html.erb %> - <%= turbo_stream_from "access_logs" %> -
          - <%= link_to kiosk_root_path, class: "btn btn-circle btn-ghost bg-base-100 text-base-content/50" do %> - <%= icon("arrow_back", classes: "size-6") %> +
          + <%= link_to kiosk_root_path, class: "btn btn-circle btn-ghost" do %> + <%= icon("arrow_left", classes: "size-6") %> <% end %>

          <%= @discipline.name %>

          -<%# LA RICERCA A TUTTA LARGHEZZA IN ALTO %> -
          -
          -
          - -
          - - - <%# MAGIA 1: dom_id per il target del turbo frame %> - -
          - -
          -
          +<%# RICERCA A TUTTA LARGHEZZA MINIMAL %> +
          + + +
          -<%# LO SPLIT A DUE COLONNE %> -
          +<%# SPLIT A DUE COLONNE %> +
          - <%# COLONNA SINISTRA: DA SMARCARE %> -
          -

          +
          +
          Da Smarcare (<%= @pending_members.size %>) -

          +
          <% if @pending_members.empty? %> -
          - Nessun socio da smarcare. -
          + <%= render "shared/empty_state", + icon_name: "check", + title: "Tutti presenti!", + message: "Hai smarcato tutti i soci previsti per questo corso." %> <% else %> - <%# MAGIA 2: Rendering della collection! Rails renderizzerà tutte le card in un solo passaggio %> - <%= render partial: "kiosk/members/card", - collection: @pending_members, - as: :member, - locals: { discipline: @discipline } %> + <%= render partial: "kiosk/members/card", collection: @pending_members, as: :member, locals: { discipline: @discipline } %> <% end %>
          <%# COLONNA DESTRA: PRESENTI IN SALA %> -
          -

          +
          +
          Oggi in Sala (<%= @today_accesses.size %>) -

          +
          <% if @today_accesses.empty? %> -
          - Nessuno ha ancora fatto check-in. -
          + <%= render "shared/empty_state", + icon_name: "clock", + title: "Nessuno in sala.", + message: "In attesa dei primi check-in..." %> <% else %> - <%# MAGIA 2: Di nuovo collection rendering per i log %> - <%= render partial: "kiosk/members/checked_in_card", - collection: @today_accesses, - as: :log %> + <%= render partial: "kiosk/members/checked_in_card", collection: @today_accesses, as: :log %> <% end %>
          diff --git a/app/views/layouts/kiosk.html.erb b/app/views/layouts/kiosk.html.erb index 5a2a5dd..8b39bd4 100644 --- a/app/views/layouts/kiosk.html.erb +++ b/app/views/layouts/kiosk.html.erb @@ -8,7 +8,6 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> - <%= yield :head %> @@ -16,39 +15,38 @@ <%= turbo_refreshes_with method: :morph, scroll: :preserve %> - - <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> - + - <%# HEADER MINIMALE KIOSK %> -
          -
          - <%= icon("fitness_center", classes: "size-10 text-primary") %> + <%# NAVBAR NATIVA DAISYUI %> +
          +
          <%# CONTENITORE PRINCIPALE %> -
          +
          <%= yield %>
          - <%# FLASH MESSAGES FLOTTANTI %> -
          + <%# TOAST NATIVO DAISYUI PER I FLASH (iPad friendly, non copre bottoni in basso) %> +
          <%= render "shared/flash" %>
          From 8d144dbb64fbe2facd004dc88388f05a9b2ff98e Mon Sep 17 00:00:00 2001 From: jcostd Date: Fri, 17 Apr 2026 22:15:30 +0200 Subject: [PATCH 26/34] Big UI update --- Gemfile.lock | 48 ++-- .../disciplines/members_controller.rb | 2 +- .../members/access_logs_controller.rb | 10 +- app/controllers/members/sales_controller.rb | 6 +- .../members/subscriptions_controller.rb | 2 +- app/controllers/members_controller.rb | 2 +- app/controllers/subscriptions_controller.rb | 12 +- app/helpers/access_logs_helper.rb | 32 +++ app/helpers/sales_helper.rb | 28 ++- app/helpers/subscriptions_helper.rb | 83 +++++++ app/models/discipline.rb | 8 +- app/models/member.rb | 10 +- app/queries/application_query.rb | 8 +- app/queries/discipline_subscriptions_query.rb | 60 ++++- app/queries/sales_query.rb | 29 ++- .../dashboard/_expiring_subscription.html.erb | 16 ++ app/views/dashboard/_recent_access.html.erb | 25 ++ app/views/dashboard/index.html.erb | 147 ++++-------- app/views/disciplines/_context.html.erb | 55 ++--- app/views/disciplines/_discipline.html.erb | 108 +++++++++ .../disciplines/_discipline_row.html.erb | 41 ---- app/views/disciplines/_row.html.erb | 47 ++++ app/views/disciplines/_shell.html.erb | 83 ------- app/views/disciplines/index.html.erb | 4 +- app/views/disciplines/members/_row.html.erb | 55 +++++ .../members/_subscription_row.html.erb | 47 ---- app/views/disciplines/members/index.html.erb | 169 ++++++++------ app/views/disciplines/show.html.erb | 113 +-------- app/views/members/access_logs/_row.html.erb | 46 ++++ app/views/members/access_logs/index.html.erb | 46 ++-- app/views/members/index.html.erb | 2 +- app/views/members/sales/_row.html.erb | 82 +++---- app/views/members/sales/index.html.erb | 109 +++++++-- app/views/members/subscriptions/_row.html.erb | 65 ++++++ .../subscriptions/_subscription_row.html.erb | 74 ------ .../members/subscriptions/index.html.erb | 29 +-- app/views/products/_context.html.erb | 6 +- app/views/products/_form.html.erb | 79 +++---- app/views/products/_product.html.erb | 106 +++++++++ app/views/products/index.html.erb | 2 +- app/views/products/show.html.erb | 106 +-------- app/views/sales/_context.html.erb | 14 +- app/views/sales/index.html.erb | 2 +- app/views/sales/show.html.erb | 220 +++++++----------- app/views/subscriptions/_form.html.erb | 154 +++--------- app/views/subscriptions/edit.html.erb | 6 +- app/views/users/index.html.erb | 4 +- 47 files changed, 1253 insertions(+), 1149 deletions(-) create mode 100644 app/helpers/access_logs_helper.rb create mode 100644 app/helpers/subscriptions_helper.rb create mode 100644 app/views/dashboard/_expiring_subscription.html.erb create mode 100644 app/views/dashboard/_recent_access.html.erb create mode 100644 app/views/disciplines/_discipline.html.erb delete mode 100644 app/views/disciplines/_discipline_row.html.erb create mode 100644 app/views/disciplines/_row.html.erb delete mode 100644 app/views/disciplines/_shell.html.erb create mode 100644 app/views/disciplines/members/_row.html.erb delete mode 100644 app/views/disciplines/members/_subscription_row.html.erb create mode 100644 app/views/members/access_logs/_row.html.erb create mode 100644 app/views/members/subscriptions/_row.html.erb delete mode 100644 app/views/members/subscriptions/_subscription_row.html.erb create mode 100644 app/views/products/_product.html.erb diff --git a/Gemfile.lock b/Gemfile.lock index e3d438f..af47595 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -110,7 +110,7 @@ GEM dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) - erb (6.0.2) + erb (6.0.3) erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -173,7 +173,7 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (6.0.3) + minitest (6.0.4) drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) @@ -207,7 +207,7 @@ GEM nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) ostruct (0.6.3) - pagy (43.5.0) + pagy (43.5.1) json uri yaml @@ -227,7 +227,7 @@ GEM prawn (>= 1.3.0, < 3.0.0) prettyprint (0.2.0) prism (1.9.0) - propshaft (1.3.1) + propshaft (1.3.2) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack @@ -281,7 +281,7 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.1) + rake (13.4.2) rdoc (7.2.0) erb psych (>= 4.0.0) @@ -346,13 +346,13 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.9.2-aarch64-linux-gnu) - sqlite3 (2.9.2-aarch64-linux-musl) - sqlite3 (2.9.2-arm-linux-gnu) - sqlite3 (2.9.2-arm-linux-musl) - sqlite3 (2.9.2-arm64-darwin) - sqlite3 (2.9.2-x86_64-linux-gnu) - sqlite3 (2.9.2-x86_64-linux-musl) + sqlite3 (2.9.3-aarch64-linux-gnu) + sqlite3 (2.9.3-aarch64-linux-musl) + sqlite3 (2.9.3-arm-linux-gnu) + sqlite3 (2.9.3-arm-linux-musl) + sqlite3 (2.9.3-arm64-darwin) + sqlite3 (2.9.3-x86_64-linux-gnu) + sqlite3 (2.9.3-x86_64-linux-musl) sshkit (1.25.0) base64 logger @@ -481,7 +481,7 @@ CHECKSUMS dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 - erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b + erb (6.0.3) sha256=e43685a8a0a0ea6a924871b2162e8953ef73147ce46b75b36d1f6774fd286e91 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df @@ -510,7 +510,7 @@ CHECKSUMS matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef - minitest (6.0.3) sha256=88ac8a1de36c00692420e7cb3cc11a0773bbcb126aee1c249f320160a7d11411 + minitest (6.0.4) sha256=df1304664589d40f46089247fdc451f866b0ce0d7cae1457a15fc1eb7d48dca1 msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 @@ -528,7 +528,7 @@ CHECKSUMS nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8 ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 - pagy (43.5.0) sha256=58885d5f659e8db5b92cf35eeba674113e4e7bda12649b603c2d6908402570a4 + pagy (43.5.1) sha256=ca5aaa6d65d21eee67a48fe8801d022d07ee72afbc5bea6a9e21b13a27b7c0b9 parallel (2.0.1) sha256=337782d3e39f4121e67563bf91dd8ece67f48923d90698614773a0ec9a5b2c7d parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 pdf-core (0.10.0) sha256=0a5d101e2063c01e3f941e1ee47cbb97f1adfc1395b58372f4f65f1300f3ce91 @@ -538,7 +538,7 @@ CHECKSUMS prawn-table (0.2.2) sha256=336d46e39e003f77bf973337a958af6a68300b941c85cb22288872dc2b36addb prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 - propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puma (8.0.0) sha256=1681050b8b60fab1d3033255ab58b6aec64cd063e43fc6f8204bcb8bf9364b88 @@ -554,7 +554,7 @@ CHECKSUMS rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3 railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a - rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 @@ -572,13 +572,13 @@ CHECKSUMS solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a - sqlite3 (2.9.2-aarch64-linux-gnu) sha256=eeb86db55645b85327ba75129e3614658d974bf4da8fdc87018a0d42c59f6e42 - sqlite3 (2.9.2-aarch64-linux-musl) sha256=4feff91fb8c2b13688da34b5627c9d1ed9cedb3ee87a7114ec82209147f07a6d - sqlite3 (2.9.2-arm-linux-gnu) sha256=1ee2eb06b5301aaf5ce343a6e88d99ac932d95202d7b350f0e7b6d8d588580d7 - sqlite3 (2.9.2-arm-linux-musl) sha256=8ca0de6aceede968de0394e22e95d549834c4d8e318f69a92a52f049878a0057 - sqlite3 (2.9.2-arm64-darwin) sha256=d15bd9609a05f9d54930babe039585efc8cadd57517c15b64ec7dfa75158a5e9 - sqlite3 (2.9.2-x86_64-linux-gnu) sha256=dce83ffcb7e72f9f7aeb6e5404f15d277a45332fe18ccce8a8b3ed51e8d23aee - sqlite3 (2.9.2-x86_64-linux-musl) sha256=e8dd906a613f13b60f6d47ae9dda376384d9de1ab3f7e3f2fdf2fd18a871a2d7 + sqlite3 (2.9.3-aarch64-linux-gnu) sha256=ca6dd1cf6c037ccc8d3e5837190cc61ef15466092014951235641b5c4c8ab4ee + sqlite3 (2.9.3-aarch64-linux-musl) sha256=ff017a36c463d02e9f0be7a6224521371128024e6a05ed16994afa5c037afbba + sqlite3 (2.9.3-arm-linux-gnu) sha256=fd8b74337a66bdaf746b97d65e6c9a2faff803c8f72d6b107fb880972815d072 + sqlite3 (2.9.3-arm-linux-musl) sha256=792ae9a786bb37dbdc4c443c527bc91df423aac10e472f76d5cf5a9ac6d51980 + sqlite3 (2.9.3-arm64-darwin) sha256=76b265d3d57362d3e38338f24f50a0c9cd47a4599c9cfbb578fac125d2299906 + sqlite3 (2.9.3-x86_64-linux-gnu) sha256=85200a10c6cf5c60085fcca411a3168c5fba8fda3e2b1b0109ec277d7c226d46 + sqlite3 (2.9.3-x86_64-linux-musl) sha256=b6d0437046d9180335dea1aa0592802e65c4f7b57409d63f14408211bf28536b sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 diff --git a/app/controllers/disciplines/members_controller.rb b/app/controllers/disciplines/members_controller.rb index bcc0127..0e25d0f 100644 --- a/app/controllers/disciplines/members_controller.rb +++ b/app/controllers/disciplines/members_controller.rb @@ -6,7 +6,7 @@ class Disciplines::MembersController < ApplicationController def index @products = @discipline.products.kept - query = DisciplineSubscriptionsQuery.new(params, @discipline.recent_subscriptions) + query = DisciplineSubscriptionsQuery.new(filter_params, @discipline.recent_subscriptions) @pagy, @subscriptions = pagy(query.results) end diff --git a/app/controllers/members/access_logs_controller.rb b/app/controllers/members/access_logs_controller.rb index bd75939..b682ce7 100644 --- a/app/controllers/members/access_logs_controller.rb +++ b/app/controllers/members/access_logs_controller.rb @@ -1,14 +1,12 @@ class Members::AccessLogsController < ApplicationController before_action :set_member - # TODO def index - @access_logs = @member.access_logs.order(created_at: :desc).limit(100) + @pagy, @access_logs = pagy(@member.access_logs.order(created_at: :desc)) end private - - def set_member - @member = Member.find(params[:member_id]) - end + def set_member + @member = Member.find(params[:member_id]) + end end diff --git a/app/controllers/members/sales_controller.rb b/app/controllers/members/sales_controller.rb index 09c9458..2acce6f 100644 --- a/app/controllers/members/sales_controller.rb +++ b/app/controllers/members/sales_controller.rb @@ -1,9 +1,11 @@ -class Members::SalesController < ApplicationController +class Members::SalesController < MembersController before_action :require_admin before_action :set_member def index - query = @member.sales.kept.includes(:product, :user).order(sold_on: :desc, created_at: :desc) + base_scope = @member.sales.includes(:product, :user) + query = SalesQuery.new(params, base_scope).results + @pagy, @sales = pagy(query) end diff --git a/app/controllers/members/subscriptions_controller.rb b/app/controllers/members/subscriptions_controller.rb index c331a30..7207230 100644 --- a/app/controllers/members/subscriptions_controller.rb +++ b/app/controllers/members/subscriptions_controller.rb @@ -2,7 +2,7 @@ class Members::SubscriptionsController < ApplicationController before_action :set_member def index - query = @member.subscriptions.kept.includes(:product, :sale).order(end_date: :desc) + query = @member.subscriptions.kept.includes(:product, :sales).order(end_date: :desc) @pagy, @subscriptions = pagy(query) end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index b00f721..a8fdb2f 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -10,7 +10,7 @@ def index @pagy, @members = pagy( MembersQuery.new(filter_params) .results - .includes(subscriptions: :product)) + .includes(subscriptions: [ :product, :sales ])) end def show diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index e0b4082..027671d 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -1,6 +1,10 @@ class SubscriptionsController < ApplicationController + before_action :require_admin, only: [ :edit, :update ] before_action :set_subscription, only: [ :edit, :update, :destroy ] + layout "modal", only: [ :edit, :update ] + + def index @subscriptions = Subscription.kept.includes(:member, :product) @@ -37,12 +41,6 @@ def set_subscription end def subscription_params - permitted = [ :start_date ] - - if current_user.respond_to?(:admin?) && current_user.admin? - permitted << :end_date - end - - params.require(:subscription).permit(permitted) + params.require(:subscription).permit([ :start_date, :end_date ]) end end diff --git a/app/helpers/access_logs_helper.rb b/app/helpers/access_logs_helper.rb new file mode 100644 index 0000000..f2e88b8 --- /dev/null +++ b/app/helpers/access_logs_helper.rb @@ -0,0 +1,32 @@ +module AccessLogsHelper + def access_log_status_color(status) + case status.to_sym + when :ok then "success" + when :warning then "warning" + when :error then "error" + else "base-content" + end + end + + def access_log_status_icon(status) + case status.to_sym + when :ok then "success" + when :warning then "warning" + when :error then "error" + else "help" + end + end + + def access_log_status_label(status) + case status.to_sym + when :ok then "Consentito" + when :warning then "Avviso" + when :error then "Negato" + else "Sconosciuto" + end + end + + def access_log_activity_name(log) + log.discipline&.name || "Accesso Generico" + end +end diff --git a/app/helpers/sales_helper.rb b/app/helpers/sales_helper.rb index 9861e55..276d14f 100644 --- a/app/helpers/sales_helper.rb +++ b/app/helpers/sales_helper.rb @@ -7,18 +7,30 @@ module SalesHelper }.freeze def grouped_product_options - discipline_groups = Discipline.kept.order(:name).includes(:products).map do |discipline| - active_products = discipline.products.kept.order(:name).pluck(:name, :id) - [ discipline.name, active_products ] - end.reject { |_, products| products.empty? } + products = Product.kept.order(:name).includes(:disciplines) - generic_products = Product.kept.where.missing(:disciplines).order(:name).pluck(:name, :id) + groups = Hash.new { |h, k| h[k] = [] } + uncategorized = [] - if generic_products.any? - discipline_groups.unshift([ "Quote e Varie", generic_products ]) + products.each do |product| + active_disciplines = product.disciplines.reject(&:discarded?) + + if active_disciplines.any? + active_disciplines.each do |discipline| + groups[discipline.name] << [product.name, product.id] + end + else + uncategorized << [product.name, product.id] + end + end + + result = groups.sort.map { |discipline_name, product_list| [discipline_name, product_list] } + + if uncategorized.any? + result.unshift(["Quote e Varie", uncategorized]) end - discipline_groups + result end # PER I FORM: f.select :payment_method, payment_method_options diff --git a/app/helpers/subscriptions_helper.rb b/app/helpers/subscriptions_helper.rb new file mode 100644 index 0000000..3fd2453 --- /dev/null +++ b/app/helpers/subscriptions_helper.rb @@ -0,0 +1,83 @@ +module SubscriptionsHelper + STATUS_I18N = { + active: { label: "Attivo", icon: "success" }, + expired: { label: "Scaduto", icon: "error" }, + expiring_soon: { label: "In Scadenza", icon: "warning" }, + future: { label: "Futuro", icon: "clock" }, + pending_payment: { label: "Da Saldare", icon: "payments" } + }.with_indifferent_access.freeze + + def subscription_status_label(status_key) + STATUS_I18N.dig(status_key, :label) || status_key.to_s.humanize + end + + def subscription_status_icon(status_key) + STATUS_I18N.dig(status_key, :icon) || "help" + end + + # Wrapper per gestire le classi CSS condizionali (es. grigio se scaduto) + def subscription_row_wrapper(subscription, status, &block) + classes = ["list-row", "hover:bg-base-200/50", "transition-colors"] + classes << "opacity-60 grayscale" if status.key.to_sym == :expired + + content_tag(:li, id: dom_id(subscription), class: classes, &block) + end + + def subscription_payment_badge(subscription, amount_due) + return if subscription.agreed_price_cents.to_i <= 0 + + if amount_due > 0 + content_tag(:span, "Da saldare", class: "badge badge-sm badge-soft badge-warning") + else + content_tag(:span, "Saldato", class: "badge badge-sm badge-soft badge-success") + end + end + + def subscription_days_left_indicator(subscription, status) + return if status.key.to_sym == :expired + + days = (subscription.end_date - Date.current).to_i + safe_join([ + content_tag(:span, "|", class: "opacity-30 mx-0.5"), + content_tag(:span, "#{days} gg rimasti", class: "font-mono") + ]) + end + + def subscription_sale_receipt_link(sale) + return unless sale.receipt_code.present? + + safe_join([ + content_tag(:span, "•", class: "opacity-50 mx-1"), + link_to(sale.receipt_code, [sale], class: "link link-hover text-primary font-medium", data: { turbo_frame: "_top" }) + ]) + end + + def subscription_installment_action(subscription, amount_due) + return unless amount_due > 0 + + content_tag(:div, class: "flex items-center justify-between w-full max-w-sm mt-1 bg-warning/10 text-warning px-2 py-1.5 rounded-box") do + concat content_tag(:span, "Resta: #{format_cents(amount_due)}", class: "text-xs font-bold") + concat link_to(new_sale_path(member_id: subscription.member_id, installment_for_subscription_id: subscription.id), + class: "btn btn-xs btn-warning btn-soft rounded-full", + data: { turbo_frame: "modal" }, title: "Incassa Rata") { + safe_join([icon("payments", classes: "size-3"), " Incassa"]) + } + end + end + + def subscription_renew_action(subscription, status) + return unless [:expired, :expiring_soon].include?(status.key.to_sym) + + link_to new_sale_path(member_id: subscription.member_id, renew_subscription_id: subscription.id), + class: "btn btn-square btn-sm btn-ghost text-info", + data: { turbo_frame: "modal" }, title: "Rinnova Abbonamento" do + icon("reset", classes: "size-5") + end + end + + def subscription_archive_action(subscription) + return unless subscription.end_date >= 7.days.ago.to_date + + ui_row_delete_button([subscription], confirm: "Eliminando l'abbonamento annullerai l'incasso. Continuare?", title: "Archivia") + end +end diff --git a/app/models/discipline.rb b/app/models/discipline.rb index df30be7..9bfef79 100644 --- a/app/models/discipline.rb +++ b/app/models/discipline.rb @@ -4,16 +4,18 @@ class Discipline < ApplicationRecord has_many :product_disciplines, dependent: :destroy has_many :products, through: :product_disciplines + + has_many :subscriptions, through: :products + has_many :access_logs, dependent: :nullify normalizes :name, with: ->(n) { n.squish.titleize } validates :name, presence: true, uniqueness: { conditions: -> { kept } } def recent_subscriptions - Subscription.kept - .where(product_id: product_ids) + subscriptions + .kept .where("end_date >= ?", 30.days.ago) .includes(:member, :product) - .order(end_date: :asc) end end diff --git a/app/models/member.rb b/app/models/member.rb index a25aac6..10d6e5c 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -30,9 +30,13 @@ def compliant?(date = Date.current) end def membership_valid?(date = Date.current) - if memberships.loaded? - expiry = memberships.reject(&:discarded?).filter_map(&:end_date).max - expiry.present? && expiry >= date + if subscriptions.loaded? + subscriptions.any? do |sub| + !sub.discarded? && + sub.product&.accounting_category == Product.accounting_categories[:associative] && + sub.end_date.present? && + sub.end_date >= date + end else memberships.kept.where("end_date >= ?", date).exists? end diff --git a/app/queries/application_query.rb b/app/queries/application_query.rb index bc48907..ab33d91 100644 --- a/app/queries/application_query.rb +++ b/app/queries/application_query.rb @@ -22,11 +22,9 @@ def apply_custom_filters(scope) end def filter_by_state(scope) - if @params[:state] == "discarded" - scope.discarded - else - scope.kept - end + return scope.discarded if @params[:state] == "discarded" + + scope.kept end def filter_by_search(scope) diff --git a/app/queries/discipline_subscriptions_query.rb b/app/queries/discipline_subscriptions_query.rb index 365369c..40cb7eb 100644 --- a/app/queries/discipline_subscriptions_query.rb +++ b/app/queries/discipline_subscriptions_query.rb @@ -4,21 +4,73 @@ def default_relation raise ArgumentError, "Richiesta la relation base (es. @discipline.recent_subscriptions)" end + def filter_by_state(scope) + base_scope = super(scope) + + case @params[:state] + when "active" then base_scope.active + when "expired" then base_scope.expired + when "upcoming" then base_scope.upcoming + else base_scope + end + end + def filter_by_search(scope) return scope if @params[:query].blank? - scope.joins(:member).merge(Member.search_text(@params[:query])) end def apply_custom_filters(scope) - scope - .then { |s| filter_by_product(s) } + # Uniamo la tabella members a prescindere se usiamo il filtro del certificato + s = @params[:med_cert].present? ? scope.joins(:member) : scope + + s.then { |q| filter_by_product(q) } + .then { |q| filter_by_membership_status(q) } + .then { |q| filter_by_med_cert(q) } end def filter_by_product(scope) return scope if @params[:product_id].blank? - scope.where(product_id: @params[:product_id]) + # Specifichiamo la tabella 'subscriptions' per evitare ambiguità SQL + scope.where(subscriptions: { product_id: @params[:product_id] }) + end + + def filter_by_membership_status(scope) + return scope if @params[:membership_status].blank? + + # 1. Troviamo gli ID di chi ha un "Tesseramento" (prodotto associativo) attivo in questo momento + active_member_ids = Subscription.active + .joins(:product) + .where(products: { accounting_category: "associative" }) + .select(:member_id) + + # 2. Filtriamo la query principale basandoci su quegli ID + if @params[:membership_status] == "active" + scope.where(member_id: active_member_ids) + else + scope.where.not(member_id: active_member_ids) + end + end + + def filter_by_med_cert(scope) + return scope if @params[:med_cert].blank? + + today = Date.current + + case @params[:med_cert] + when "valid" + # Scadenza futura o uguale a oggi + scope.where("members.medical_certificate_expiry >= ?", today) + when "expiring" + # Scade nei prossimi 30 giorni + scope.where(members: { medical_certificate_expiry: today..(today + 30.days) }) + when "invalid" + # Scaduto (passato) o mai inserito (NULL) + scope.where("members.medical_certificate_expiry < ? OR members.medical_certificate_expiry IS NULL", today) + else + scope + end end def apply_sorting(scope) diff --git a/app/queries/sales_query.rb b/app/queries/sales_query.rb index 783a2bc..712b909 100644 --- a/app/queries/sales_query.rb +++ b/app/queries/sales_query.rb @@ -5,15 +5,21 @@ def default_relation end def apply_custom_filters(scope) - scope.then { |s| filter_by_payment_method(s) } + scope + .then { |s| filter_by_payment_method(s) } + .then { |s| filter_by_product(s) } end def filter_by_search(scope) return scope if @params[:query].blank? term = "%#{@params[:query]}%" - scope.joins(:member).where( - "CAST(sales.receipt_number AS TEXT) LIKE :q OR members.first_name LIKE :q OR members.last_name LIKE :q", + + scope.joins(:member, :product).where( + "CAST(sales.receipt_number AS TEXT) LIKE :q " \ + "OR members.first_name LIKE :q " \ + "OR members.last_name LIKE :q " \ + "OR products.name LIKE :q", q: term ) end @@ -24,9 +30,22 @@ def filter_by_payment_method(scope) scope.where(payment_method: @params[:payment_method]) end + def filter_by_product(scope) + return scope if @params[:product_id].blank? + + scope.where(product_id: @params[:product_id]) + end + def apply_sorting(scope) - return super if @params[:sort].present? + return scope.order(sold_on: :desc, created_at: :desc) if @params[:sort].blank? - scope.order(sold_on: :desc, created_at: :desc) + case @params[:sort] + when "name_asc" + scope.joins(:product).order("products.name ASC") + when "name_desc" + scope.joins(:product).order("products.name DESC") + else + super + end end end diff --git a/app/views/dashboard/_expiring_subscription.html.erb b/app/views/dashboard/_expiring_subscription.html.erb new file mode 100644 index 0000000..472d485 --- /dev/null +++ b/app/views/dashboard/_expiring_subscription.html.erb @@ -0,0 +1,16 @@ +
        • +
          +
          + <%= link_to sub.member.full_name, sub.member, class: "link link-hover" %> +
          +
          + Scade il <%= sub.end_date.strftime("%d/%m") %> +
          +
          + + <%= link_to new_sale_path(member_id: sub.member_id, renew_subscription_id: sub.id), + data: { turbo_frame: "modal" }, + class: "btn btn-sm btn-outline" do %> + Rinnova + <% end %> +
        • diff --git a/app/views/dashboard/_recent_access.html.erb b/app/views/dashboard/_recent_access.html.erb new file mode 100644 index 0000000..580f808 --- /dev/null +++ b/app/views/dashboard/_recent_access.html.erb @@ -0,0 +1,25 @@ +
        • +
          + <%= link_to log.member, class: "block hover:opacity-80 transition-opacity focus:outline-none focus:ring-2 focus:ring-primary rounded-box" do %> + <%= ui_avatar(log.member, size: "size-10", text_size: "text-md") %> + <% end %> +
          + +
          +
          + <%= link_to log.member.full_name, log.member, class: "block hover:opacity-80 transition-opacity focus:outline-none focus:ring-2 focus:ring-primary rounded-box" %> +
          +
          + <%= log.discipline&.name || 'Sala Pesi / Libero' %> +
          +
          + +
          + <% if log.status == 0 || log.status == 'ok' %> + + <% else %> +
          Check
          + <% end %> + <%= log.entered_at.strftime("%H:%M") %> +
          +
        • diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index dfc35e9..37360fe 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -1,3 +1,7 @@ +<%= turbo_stream_from "access_logs" %> +<%= turbo_stream_from "subscriptions" %> +<%= turbo_stream_from "sales" %> +

          @@ -8,35 +12,32 @@

          - <%= link_to new_sale_path, - class: "btn btn-primary shadow-sm", - data: { turbo_frame: "modal" } do %> + <%= link_to new_sale_path, class: "btn btn-primary", data: { turbo_frame: "modal" } do %> <%= icon("add") %> Nuova Vendita <% end %>
          -<%# RIGA 1: STATISTICHE %> -
          +
          -
          +
          <%= icon("sunny", classes: "size-8") %>
          Cash Mattina
          <%= format_money(@daily_cash.morning_total) %>
          -
          +
          <%= icon("moon", classes: "size-8") %>
          Cash Pomeriggio
          <%= format_money(@daily_cash.afternoon_total) %>
          -
          +
          <%= icon("access", classes: "size-8") %>
          Ingressi Oggi
          <%= @today_accesses_count %>
          -
          +
          <%= icon("notification_important", classes: "size-8") %>
          @@ -49,101 +50,53 @@ <%# RIGA 2: I FEED OPERATIVI %>
          - <%# COLONNA 1: Timeline Ingressi %> -
          -
          -

          - <%= icon("history", classes: "size-5 opacity-50") %> Registro Accessi -

          - <%= link_to "Vedi tutti", access_logs_path, class: "text-xs font-semibold link link-hover text-base-content/60" %> -
          - - <% if @recent_accesses.any? %> -
            - <% @recent_accesses.each do |log| %> -
          • - -
            - <%# Avatar collegato al nostro concern Avatarable %> - - -
            - - <%= link_to log.member.full_name, log.member, class: "link link-hover" %> - - - <%= log.discipline&.name || 'Sala Pesi / Libero' %> - -
            -
            - -
            - <% if log.status == 0 || log.status == 'ok' %> - - <% else %> -
            - <%= icon("warning", classes: "size-3") %> Check -
            - <% end %> - <%= log.entered_at.strftime("%H:%M") %> -
            - -
          • - <% end %> -
          - <% else %> -
          - Nessun ingresso registrato oggi. + <%# COLONNA 1: Timeline Ingressi (Card) %> +
          +
          +
          +

          + <%= icon("history", classes: "size-5 opacity-50") %> Registro Accessi +

          + <%= link_to "Vedi tutti", access_logs_path, class: "text-xs font-semibold link link-hover text-base-content/60" %>
          - <% end %> -
          - <%# COLONNA 2: Scadenze (Task list) %> -
          -

          - <%= icon("event", classes: "size-6 text-warning") %> In Scadenza -

          + <% if @recent_accesses.any? %> + <%# Componente LIST nativo di DaisyUI %> +
            + <%= render partial: "dashboard/recent_access", collection: @recent_accesses, as: :log, cached: true %> +
          + <% else %> +
          + Nessun ingresso registrato oggi. +
          + <% end %> +
          +
          - <% if @expiring_subscriptions.any? %> -
            - <% @expiring_subscriptions.each do |sub| %> + <%# COLONNA 2: Scadenze (Card) %> +
            +
            +

            + <%= icon("event", classes: "size-6 text-warning") %> In Scadenza +

            -
          • -
            -
            - - <%= link_to sub.member.full_name, sub.member, class: "link link-hover" %> - - <%= sub.end_date.strftime("%d/%m") %> -
            + <% if @expiring_subscriptions.any? %> +
              + <%= render partial: "dashboard/expiring_subscription", collection: @expiring_subscriptions, as: :sub, cached: true %> +
            - <%= link_to new_sale_path(member_id: sub.member_id, renew_subscription_id: sub.id), - data: { turbo_frame: "modal" }, - class: "btn btn-sm btn-outline btn-block mt-2" do %> - Rinnova - <% end %> -
            -
          • + <% if @expiring_count > 5 %> +
            + <%= link_to "Mostra altre (#{@expiring_count - 5})", subscriptions_path(filter: 'expiring'), class: "btn btn-ghost btn-sm text-xs font-semibold" %> +
            <% end %> -
          - - <% if @expiring_count > 5 %> -
          - <%= link_to "Mostra altre (#{@expiring_count - 5})", subscriptions_path(filter: 'expiring'), class: "btn btn-ghost btn-sm text-xs font-semibold" %> -
          + <% else %> +
          + <%= icon("success", classes: "size-10 opacity-20") %> + Nessuna urgenza. +
          <% end %> - <% else %> -
          - <%= icon("success", classes: "size-10 opacity-20") %> - Nessuna urgenza. -
          - <% end %> +
          diff --git a/app/views/disciplines/_context.html.erb b/app/views/disciplines/_context.html.erb index 46eb2ad..f1ba667 100644 --- a/app/views/disciplines/_context.html.erb +++ b/app/views/disciplines/_context.html.erb @@ -1,6 +1,6 @@ <% content_for :discipline_actions do %> -
            -
          diff --git a/app/views/disciplines/_row.html.erb b/app/views/disciplines/_row.html.erb index 4862419..468a09a 100644 --- a/app/views/disciplines/_row.html.erb +++ b/app/views/disciplines/_row.html.erb @@ -36,12 +36,11 @@ <%# -- Colonna 3: Azioni -- %> <% unless discipline.discarded? %> -
          - <%= ui_row_edit_button(edit_discipline_path(discipline)) %> - - <% if current_user&.admin? %> + <% if current_user&.admin? %> +
          + <%= ui_row_edit_button(edit_discipline_path(discipline)) %> <%= ui_row_delete_button(discipline, confirm: "Sei sicuro di voler archiviare #{discipline.name}?") %> - <% end %> -
          +
          + <% end %> <% end %> diff --git a/app/views/kiosk/disciplines/show.html.erb b/app/views/kiosk/disciplines/show.html.erb index a45598c..b7d8c88 100644 --- a/app/views/kiosk/disciplines/show.html.erb +++ b/app/views/kiosk/disciplines/show.html.erb @@ -12,6 +12,7 @@
          -
          - <%# Certificato Medico %>
          <%= f.label :medical_certificate_expiry, "Data Scadenza Certificato Medico", class: "label" %> diff --git a/app/views/members/_member.html.erb b/app/views/members/_member.html.erb index c3be7f6..a2bcfc1 100644 --- a/app/views/members/_member.html.erb +++ b/app/views/members/_member.html.erb @@ -134,40 +134,5 @@ <% end %>
          - <%# ACQUISTI RECENTI %> -
          -
          -

          - <%= icon("receipt", classes: "size-4") %> Storico Acquisti -

          - <%= link_to "Vedi Tutti", member_sales_path(member), class: "link link-hover text-xs opacity-60" %> -
          - - <% recent_sales = member.sales.order(created_at: :desc).limit(5) %> - <% if recent_sales.any? %> -
          - - - <% recent_sales.each do |sale| %> - - - - - - <% end %> - -
          <%= format_date(sale.sold_on) %> -
          <%= display_value(sale.product_name_snapshot) %>
          -
          <%= display_value(sale.receipt_code) %>
          -
          - <%# Supporta sia se hai amount sia se hai solo amount_cents %> - <%= format_money(sale.try(:amount) || (sale.amount_cents / 100.0)) %> -
          -
          - <% else %> -
          Nessun movimento registrato.
          - <% end %> -
          -
          diff --git a/app/views/members/_row.html.erb b/app/views/members/_row.html.erb index 897d832..4e8802e 100644 --- a/app/views/members/_row.html.erb +++ b/app/views/members/_row.html.erb @@ -34,12 +34,14 @@ <%# -- Colonna 3: Azioni -- %> <% unless member.discarded? %> -
          - <%= ui_row_edit_button(edit_member_path(member)) %> - - <% if current_user&.admin? %> - <%= ui_row_delete_button(member, confirm: "Sei sicuro di voler archiviare questo socio?") %> - <% end %> -
          + <% if current_user.admin? %> +
          + <%= ui_row_edit_button(edit_member_path(member)) %> + + <% if current_user&.admin? %> + <%= ui_row_delete_button(member, confirm: "Sei sicuro di voler archiviare questo socio?") %> + <% end %> +
          + <% end %> <% end %> diff --git a/app/views/members/index.html.erb b/app/views/members/index.html.erb index 3fc0da4..be45aa5 100644 --- a/app/views/members/index.html.erb +++ b/app/views/members/index.html.erb @@ -96,7 +96,7 @@ <%= filtered_results_counter(@pagy) %>
            - <%= render partial: "members/row", collection: @members, as: :member, cached: true %> + <%= render partial: "members/row", collection: @members, as: :member %>
          <%= render "shared/pagination" %> diff --git a/app/views/receipt_counters/edit.html.erb b/app/views/receipt_counters/edit.html.erb deleted file mode 100644 index f336097..0000000 --- a/app/views/receipt_counters/edit.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -
          -

          ReceiptCounters#edit

          -

          Find me in app/views/receipt_counters/edit.html.erb

          -
          diff --git a/app/views/receipt_counters/index.html.erb b/app/views/receipt_counters/index.html.erb deleted file mode 100644 index 05e8b66..0000000 --- a/app/views/receipt_counters/index.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -
          -

          ReceiptCounters#index

          -

          Find me in app/views/receipt_counters/index.html.erb

          -
          diff --git a/app/views/receipt_counters/update.html.erb b/app/views/receipt_counters/update.html.erb deleted file mode 100644 index aeb94e9..0000000 --- a/app/views/receipt_counters/update.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -
          -

          ReceiptCounters#update

          -

          Find me in app/views/receipt_counters/update.html.erb

          -
          diff --git a/app/views/reports/_row.html.erb b/app/views/reports/_row.html.erb new file mode 100644 index 0000000..0faf159 --- /dev/null +++ b/app/views/reports/_row.html.erb @@ -0,0 +1,49 @@ +<%# locals: (report:) %> + +
        • + +
          +
          + <%= icon("calendar_today") %> +
          +
          + + <%# -- Colonna 2: Contenuto centrale -- %> +
          + +
          +
          + <%= link_to format_date(report.date, format: :long), + report_path("daily_cash", date: report.date), + class: "hover:text-primary transition-colors focus:outline-none", + data: { turbo_frame: "_top" } + %> +
          + + <% if report.date == Date.current %> +
          + Oggi +
          + <% end %> +
          + + <%# Riga B: Dettaglio Turni %> +
          + Mattina: <%= format_money(report.morning_total) %> + + Pomeriggio: <%= format_money(report.afternoon_total) %> +
          +
          + + <%# -- Colonna 3: Totale -- %> +
          + + <%# Totale Netto (con label visibile solo su desktop per pulizia) %> +
          + + + <%= format_money(report.total) %> + +
          +
          +
        • diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index b7780da..4b7f376 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -1,90 +1,67 @@ - - -
          -
          -

          Report Mensile

          -

          Riepilogo incassi contanti

          -
          - -
          - <%= link_to reports_path(date: @date.prev_month), class: "btn btn-sm join-item" do %> - <%= icon("arrow_left") %> - <% end %> - - - - <%= link_to reports_path(date: @date.next_month), class: "btn btn-sm join-item" do %> - <%= icon("arrow_right") %> - <% end %> -
          -
          - -<%= render "shared/table", pagy: @pagy do %> - - - Data - Mattina - Pomeriggio - Totale - Azioni - - - - - <% @daily_reports.each do |report| %> - - - <%= l(report.date, format: :short) %> +<% content_for :page_title, "Report Mensile" %> + +<% content_for :page_counter, "#{format_money(@monthly_total)} incassati" %> + +<% content_for :page_search_and_filters do %> + <%= form_with url: reports_path, + method: :get, + html: { + autocomplete: "off", + id: "filter-form", + data: { + controller: "autosubmit", + turbo_frame: "reports_list", + turbo_action: "advance" + } + } do |form| %> + +
          + + <%# SINISTRA: Selezione Mese %> +
          + +
          + + <%# DESTRA: Navigazione Precedente / Successivo %> +
          + <%= link_to reports_path(month: @date.prev_month.strftime("%Y-%m")), class: "btn bg-base-100 tooltip", data: { tip: "Mese precedente" } do %> + <%= icon("arrow_left") %> + <% end %> + + <%= link_to reports_path(month: @date.next_month.strftime("%Y-%m")), class: "btn bg-base-100 tooltip", data: { tip: "Mese successivo" } do %> + <%= icon("arrow_right") %> + <% end %> +
          + +
          + <% end %> +<% end %> - <% if report.date == Date.current %> - Oggi - <% end %> - +<%= render "shared/header/page" %> - - <%= format_money(report.morning_total) %> - +<%= turbo_frame_tag "reports_list" do %> + <%= render "shared/filter/active", filter_keys: @keys if @keys.present? %> - - <%= format_money(report.afternoon_total) %> - + <%# --- INIZIO LISTA --- %> +
          + <% if @daily_reports.empty? %> + <%= render "shared/empty_state", + icon_name: "calendar_today", + title: "Nessun report in questo mese", + message: "Non ci sono incassi o turni registrati per #{l(@date, format: '%B %Y')}." %> + <% else %> - - <%= format_money(report.total) %> - +
            + <%= render partial: "reports/row", collection: @daily_reports, as: :report %> +
          - - <% unless report.empty? %> - <%= link_to report_path("daily_cash", date: report.date), - class: "btn btn-ghost btn-xs btn-square tooltip tooltip-left", - data: { tip: "Dettaglio" } do %> - <%= icon("view", size: 16) %> - <% end %> - <% end %> - - <% end %> - - - - - TOTALE MESE - - - <%= format_money(@daily_reports.sum(&:morning_total)) %> - - - - <%= format_money(@daily_reports.sum(&:afternoon_total)) %> - - - - <%= format_money(@daily_reports.sum(&:total)) %> - - - - - +
          <% end %> diff --git a/app/views/reports/show.html.erb b/app/views/reports/show.html.erb index 298e765..123d6a9 100644 --- a/app/views/reports/show.html.erb +++ b/app/views/reports/show.html.erb @@ -1,179 +1,99 @@ -
          - <%# Header con navigazione (Nascondi in stampa) %> -
          - <%# Link per tornare indietro: punta all'Index (reports_path) mantenendo la data %> - <%= link_to reports_path(date: @date), class: "btn btn-ghost gap-2 pl-0" do %> - <%= icon("arrow_back") %> Torna al Mese - <% end %> +<% content_for :page_title, "Dettaglio Incassi - #{l(@date, format: :short)}" %> - <%# Bottone che attiva la funzione stampa del browser %> - -
          - - <%# Intestazione Report (Visibile in stampa) %> -
          -

          Chiusura Cassa

          +<%# --- Header Navigazione --- %> +
          + <%= link_to reports_path(month: @date.strftime("%Y-%m")), class: "btn btn-ghost btn-sm gap-2 pl-0" do %> + <%= icon("arrow_left") %> Torna a <%= l(@date, format: "%B %Y") %> + <% end %> +
          -
          +<%# --- Intestazione Report --- %> +
          +
          +

          Dettaglio Incassi

          +
          <%= l(@date, format: :long) %>
          - <%# --- RIEPILOGO TOTALI --- %> -
          -
          -
          Mattina
          + <%# Totale netto in evidenza subito in alto %> +
          +
          Totale Giornata
          +
          <%= format_money(@daily_cash.total) %>
          +
          +
          -
          - <%= format_money(@daily_cash.morning_total) %> +<%# --- RIEPILOGO TURNI --- %> +
          + + <%# --- BLOCCO MATTINA --- %> +
          +
          +
          + <%= icon("sunny", classes: "size-12 text-warning") %> +
          +

          Mattina

          +
          00:00 - 13:59
          +
          -
          - -
          -
          Pomeriggio
          - -
          - <%= format_money(@daily_cash.afternoon_total) %> +
          + <%= format_money(@daily_cash.morning_total) %>
          -
          -
          TOTALE NETTO
          - -
          - <%= format_money(@daily_cash.total) %> -
          -
          +
            + <% if @daily_cash.morning_sales.any? %> + <% @daily_cash.morning_sales.each do |sale| %> +
          • +
            <%= l(sale.created_at, format: "%H:%M") %>
            +
            +
            <%= sale.member.full_name %>
            +
            <%= sale.product_name_snapshot %>
            +
            +
            + <%= format_money(sale.amount) %> +
            +
          • + <% end %> + <% else %> +
          • Nessun movimento registrato.
          • + <% end %> +
          - <%# --- LISTA VENDITE DETTAGLIATA --- %> -
          - <%# COLONNA MATTINA %> -
          -
          - <%= icon("sunny", size: 18) %> Turno Mattina - (00:00 - 13:59) + <%# --- BLOCCO POMERIGGIO --- %> +
          +
          +
          + <%= icon("moon", classes: "size-12 text-indigo-400") %> +
          +

          Pomeriggio

          +
          14:00 - 23:59
          +
          - -
          - - - <% if @daily_cash.morning_sales.any? %> - <% @daily_cash.morning_sales.each do |sale| %> - - - - - - - - <% end %> - <% else %> - - - - <% end %> - -
          - <%= l(sale.created_at, format: :short) %> - -
          - <%= sale.member.full_name %> -
          - -
          - <%= sale.product_name_snapshot %> -
          -
          - <%= format_money(sale.amount) %> -
          - Nessun movimento -
          +
          + <%= format_money(@daily_cash.afternoon_total) %>
          - <%# COLONNA POMERIGGIO %> -
          -
          - <%= icon("bedtime", size: 18) %> Turno Pomeriggio - (14:00 - 23:59) -
          - -
          - - - <% if @daily_cash.afternoon_sales.any? %> - <% @daily_cash.afternoon_sales.each do |sale| %> - - - - - - - - <% end %> - <% else %> - - - - <% end %> - -
          - <%= l(sale.created_at, format: :short) %> - -
          - <%= sale.member.full_name %> -
          - -
          - <%= sale.product_name_snapshot %> -
          -
          - <%= format_money(sale.amount) %> -
          - Nessun movimento -
          -
          -
          +
            + <% if @daily_cash.afternoon_sales.any? %> + <% @daily_cash.afternoon_sales.each do |sale| %> +
          • +
            <%= l(sale.created_at, format: "%H:%M") %>
            +
            +
            <%= sale.member.full_name %>
            +
            <%= sale.product_name_snapshot %>
            +
            +
            + <%= format_money(sale.amount) %> +
            +
          • + <% end %> + <% else %> +
          • Nessun movimento registrato.
          • + <% end %> +
          - <%# Footer per firma (utile in stampa cartacea) %> -
          - -<%# Stile CSS che si attiva SOLO quando fai Stampa %> - diff --git a/app/views/shared/sidebar/_admin.html.erb b/app/views/shared/sidebar/_admin.html.erb index 5d20bff..db9a6cb 100644 --- a/app/views/shared/sidebar/_admin.html.erb +++ b/app/views/shared/sidebar/_admin.html.erb @@ -66,8 +66,32 @@ <% end %> <%= yield :sale_actions if content_for?(:sale_actions) %> +
        • + <%= active_link_to reports_path do %> + <%= t("sidebar.items.reports", default: "Report Incassi") %> + <% end %> + <%= yield :report_actions if content_for?(:report_actions) %> +
        • +
        + + + +
      • +
        + + <%= icon("login", classes: "size-5 opacity-75") %> + <%= t("sidebar.sections.accesses", default: "Controllo Accessi") %> + +
          +
        • + <%= active_link_to access_logs_path do %> + <%= t("sidebar.items.access_logs", default: "Registro Entrate") %> + <% end %> + <%= yield :access_log_actions if content_for?(:access_log_actions) %> +
      • +
      diff --git a/app/views/subscriptions/_compact.html.erb b/app/views/subscriptions/_compact.html.erb index 40a924e..b8f4f2d 100644 --- a/app/views/subscriptions/_compact.html.erb +++ b/app/views/subscriptions/_compact.html.erb @@ -1,17 +1,13 @@ <%# locals: (subscription:) %> - <% status = subscription.status %> -<% bg_class = status.requires_attention? ? "badge-#{status.color} badge-soft" : "badge-ghost opacity-60" %> -<% padding_class = status.requires_attention? ? "pl-2 pr-0.5 py-3" : "py-3" %> +
      -
      - + <%= subscription.product.name %> - - + <% if status.key == :pending_payment %> -<%= format_cents(subscription.agreed_price_cents - subscription.amount_paid) %> <% elsif status.key == :expired %> @@ -24,18 +20,16 @@ <% if status.key == :pending_payment %> - <%# AZIONE A: Paga la rata %> <%= link_to new_sale_path(member_id: subscription.member_id, installment_for_subscription_id: subscription.id), - class: "btn btn-ghost btn-xs btn-circle ml-0.5 hover:bg-current/20 text-current", - data: { turbo_frame: "modal" }, title: "Incassa Rata" do %> + class: "ml-0.5 p-0.5 -mr-1.5 rounded-full hover:bg-current/20 text-current transition-colors focus:outline-none focus:ring-1 focus:ring-current", + data: { turbo_frame: "modal" }, title: "Incassa Rata" do %> <%= icon("payments", classes: "size-4") %> <% end %> <% elsif [:expired, :expiring_soon].include?(status.key) %> - <%# AZIONE B: Rinnova abbonamento %> <%= link_to new_sale_path(member_id: subscription.member_id, renew_subscription_id: subscription.id), - class: "btn btn-ghost btn-xs btn-circle ml-0.5 hover:bg-current/20 text-current", - data: { turbo_frame: "modal" }, title: "Rinnova al volo" do %> + class: "ml-0.5 p-0.5 -mr-1.5 rounded-full hover:bg-current/20 text-current transition-colors focus:outline-none focus:ring-1 focus:ring-current", + data: { turbo_frame: "modal" }, title: "Rinnova al volo" do %> <%= icon("reset", classes: "size-4") %> <% end %> <% end %> diff --git a/app/views/users/_context.html.erb b/app/views/users/_context.html.erb index 24c3bc0..6dcd684 100644 --- a/app/views/users/_context.html.erb +++ b/app/views/users/_context.html.erb @@ -47,16 +47,19 @@ <%= icon("edit") %> Modifica Anagrafica <% end %> -
    • - <%= link_to user_path(user), - class: "text-error hover:bg-error/10 hover:text-error", - data: { - turbo_method: :delete, - turbo_confirm: "Sei sicuro?" - } do %> - <%= icon("delete") %> Archivia Utente - <% end %> -
    • + + <% if current_user.admin? && user != current_user %> +
    • + <%= link_to user_path(user), + class: "text-error hover:bg-error/10 hover:text-error", + data: { + turbo_method: :delete, + turbo_confirm: "Sei sicuro?" + } do %> + <%= icon("delete") %> Archivia Utente + <% end %> +
    • + <% end %> <% end %> <% end %> diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index ebe2d4d..3cbf4e4 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -29,7 +29,7 @@ <%# --- SEZIONE 2: ACCESSO & SICUREZZA --- %>
      - <%= icon("lock", classes: "size-6 opacity-40") %> Accesso & Sicurezza + <%= icon("fingerprint", classes: "size-6 opacity-40") %> Accesso & Sicurezza
      diff --git a/app/views/users/_user_row.html.erb b/app/views/users/_row.html.erb similarity index 100% rename from app/views/users/_user_row.html.erb rename to app/views/users/_row.html.erb diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 86799ae..c6e7a7d 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -90,7 +90,7 @@ <%= filtered_results_counter(@pagy) %>
        - <%= render partial: "user_row", collection: @users, as: :user, cached: true %> + <%= render partial: "users/row", collection: @users, as: :user, cached: true %>
      <%= render "shared/pagination" %> diff --git a/config/routes.rb b/config/routes.rb index 9b25878..a94f847 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,4 @@ Rails.application.routes.draw do - # ============================================================================ - # 1. AUTHENTICATION - # ============================================================================ resource :session resources :passwords, param: :token @@ -13,9 +10,6 @@ concerns :searchable end - # ============================================================================ - # 2. ANAGRAFICA (Registry) - # ============================================================================ resources :members do resources :subscriptions, only: [ :index ], module: :members resources :access_logs, only: [ :index ], module: :members @@ -28,36 +22,18 @@ resource :language, only: [ :update ] end - # ============================================================================ - # 3. CATALOGO (Catalog) - # ============================================================================ resources :disciplines do resources :members, only: [ :index ], module: :disciplines end resources :products - # ============================================================================ - # 4. AMMINISTRAZIONE & VENDITE (Accounting) - # ============================================================================ resources :sales, only: [ :index, :new, :create, :show, :destroy ] - resources :subscriptions, only: [ :index, :edit, :update, :destroy ] - resources :receipt_counters - - # ============================================================================ - # 5. ACCESSI (Access Control) - # ============================================================================ - resources :access_logs, only: [ :index, :new, :create ] + resources :access_logs, only: [ :index, :destroy ] - # ============================================================================ - # 6. REPORTING & UTILITY - # ============================================================================ resources :reports, only: [ :index, :show ], param: :report_type resources :feedbacks, only: [ :new, :create ] - # ============================================================================ - # ROOT & SYSTEM - # ============================================================================ get "up" => "rails/health#show", as: :rails_health_check root "dashboard#index" From 054d537b29bc766bd0e580a6da0e02ff8bfd354a Mon Sep 17 00:00:00 2001 From: jcostd Date: Tue, 21 Apr 2026 21:20:22 +0200 Subject: [PATCH 29/34] Rubocop refactor --- app/controllers/access_logs_controller.rb | 2 +- app/helpers/sales_helper.rb | 8 ++++---- app/helpers/subscriptions_helper.rb | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/controllers/access_logs_controller.rb b/app/controllers/access_logs_controller.rb index 94333f7..e2b6047 100644 --- a/app/controllers/access_logs_controller.rb +++ b/app/controllers/access_logs_controller.rb @@ -2,7 +2,7 @@ class AccessLogsController < ApplicationController include Filterable before_action :require_admin - before_action :set_access_log, only: [:destroy] + before_action :set_access_log, only: [ :destroy ] def index @total_accesses = AccessLog.count diff --git a/app/helpers/sales_helper.rb b/app/helpers/sales_helper.rb index 3395383..4595609 100644 --- a/app/helpers/sales_helper.rb +++ b/app/helpers/sales_helper.rb @@ -29,17 +29,17 @@ def grouped_product_options if active_disciplines.any? active_disciplines.each do |discipline| - groups[discipline.name] << [product.name, product.id] + groups[discipline.name] << [ product.name, product.id ] end else - uncategorized << [product.name, product.id] + uncategorized << [ product.name, product.id ] end end - result = groups.sort.map { |discipline_name, product_list| [discipline_name, product_list] } + result = groups.sort.map { |discipline_name, product_list| [ discipline_name, product_list ] } if uncategorized.any? - result.unshift(["Quote e Varie", uncategorized]) + result.unshift([ "Quote e Varie", uncategorized ]) end result diff --git a/app/helpers/subscriptions_helper.rb b/app/helpers/subscriptions_helper.rb index 3fd2453..14ad16e 100644 --- a/app/helpers/subscriptions_helper.rb +++ b/app/helpers/subscriptions_helper.rb @@ -17,7 +17,7 @@ def subscription_status_icon(status_key) # Wrapper per gestire le classi CSS condizionali (es. grigio se scaduto) def subscription_row_wrapper(subscription, status, &block) - classes = ["list-row", "hover:bg-base-200/50", "transition-colors"] + classes = [ "list-row", "hover:bg-base-200/50", "transition-colors" ] classes << "opacity-60 grayscale" if status.key.to_sym == :expired content_tag(:li, id: dom_id(subscription), class: classes, &block) @@ -48,7 +48,7 @@ def subscription_sale_receipt_link(sale) safe_join([ content_tag(:span, "•", class: "opacity-50 mx-1"), - link_to(sale.receipt_code, [sale], class: "link link-hover text-primary font-medium", data: { turbo_frame: "_top" }) + link_to(sale.receipt_code, [ sale ], class: "link link-hover text-primary font-medium", data: { turbo_frame: "_top" }) ]) end @@ -60,13 +60,13 @@ def subscription_installment_action(subscription, amount_due) concat link_to(new_sale_path(member_id: subscription.member_id, installment_for_subscription_id: subscription.id), class: "btn btn-xs btn-warning btn-soft rounded-full", data: { turbo_frame: "modal" }, title: "Incassa Rata") { - safe_join([icon("payments", classes: "size-3"), " Incassa"]) + safe_join([ icon("payments", classes: "size-3"), " Incassa" ]) } end end def subscription_renew_action(subscription, status) - return unless [:expired, :expiring_soon].include?(status.key.to_sym) + return unless [ :expired, :expiring_soon ].include?(status.key.to_sym) link_to new_sale_path(member_id: subscription.member_id, renew_subscription_id: subscription.id), class: "btn btn-square btn-sm btn-ghost text-info", @@ -78,6 +78,6 @@ def subscription_renew_action(subscription, status) def subscription_archive_action(subscription) return unless subscription.end_date >= 7.days.ago.to_date - ui_row_delete_button([subscription], confirm: "Eliminando l'abbonamento annullerai l'incasso. Continuare?", title: "Archivia") + ui_row_delete_button([ subscription ], confirm: "Eliminando l'abbonamento annullerai l'incasso. Continuare?", title: "Archivia") end end From 8a374f507b6c26255c2cf2c44311e38f5597201d Mon Sep 17 00:00:00 2001 From: jcostd Date: Tue, 28 Apr 2026 00:07:48 +0200 Subject: [PATCH 30/34] Updated gems and product --- Gemfile.lock | 52 +++++++++---------- app/controllers/access_logs_controller.rb | 2 +- app/controllers/application_controller.rb | 15 ++++++ app/controllers/disciplines_controller.rb | 8 +-- app/controllers/members_controller.rb | 17 +++--- app/controllers/products_controller.rb | 7 +-- app/controllers/users_controller.rb | 15 ++---- .../controllers/carnet_toggle_controller.js | 16 ++++++ app/models/access_log.rb | 2 +- app/models/access_policy.rb | 17 ++---- app/models/concerns/date_rangeable.rb | 5 +- app/models/product.rb | 2 +- app/models/subscription.rb | 21 ++++++++ app/models/subscription_status.rb | 15 ++---- app/queries/application_query.rb | 2 +- app/queries/products_query.rb | 2 +- app/views/kiosk/members/_card.html.erb | 2 - .../kiosk/members/_checked_in_card.html.erb | 11 +++- app/views/members/_member.html.erb | 42 +++++++-------- app/views/members/show.html.erb | 2 +- app/views/products/_form.html.erb | 27 ++++++++++ app/views/subscriptions/_form.html.erb | 16 ++++-- 22 files changed, 183 insertions(+), 115 deletions(-) create mode 100644 app/javascript/controllers/carnet_toggle_controller.js diff --git a/Gemfile.lock b/Gemfile.lock index aa4bb36..b8a810c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,7 +83,7 @@ GEM bcrypt_pbkdf (1.1.2) bigdecimal (3.3.1) bindex (0.8.1) - bootsnap (1.23.0) + bootsnap (1.24.0) msgpack (~> 1.2) brakeman (8.0.4) racc @@ -136,7 +136,7 @@ GEM activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.2) - irb (1.17.0) + irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) rdoc (>= 4.0.0) @@ -177,7 +177,7 @@ GEM drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) - net-imap (0.6.3) + net-imap (0.6.4) date net-protocol net-pop (0.1.2) @@ -192,26 +192,26 @@ GEM net-protocol net-ssh (7.3.2) nio4r (2.7.5) - nokogiri (1.19.2-aarch64-linux-gnu) + nokogiri (1.19.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-aarch64-linux-musl) + nokogiri (1.19.3-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.2-arm-linux-gnu) + nokogiri (1.19.3-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-arm-linux-musl) + nokogiri (1.19.3-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.2-arm64-darwin) + nokogiri (1.19.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-gnu) + nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-musl) + nokogiri (1.19.3-x86_64-linux-musl) racc (~> 1.4) ostruct (0.6.3) - pagy (43.5.1) + pagy (43.5.3) json uri yaml - parallel (2.0.1) + parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) racc @@ -235,7 +235,7 @@ GEM date stringio public_suffix (7.0.5) - puma (8.0.0) + puma (8.0.1) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) @@ -468,7 +468,7 @@ CHECKSUMS bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (3.3.1) sha256=eaa01e228be54c4f9f53bf3cc34fe3d5e845c31963e7fcc5bedb05a4e7d52218 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e - bootsnap (1.23.0) sha256=c1254f458d58558b58be0f8eb8f6eec2821456785b7cdd1e16248e2020d3f214 + bootsnap (1.24.0) sha256=34e6dea61ff4895101aa9c10894ce30186bec73fe2279e0eb52040d8d4cec297 brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 @@ -497,7 +497,7 @@ CHECKSUMS image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc - irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac kamal (2.11.0) sha256=1408864425e0dec7e0a14d712a3b13f614e9f3a425b7661d3f9d287a51d7dd75 @@ -512,7 +512,7 @@ CHECKSUMS mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.5) sha256=f007d7246bf4feea549502842cd7c6aba8851cdc9c90ba06de9c476c0d01155c msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 - net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad + net-imap (0.6.4) sha256=9a5598c67a3022c284d98430ef1d4948e7dbdb62596f61081ea8ca933270a02b net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d @@ -520,16 +520,16 @@ CHECKSUMS net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 net-ssh (7.3.2) sha256=65029e213c380e20e5fd92ece663934ab0a0fe888e0cd7cc6a5b664074362dd4 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 - nokogiri (1.19.2-aarch64-linux-gnu) sha256=c34d5c8208025587554608e98fd88ab125b29c80f9352b821964e9a5d5cfbd19 - nokogiri (1.19.2-aarch64-linux-musl) sha256=7f6b4b0202d507326841a4f790294bf75098aef50c7173443812e3ac5cb06515 - nokogiri (1.19.2-arm-linux-gnu) sha256=b7fa1139016f3dc850bda1260988f0d749934a939d04ef2da13bec060d7d5081 - nokogiri (1.19.2-arm-linux-musl) sha256=61114d44f6742ff72194a1b3020967201e2eb982814778d130f6471c11f9828c - nokogiri (1.19.2-arm64-darwin) sha256=58d8ea2e31a967b843b70487a44c14c8ba1866daa1b9da9be9dbdf1b43dee205 - nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f - nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8 + nokogiri (1.19.3-aarch64-linux-gnu) sha256=46b89e5d7b9e844c2ee360794240c6ea2a4e6fa0c5892a4ed487db621224b639 + nokogiri (1.19.3-aarch64-linux-musl) sha256=8392dfdcd21be7a94dbbe9ccc138dea01b97b24cb2dc02a114ca98bfb1d9a0b7 + nokogiri (1.19.3-arm-linux-gnu) sha256=3919d5ffc334ad778a4a9eb88fda7dcb8b1fb58c8a52ac640c6dcd2f038e774f + nokogiri (1.19.3-arm-linux-musl) sha256=9ce1cb6346bb9c67b1550eb537aa183ead91e4b6eadb2f36ade02d8dd2a79fb6 + nokogiri (1.19.3-arm64-darwin) sha256=71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42 + nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976 + nokogiri (1.19.3-x86_64-linux-musl) sha256=248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 - pagy (43.5.1) sha256=ca5aaa6d65d21eee67a48fe8801d022d07ee72afbc5bea6a9e21b13a27b7c0b9 - parallel (2.0.1) sha256=337782d3e39f4121e67563bf91dd8ece67f48923d90698614773a0ec9a5b2c7d + pagy (43.5.3) sha256=f9d73e690648d484706661dcb815647775cf8330fcc5c6e62ec87b9df431290b + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 pdf-core (0.10.0) sha256=0a5d101e2063c01e3f941e1ee47cbb97f1adfc1395b58372f4f65f1300f3ce91 phonelib (0.10.18) sha256=2096b127bfbd4fef58eddb4dc916bb285495855b6673648719dff76cd8c32007 @@ -541,7 +541,7 @@ CHECKSUMS propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 - puma (8.0.0) sha256=1681050b8b60fab1d3033255ab58b6aec64cd063e43fc6f8204bcb8bf9364b88 + puma (8.0.1) sha256=7b94e50c07655718c1fb8ae41a11fc06c7d61293208b3aa608ff71a46d3ad37c raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 diff --git a/app/controllers/access_logs_controller.rb b/app/controllers/access_logs_controller.rb index e2b6047..00eb92c 100644 --- a/app/controllers/access_logs_controller.rb +++ b/app/controllers/access_logs_controller.rb @@ -17,7 +17,7 @@ def new def destroy @access_log.destroy - redirect_to access_logs_path, status: :see_other, notice: "Accesso annullato con successo." + turbo_refresh_or_redirect_to access_logs_path, status: :see_other, notice: "Accesso annullato con successo." end private diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4457cd8..3d7cf4b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,4 +8,19 @@ class ApplicationController < ActionController::Base # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + private + + def turbo_refresh_or_redirect_to(fallback_path, options = {}) + respond_to do |format| + format.turbo_stream do + flash[:notice] = options[:notice] if options[:notice] + flash[:alert] = options[:alert] if options[:alert] + + render turbo_stream: turbo_stream.refresh(request_id: nil) + end + + format.html { redirect_to fallback_path, **options } + end + end end diff --git a/app/controllers/disciplines_controller.rb b/app/controllers/disciplines_controller.rb index 12d83ca..1c0edc3 100644 --- a/app/controllers/disciplines_controller.rb +++ b/app/controllers/disciplines_controller.rb @@ -25,7 +25,7 @@ def create @discipline = Discipline.new(discipline_params) if @discipline.save - redirect_to disciplines_path, notice: "Disciplina creata con successo." + turbo_refresh_or_redirect_to disciplines_path, notice: "Disciplina creata con successo." else render :new, status: :unprocessable_entity end @@ -35,7 +35,7 @@ def edit; end def update if @discipline.update(discipline_params) - redirect_to disciplines_path, notice: "Disciplina aggiornata." + turbo_refresh_or_redirect_to disciplines_path, notice: "Disciplina aggiornata." else render :edit, status: :unprocessable_entity end @@ -43,9 +43,9 @@ def update def destroy if @discipline.discard! - redirect_to disciplines_path, notice: "Disciplina archiviata." + turbo_refresh_or_redirect_to disciplines_path, notice: "Disciplina archiviata." else - redirect_to disciplines_path, alert: "Impossibile archiviare." + turbo_refresh_or_redirect_to disciplines_path, alert: "Impossibile archiviare." end end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index a8fdb2f..4a6f96c 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -3,7 +3,7 @@ class MembersController < ApplicationController before_action :set_member, only: [ :show, :edit, :update, :destroy ] - layout "modal", only: [ :new, :create, :edit, :update ] + layout "modal", only: [ :new, :edit ] def index @total_active_members = Member.kept.count @@ -15,7 +15,10 @@ def index def show @member = Member.find(params[:id]) - @active_subscriptions = @member.active_subscriptions + @active_subscriptions = @member.subscriptions.kept + .includes(:product, :access_logs) + .select { |s| (s.end_date.nil? || s.end_date >= Date.current) && !s.out_of_entries? } + .sort_by { |s| s.start_date || Date.current } @recent_sales = @member.recent_sales end @@ -27,9 +30,9 @@ def create @member = Member.new(member_params) if @member.save - redirect_to @member, notice: t(".created", default: "Socio creato con successo.") + turbo_refresh_or_redirect_to @member, notice: t(".created", default: "Socio creato con successo.") else - render :new, status: :unprocessable_entity + render :new, layout: "modal", status: :unprocessable_entity end end @@ -37,15 +40,15 @@ def edit; end def update if @member.update(member_params) - redirect_to @member, notice: t(".updated", default: "Socio aggiornato con successo.") + turbo_refresh_or_redirect_to @member, notice: t(".updated", default: "Socio aggiornato con successo.") else - render :edit, status: :unprocessable_entity + render :edit, layout: "modal", status: :unprocessable_entity end end def destroy if @member.discard! - redirect_to members_path, status: :see_other, notice: t(".discarded", default: "Socio archiviato correttamente.") + turbo_refresh_or_redirect_to members_path, status: :see_other, notice: t(".discarded", default: "Socio archiviato correttamente.") else redirect_to members_path, status: :see_other, alert: t(".discard_error", default: "Impossibile archiviare il socio.") end diff --git a/app/controllers/products_controller.rb b/app/controllers/products_controller.rb index 3d3a837..d75f02f 100644 --- a/app/controllers/products_controller.rb +++ b/app/controllers/products_controller.rb @@ -27,7 +27,7 @@ def create @product = Product.new(product_params) if @product.save - redirect_to products_path, notice: t(".created", default: "Prodotto creato correttamente.") + turbo_refresh_or_redirect_to products_path, notice: t(".created", default: "Prodotto creato correttamente.") else render :new, status: :unprocessable_entity end @@ -37,7 +37,7 @@ def edit; end def update if @product.update(product_params) - redirect_to products_path, notice: t(".updated", default: "Prodotto aggiornato.") + turbo_refresh_or_redirect_to products_path, notice: t(".updated", default: "Prodotto aggiornato.") else render :edit, status: :unprocessable_entity end @@ -45,7 +45,7 @@ def update def destroy if @product.discard! - redirect_to products_path, notice: t(".discarded", default: "Prodotto archiviato.") + turbo_refresh_or_redirect_to products_path, notice: t(".discarded", default: "Prodotto archiviato.") else redirect_to products_path, alert: t(".error", default: "Impossibile archiviare.") end @@ -62,6 +62,7 @@ def product_params :price, :duration_days, :accounting_category, + :entry_limit, discipline_ids: [] ) end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a6bf0b8..5b75971 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -22,7 +22,7 @@ def create @user = User.new(user_params) if @user.save - redirect_to users_path, notice: t(".created", default: "Utente creato con successo.") + turbo_refresh_or_redirect_to users_path, notice: t(".created", default: "Utente creato con successo.") else render :new, status: :unprocessable_entity end @@ -38,22 +38,17 @@ def update end if @user.update(upd_params) - redirect_to users_path, notice: t(".updated", default: "Profilo utente aggiornato.") + turbo_refresh_or_redirect_to users_path, notice: t(".updated", default: "Profilo utente aggiornato.") else render :edit, status: :unprocessable_entity end end def destroy - if @user == current_user - redirect_to users_path, alert: "Non puoi archiviare il tuo stesso account." - return - end - - if @user.discard! - redirect_to users_path, status: :see_other, notice: t(".discarded", default: "Utente archiviato.") + if @user != current_user && @user.discard! + turbo_refresh_or_redirect_to users_path, status: :see_other, notice: t(".discarded", default: "Utente archiviato.") else - redirect_to users_path, status: :see_other, alert: t(".error", default: "Impossibile archiviare utente.") + turbo_refresh_or_redirect_to users_path, status: :see_other, alert: t(".error", default: "Impossibile archiviare utente.") end end diff --git a/app/javascript/controllers/carnet_toggle_controller.js b/app/javascript/controllers/carnet_toggle_controller.js new file mode 100644 index 0000000..09cf384 --- /dev/null +++ b/app/javascript/controllers/carnet_toggle_controller.js @@ -0,0 +1,16 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["content", "input"] + + toggle(event) { + if (event.target.checked) { + this.contentTarget.classList.remove("hidden") + } else { + this.contentTarget.classList.add("hidden") + if (this.hasInputTarget) { + this.inputTarget.value = "" + } + } + } +} diff --git a/app/models/access_log.rb b/app/models/access_log.rb index d1fa518..7ebdc57 100644 --- a/app/models/access_log.rb +++ b/app/models/access_log.rb @@ -17,7 +17,7 @@ class AccessLog < ApplicationRecord validate :subscription_belongs_to_member validate :subscription_must_be_active, on: :create, if: -> { status == "ok" } - scope :valid_entries, -> { where(status: :ok) } + scope :valid_entries, -> { where(status: [ :ok, :warning ]) } private def set_defaults diff --git a/app/models/access_policy.rb b/app/models/access_policy.rb index 7ae1f9e..b87befd 100644 --- a/app/models/access_policy.rb +++ b/app/models/access_policy.rb @@ -1,4 +1,3 @@ -# app/models/access_policy.rb class AccessPolicy include ActiveModel::Model @@ -47,10 +46,8 @@ def check_subscription def check_entry_limits return unless subscription && entry_limit_applies? - # Nota: qui diamo per scontato che tu abbia access_logs_count o un metodo simile in Subscription - used_entries = subscription.access_logs.valid_entries.count - if used_entries >= subscription.entry_limit - errors.add(:base, "Ingressi esauriti (#{used_entries}/#{subscription.entry_limit}).") + if subscription.out_of_entries? + errors.add(:base, "Ingressi esauriti (#{subscription.entries_used}/#{subscription.entry_limit}).") end end @@ -64,17 +61,11 @@ def evaluate_warnings @warnings << "Abbonamento in scadenza tra #{days_left} giorni." end - if entry_limit_applies? - used = subscription.access_logs.valid_entries.count - remaining = subscription.entry_limit - used - if remaining <= 2 - @warnings << "Rimangono solo #{remaining} ingressi." - end + if entry_limit_applies? && subscription.entries_remaining <= 2 + @warnings << "Rimangono solo #{subscription.entries_remaining} ingressi." end end - # --- Metodi Helper Interni --- - def entry_limit_applies? subscription.entry_limit.present? && subscription.entry_limit > 0 end diff --git a/app/models/concerns/date_rangeable.rb b/app/models/concerns/date_rangeable.rb index ee6d02f..a013986 100644 --- a/app/models/concerns/date_rangeable.rb +++ b/app/models/concerns/date_rangeable.rb @@ -17,14 +17,15 @@ def active?(date = Date.current) end def future? - start_date > Date.current + start_date.present? && start_date > Date.current end def expired?(date = Date.current) - end_date < date + end_date.present? && end_date < date end def days_left + return false unless end_date (end_date - Date.current).to_i end diff --git a/app/models/product.rb b/app/models/product.rb index 970947b..28e9848 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -1,5 +1,5 @@ class Product < ApplicationRecord - include SoftDeletable, Monetizable + include SoftDeletable, Monetizable, Refreshable monetize :price diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 3a47d1e..3426bfd 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -33,6 +33,27 @@ def unlimited_entries? entry_limit.nil? || entry_limit.zero? end + def entries_used + return 0 if unlimited_entries? + access_logs.valid_entries.count + end + + def entries_remaining + return nil if unlimited_entries? + [ entry_limit - entries_used, 0 ].max + end + + def out_of_entries? + !unlimited_entries? && entries_remaining.zero? + end + + def expiring_soon? + return false unless end_date + return false if out_of_entries? + + days_left.between?(0, 7) + end + private def set_default_agreed_price if (agreed_price_cents.nil? || agreed_price_cents.zero?) && product.present? diff --git a/app/models/subscription_status.rb b/app/models/subscription_status.rb index 9ea2a05..dd3a598 100644 --- a/app/models/subscription_status.rb +++ b/app/models/subscription_status.rb @@ -6,17 +6,17 @@ def initialize(subscription) end def requires_attention? - [ :pending_payment, :expired, :expiring_soon ].include?(key) + [ :pending_payment, :expired, :expiring_soon, :out_of_entries ].include?(key) end def key if !subscription.fully_paid? :pending_payment - elsif subscription.end_date.present? && subscription.end_date < Date.current + elsif subscription.expired? || subscription.out_of_entries? :expired - elsif subscription.start_date.present? && subscription.start_date > Date.current + elsif subscription.future? :future - elsif expiring_soon? + elsif subscription.expiring_soon? :expiring_soon else :active @@ -52,11 +52,4 @@ def icon when :active then "success" end end - - private - def expiring_soon? - return false unless subscription.end_date - - subscription.end_date.between?(Date.current, 14.days.from_now.to_date) - end end diff --git a/app/queries/application_query.rb b/app/queries/application_query.rb index ab33d91..43795ca 100644 --- a/app/queries/application_query.rb +++ b/app/queries/application_query.rb @@ -39,7 +39,7 @@ def apply_sorting(scope) when "name_desc" then scope.order(last_name: :desc, first_name: :desc) when "created_desc" then scope.order(created_at: :desc) else - @params[:query].present? ? scope : scope.order(created_at: :desc) + @params[:query].present? ? scope : scope.order(updated_at: :desc) end end end diff --git a/app/queries/products_query.rb b/app/queries/products_query.rb index c320ca4..d163c33 100644 --- a/app/queries/products_query.rb +++ b/app/queries/products_query.rb @@ -30,7 +30,7 @@ def apply_sorting(scope) when "created_asc" then scope.order(created_at: :asc) when "created_desc" then scope.order(created_at: :desc) else - @params[:query].present? ? scope : scope.order(name: :asc) + @params[:query].present? ? scope : scope.order(created_at: :desc) end end end diff --git a/app/views/kiosk/members/_card.html.erb b/app/views/kiosk/members/_card.html.erb index 1580af9..1aaf0f2 100644 --- a/app/views/kiosk/members/_card.html.erb +++ b/app/views/kiosk/members/_card.html.erb @@ -1,5 +1,3 @@ -<%# app/views/kiosk/members/_card.html.erb %> - <% cache [member, discipline] do %>
      diff --git a/app/views/kiosk/members/_checked_in_card.html.erb b/app/views/kiosk/members/_checked_in_card.html.erb index fcaaf06..61c4631 100644 --- a/app/views/kiosk/members/_checked_in_card.html.erb +++ b/app/views/kiosk/members/_checked_in_card.html.erb @@ -1,4 +1,3 @@ -<%# app/views/kiosk/members/_checked_in_card.html.erb %> <%# locals: (log:) %> <% cache log do %> @@ -20,7 +19,7 @@
      <%= icon("clock", classes: "size-3") %> - <%= log.entered_at.strftime("%H:%M") %> + <%= format_datetime(log.entered_at, format: :short) %> <% if is_warning %> @@ -28,6 +27,14 @@ <%= icon("warning", classes: "size-3") %> Forzato <% end %> + + <% sub = member.valid_subscription_for(log.discipline) %> + <% if sub && !sub.unlimited_entries? %> + + <%= icon("confirmation_number", classes: "size-3") %> PT: Rimanenti <%= sub.entries_remaining %> + + <% end %> +
      diff --git a/app/views/members/_member.html.erb b/app/views/members/_member.html.erb index a2bcfc1..8eac864 100644 --- a/app/views/members/_member.html.erb +++ b/app/views/members/_member.html.erb @@ -1,10 +1,7 @@ -<%# locals: (member:) %> +<%# locals: (member:, valid_subscriptions: []) %>
      - <%# ========================================== %> - <%# COLONNA SINISTRA: DATI & STATO %> - <%# ========================================== %>
      <%# Certificato Medico %> @@ -63,9 +60,6 @@
      - <%# ========================================== %> - <%# COLONNA DESTRA: OPERATIVITÀ %> - <%# ========================================== %>
      <%# Abbonamenti %> @@ -77,26 +71,26 @@ <%= link_to "Vedi Tutti", member_subscriptions_path(member), class: "link link-hover text-xs opacity-60" %>
      - <% valid_subs = member.subscriptions.select { |s| s.kept? && s.end_date >= Date.current }.sort_by(&:start_date) %> - - <% if valid_subs.any? %> + <% if valid_subscriptions.any? %>
        - <% valid_subs.each do |sub| %> - <% - is_future = sub.start_date > Date.current - days_left = (sub.end_date - Date.current).to_i - is_expiring_soon = !is_future && days_left.between?(0, 7) - %> -
      • + <% valid_subscriptions.each do |sub| %> +
      • -
        - <%= icon(is_future ? "clock" : "success") %> +
        + <%= icon(sub.future? ? "clock" : "success") %>
        <%= display_value(sub.product&.name) %>
        -
        - <% if is_future %> + + <% unless sub.unlimited_entries? %> +
        + <%= sub.entries_used %> / <%= sub.entry_limit %> Ingressi +
        + <% end %> + +
        + <% if sub.future? %> Inizia il <%= format_date(sub.start_date) %> <% else %> Scade il <%= format_date(sub.end_date) %> @@ -104,15 +98,15 @@
        - <% if is_future %> + <% if sub.future? %> <%= ui_badge("Futuro", style: "info") %> <% else %> <%= link_to new_sale_path(member_id: member.id, renew_subscription_id: sub.id), - class: "btn btn-sm btn-square #{is_expiring_soon ? 'btn-primary' : 'btn-ghost'}", + class: "btn btn-sm btn-square #{sub.expiring_soon? ? 'btn-primary' : 'btn-ghost'}", data: { turbo_frame: "modal" }, title: "Rinnova" do %> <%= icon("reset") %> <% end %> diff --git a/app/views/members/show.html.erb b/app/views/members/show.html.erb index 0d2e2a2..afbd2f0 100644 --- a/app/views/members/show.html.erb +++ b/app/views/members/show.html.erb @@ -2,4 +2,4 @@ <%= render "members/context", member: @member %> -<%= render @member %> +<%= render "member", member: @member, valid_subscriptions: @active_subscriptions %> diff --git a/app/views/products/_form.html.erb b/app/views/products/_form.html.erb index e73bd92..6fa3f1e 100644 --- a/app/views/products/_form.html.erb +++ b/app/views/products/_form.html.erb @@ -49,6 +49,33 @@ class: "select w-full" %>
        + + <%# --- INIZIO GESTIONE CARNET / PT --- %> +
        + + +
        + <%= f.label :entry_limit, "Numero Totale Ingressi", class: "label text-xs font-bold uppercase opacity-70" %> + <%= f.number_field :entry_limit, + class: "input input-bordered w-full md:w-1/3", + placeholder: "Es. 10", + min: 1, + data: { carnet_toggle_target: "input" } %> +
        + Se impostato, l'abbonamento scalerà un ingresso ad ogni check-in e si bloccherà a quota zero. +
        +
        +
        + <%# --- FINE GESTIONE CARNET / PT --- %>
      <%# --- SEZIONE 2: ACCESSI E DISCIPLINE --- %> diff --git a/app/views/subscriptions/_form.html.erb b/app/views/subscriptions/_form.html.erb index f0f4e98..953d989 100644 --- a/app/views/subscriptions/_form.html.erb +++ b/app/views/subscriptions/_form.html.erb @@ -34,16 +34,22 @@
      <%= f.label :start_date, "Data Inizio*", class: "label" %> - <%= f.date_field :start_date, - required: true, - class: "input w-full" %> + <%= f.date_field :start_date, class: "input w-full", required: true %>
      <%= f.label :end_date, "Data Scadenza*", class: "label" %> + <%= f.date_field :end_date, class: "input w-full" %> +
      +
      - <%= f.date_field :end_date, - class: "input w-full" %> +
      + <%= f.label :entry_limit, "Ingressi Totali Consentiti", class: "label font-bold" %> + <%= f.number_field :entry_limit, + class: "input w-full md:w-1/3", + placeholder: "Lascia vuoto per ingressi illimitati" %> +
      + Questo valore viene copiato in automatico dal Prodotto. Modificalo solo se vuoi fare un'eccezione per questo socio.
      From b26dfcb7a6d71d73884bc2a848d33443bda8d283 Mon Sep 17 00:00:00 2001 From: jcostd Date: Wed, 29 Apr 2026 20:31:25 +0200 Subject: [PATCH 31/34] Big UI and Filter update --- .gitignore | 1 + Gemfile.lock | 28 ++-- app/controllers/access_logs_controller.rb | 4 +- .../disciplines/members_controller.rb | 8 +- app/controllers/disciplines_controller.rb | 7 +- app/controllers/members/sales_controller.rb | 12 +- app/controllers/members_controller.rb | 9 +- app/controllers/products_controller.rb | 8 +- app/controllers/sales_controller.rb | 4 +- app/controllers/users_controller.rb | 7 +- app/helpers/subscriptions_helper.rb | 2 +- app/models/access_log.rb | 63 ++++---- app/models/access_log/filterable.rb | 40 ++++++ app/models/access_policy.rb | 11 +- app/models/concerns/date_rangeable.rb | 42 ------ app/models/concerns/subscription_issuer.rb | 29 ++-- app/models/concerns/trackable.rb | 89 +++++++++++- app/models/discipline.rb | 3 +- app/models/discipline/filterable.rb | 32 +++++ app/models/duration.rb | 135 +++++------------- app/models/gym_profile.rb | 4 +- app/models/member.rb | 83 ++++++++--- app/models/member/filterable.rb | 64 +++++++++ app/models/pos_draft_builder.rb | 105 +++++++------- app/models/product.rb | 1 + app/models/product/filterable.rb | 44 ++++++ app/models/renewal_calculator.rb | 33 ----- app/models/sale.rb | 4 +- app/models/sale/filterable.rb | 55 +++++++ app/models/subscription.rb | 109 ++++++++++---- app/models/subscription/filterable.rb | 74 ++++++++++ app/models/user.rb | 1 + app/models/user/filterable.rb | 46 ++++++ app/queries/access_logs_query.rb | 42 ------ app/queries/application_query.rb | 45 ------ app/queries/discipline_subscriptions_query.rb | 85 ----------- app/queries/disciplines_query.rb | 23 --- app/queries/members_query.rb | 38 ----- app/queries/products_query.rb | 36 ----- app/queries/sales_query.rb | 51 ------- app/queries/users_query.rb | 26 ---- app/views/disciplines/index.html.erb | 23 --- app/views/members/index.html.erb | 9 +- app/views/members/sales/index.html.erb | 4 +- app/views/products/index.html.erb | 13 -- .../{_sale_row.html.erb => _row.html.erb} | 2 +- app/views/sales/index.html.erb | 4 +- app/views/users/index.html.erb | 9 +- ...02113_add_entries_used_to_subscriptions.rb | 5 + db/structure.sql | 3 +- test/models/renewal_calculator_test.rb | 87 ----------- 51 files changed, 809 insertions(+), 853 deletions(-) create mode 100644 app/models/access_log/filterable.rb delete mode 100644 app/models/concerns/date_rangeable.rb create mode 100644 app/models/discipline/filterable.rb create mode 100644 app/models/member/filterable.rb create mode 100644 app/models/product/filterable.rb delete mode 100644 app/models/renewal_calculator.rb create mode 100644 app/models/sale/filterable.rb create mode 100644 app/models/subscription/filterable.rb create mode 100644 app/models/user/filterable.rb delete mode 100644 app/queries/access_logs_query.rb delete mode 100644 app/queries/application_query.rb delete mode 100644 app/queries/discipline_subscriptions_query.rb delete mode 100644 app/queries/disciplines_query.rb delete mode 100644 app/queries/members_query.rb delete mode 100644 app/queries/products_query.rb delete mode 100644 app/queries/sales_query.rb delete mode 100644 app/queries/users_query.rb rename app/views/sales/{_sale_row.html.erb => _row.html.erb} (98%) create mode 100644 db/migrate/20260428102113_add_entries_used_to_subscriptions.rb delete mode 100644 test/models/renewal_calculator_test.rb diff --git a/.gitignore b/.gitignore index ac1323f..20773d6 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ # Ignore key files for decrypting credentials and more. /config/credentials/*.key +TODO.org diff --git a/Gemfile.lock b/Gemfile.lock index b8a810c..ef00588 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,7 +83,7 @@ GEM bcrypt_pbkdf (1.1.2) bigdecimal (3.3.1) bindex (0.8.1) - bootsnap (1.24.0) + bootsnap (1.24.1) msgpack (~> 1.2) brakeman (8.0.4) racc @@ -366,12 +366,12 @@ GEM tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.2.2) - tailwindcss-ruby (4.2.2-aarch64-linux-gnu) - tailwindcss-ruby (4.2.2-aarch64-linux-musl) - tailwindcss-ruby (4.2.2-arm64-darwin) - tailwindcss-ruby (4.2.2-x86_64-linux-gnu) - tailwindcss-ruby (4.2.2-x86_64-linux-musl) + tailwindcss-ruby (4.2.4) + tailwindcss-ruby (4.2.4-aarch64-linux-gnu) + tailwindcss-ruby (4.2.4-aarch64-linux-musl) + tailwindcss-ruby (4.2.4-arm64-darwin) + tailwindcss-ruby (4.2.4-x86_64-linux-gnu) + tailwindcss-ruby (4.2.4-x86_64-linux-musl) thor (1.5.0) thruster (0.1.20) thruster (0.1.20-aarch64-linux) @@ -468,7 +468,7 @@ CHECKSUMS bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (3.3.1) sha256=eaa01e228be54c4f9f53bf3cc34fe3d5e845c31963e7fcc5bedb05a4e7d52218 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e - bootsnap (1.24.0) sha256=34e6dea61ff4895101aa9c10894ce30186bec73fe2279e0eb52040d8d4cec297 + bootsnap (1.24.1) sha256=d7faea1dc24aa5b22dacc049c9236b64ebf60b14dd49c615e15d8402375d39ef brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 @@ -583,12 +583,12 @@ CHECKSUMS stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 tailwindcss-rails (4.4.0) sha256=efa2961351a52acebe616e645a81a30bb4f27fde46cc06ce7688d1cd1131e916 - tailwindcss-ruby (4.2.2) sha256=ce66da7b01fb6ef1ad6485b4b8c3476fac959f3324894fd26ec7c67ab3996d30 - tailwindcss-ruby (4.2.2-aarch64-linux-gnu) sha256=8656621046bb54c9c368cd1d2f03f7bfaf6046a4fe7060c574b9958043f1deeb - tailwindcss-ruby (4.2.2-aarch64-linux-musl) sha256=3dbaa653a5e9cddbb6bc73598a566d7172a91724463000cd594624dfe5b0eaec - tailwindcss-ruby (4.2.2-arm64-darwin) sha256=2d66feba0c1ffca5b79246bd881bfb9a6b2298d57c4bc83ee3a8c3233df79d41 - tailwindcss-ruby (4.2.2-x86_64-linux-gnu) sha256=7f5e7cdd697ff25600d684cedb4df4a56736633c231ee03c7148992c62fd228f - tailwindcss-ruby (4.2.2-x86_64-linux-musl) sha256=676b802dafc677983d471f3acf2dddbddea4e978ea0300bfa21ebd6ab167d6a8 + tailwindcss-ruby (4.2.4) sha256=f3025ba442aa1436168a6df07cf44f9f43f0124a69b5375db1d359ad39c12446 + tailwindcss-ruby (4.2.4-aarch64-linux-gnu) sha256=8f73d4faf9e36ef9a4f0691cbab7b63cd0ecaf955c7878ee6a96033a29f30e8c + tailwindcss-ruby (4.2.4-aarch64-linux-musl) sha256=e96e9f5ba4743179d3731a91ea0b002383915f8bf7d6a23c9ef82b0e146a6abf + tailwindcss-ruby (4.2.4-arm64-darwin) sha256=a052b8b1307957a760ec71529341a5295177ef49e5ef391dba031ebb79fb7bfc + tailwindcss-ruby (4.2.4-x86_64-linux-gnu) sha256=8bac2ad4a1a1d7e1ff387499f322491a4bcbac622f12e396222cdd29a3499917 + tailwindcss-ruby (4.2.4-x86_64-linux-musl) sha256=a54ec6b5e7b3903328950ef7314825d10f43d581488fbb3f53c9172d40e478b7 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thruster (0.1.20) sha256=c05f2fbcae527bbe093a6e6d84fb12d9d680617e7c162325d9b97e8e9d4b5201 thruster (0.1.20-aarch64-linux) sha256=754f1701061235235165dde31e7a3bc87ec88066a02981ff4241fcda0d76d397 diff --git a/app/controllers/access_logs_controller.rb b/app/controllers/access_logs_controller.rb index 00eb92c..f3e803b 100644 --- a/app/controllers/access_logs_controller.rb +++ b/app/controllers/access_logs_controller.rb @@ -7,7 +7,9 @@ class AccessLogsController < ApplicationController def index @total_accesses = AccessLog.count @pagy, @access_logs = pagy( - AccessLogsQuery.new(filter_params).results.includes(:member, :discipline, :checkin_by_user) + AccessLog + .apply_filters(filter_params) + .includes(:member, :discipline, :checkin_by_user) ) end diff --git a/app/controllers/disciplines/members_controller.rb b/app/controllers/disciplines/members_controller.rb index 0e25d0f..6b49a0f 100644 --- a/app/controllers/disciplines/members_controller.rb +++ b/app/controllers/disciplines/members_controller.rb @@ -6,8 +6,12 @@ class Disciplines::MembersController < ApplicationController def index @products = @discipline.products.kept - query = DisciplineSubscriptionsQuery.new(filter_params, @discipline.recent_subscriptions) - @pagy, @subscriptions = pagy(query.results) + @query = @discipline.recent_subscriptions + .apply_filters(filter_params) + .includes(:product, member: [:subscriptions]) + .references(:subscriptions) + + @pagy, @subscriptions = pagy(@query) end private diff --git a/app/controllers/disciplines_controller.rb b/app/controllers/disciplines_controller.rb index 1c0edc3..6c5a871 100644 --- a/app/controllers/disciplines_controller.rb +++ b/app/controllers/disciplines_controller.rb @@ -7,7 +7,10 @@ class DisciplinesController < ApplicationController def index @total_active_disciplines = Discipline.kept.count - @pagy, @disciplines = pagy(DisciplinesQuery.new(filter_params).results) + @pagy, @disciplines = pagy( + Discipline + .apply_filters(filter_params) + ) end def show @@ -59,6 +62,6 @@ def discipline_params end def filter_params - params.permit(:query, :sort, :state) + params.permit(:query, :sort) end end diff --git a/app/controllers/members/sales_controller.rb b/app/controllers/members/sales_controller.rb index 2acce6f..9e37a37 100644 --- a/app/controllers/members/sales_controller.rb +++ b/app/controllers/members/sales_controller.rb @@ -3,14 +3,20 @@ class Members::SalesController < MembersController before_action :set_member def index - base_scope = @member.sales.includes(:product, :user) - query = SalesQuery.new(params, base_scope).results + @query = @member.sales + .apply_filters(filter_params) + .includes(:product, :user, subscription: [:product, :sales]) - @pagy, @sales = pagy(query) + @pagy, @sales = pagy(@query) + @total_amount_cents = @query.sum(:amount_cents) end private def set_member @member = Member.find(params[:member_id]) end + + def filter_params + params.permit(:query, :sort, :product_id, :payment_method, :state) + end end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index 4a6f96c..e1eb524 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -8,9 +8,10 @@ class MembersController < ApplicationController def index @total_active_members = Member.kept.count @pagy, @members = pagy( - MembersQuery.new(filter_params) - .results - .includes(subscriptions: [ :product, :sales ])) + Member + .apply_filters(filter_params) + .includes(subscriptions: [:product, :sales]) + ) end def show @@ -68,6 +69,6 @@ def member_params end def filter_params - params.permit(:query, :sort, :membership_status, :med_cert, :state) + params.permit(:query, :sort, :membership_status, :med_cert) end end diff --git a/app/controllers/products_controller.rb b/app/controllers/products_controller.rb index d75f02f..6548240 100644 --- a/app/controllers/products_controller.rb +++ b/app/controllers/products_controller.rb @@ -8,7 +8,11 @@ class ProductsController < ApplicationController def index @total_active_products = Product.kept.count - @pagy, @products = pagy(ProductsQuery.new(filter_params).results.includes(:disciplines)) + @pagy, @products = pagy( + Product + .apply_filters(filter_params) + .includes(:disciplines) + ) end def show; end @@ -68,6 +72,6 @@ def product_params end def filter_params - params.permit(:query, :sort, :state, :accounting_category) + params.permit(:query, :sort) end end diff --git a/app/controllers/sales_controller.rb b/app/controllers/sales_controller.rb index 2e8849c..f1f03a9 100644 --- a/app/controllers/sales_controller.rb +++ b/app/controllers/sales_controller.rb @@ -9,8 +9,8 @@ class SalesController < ApplicationController def index @total_active_sales = Sale.kept.count @pagy, @sales = pagy( - SalesQuery.new(filter_params) - .results + Sale + .apply_filters(filter_params) .includes(:member, :user) ) end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 5b75971..ce7c0e4 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -7,7 +7,10 @@ class UsersController < ApplicationController def index @total_active_users = User.kept.count - @pagy, @users = pagy(UsersQuery.new(filter_params).results) + @pagy, @users = pagy( + User + .apply_filters(filter_params) + ) end def show @@ -68,6 +71,6 @@ def user_params end def filter_params - params.permit(:query, :sort, :state, :role) + params.permit(:query, :sort, :role) end end diff --git a/app/helpers/subscriptions_helper.rb b/app/helpers/subscriptions_helper.rb index 14ad16e..7711837 100644 --- a/app/helpers/subscriptions_helper.rb +++ b/app/helpers/subscriptions_helper.rb @@ -76,7 +76,7 @@ def subscription_renew_action(subscription, status) end def subscription_archive_action(subscription) - return unless subscription.end_date >= 7.days.ago.to_date + return unless subscription.end_date && subscription.end_date >= 7.days.ago.to_date ui_row_delete_button([ subscription ], confirm: "Eliminando l'abbonamento annullerai l'incasso. Continuare?", title: "Archivia") end diff --git a/app/models/access_log.rb b/app/models/access_log.rb index 7ebdc57..602b61a 100644 --- a/app/models/access_log.rb +++ b/app/models/access_log.rb @@ -1,25 +1,31 @@ class AccessLog < ApplicationRecord include Refreshable + include AccessLog::Filterable - belongs_to :member, touch: true - belongs_to :subscription, optional: true, touch: true - belongs_to :checkin_by_user, class_name: "User" - belongs_to :discipline, optional: true + belongs_to :member, touch: true + belongs_to :subscription, optional: true, touch: true + belongs_to :checkin_by_user, class_name: "User" + belongs_to :discipline, optional: true enum :status, { ok: 0, warning: 1, error: 2 }, default: :ok, validate: true before_validation :set_defaults before_validation :evaluate_access_policy, on: :create + before_destroy :cache_valid_entry_state + + after_create_commit :increment_entries_used + after_destroy_commit :decrement_entries_used + validates :member, :checkin_by_user, :entered_at, presence: true - validate :prevent_double_tap, on: :create + validate :prevent_double_tap, on: :create validate :subscription_belongs_to_member - validate :subscription_must_be_active, on: :create, if: -> { status == "ok" } scope :valid_entries, -> { where(status: [ :ok, :warning ]) } private + def set_defaults self.entered_at ||= Time.current end @@ -27,37 +33,44 @@ def set_defaults def evaluate_access_policy return unless member && discipline - policy = AccessPolicy.new(member: member, discipline: discipline) - policy.evaluate! - - self.status = policy.status + policy = AccessPolicy.new(member: member, discipline: discipline).evaluate! + self.status = policy.status self.subscription = policy.subscription end def prevent_double_tap return unless member_id && discipline_id - recent_entry = AccessLog.where(member_id: member_id, discipline_id: discipline_id) - .where("entered_at >= ?", 10.minutes.ago) - .exists? - - if recent_entry + if AccessLog.where(member_id: member_id, discipline_id: discipline_id) + .where("entered_at >= ?", 10.minutes.ago) + .exists? errors.add(:base, "Check-in già effettuato negli ultimi 10 minuti.") end end - def subscription_must_be_active - if subscription.blank? - errors.add(:base, "Impossibile registrare un accesso regolare senza un abbonamento attivo.") - elsif subscription.end_date.present? && subscription.end_date < Date.current - errors.add(:subscription, "risulta scaduto.") - end - end - def subscription_belongs_to_member - return unless subscription && member - if subscription.member_id != member_id + return unless subscription_id + if subscription&.member_id != member_id errors.add(:subscription, "non appartiene a questo socio") end end + + def cache_valid_entry_state + @was_valid_entry = ok? || warning? + end + + def increment_entries_used + return unless subscription_id && (ok? || warning?) + + Subscription.where(id: subscription_id) + .update_all("entries_used = entries_used + 1") + end + + def decrement_entries_used + return unless subscription_id && @was_valid_entry + + Subscription.where(id: subscription_id) + .where("entries_used > 0") + .update_all("entries_used = entries_used - 1") + end end diff --git a/app/models/access_log/filterable.rb b/app/models/access_log/filterable.rb new file mode 100644 index 0000000..8884727 --- /dev/null +++ b/app/models/access_log/filterable.rb @@ -0,0 +1,40 @@ +module AccessLog::Filterable + extend ActiveSupport::Concern + + included do + scope :search_text, ->(query) { + return all if query.blank? + + matching_members = Member.search_text(query).select(:id) + where(access_logs: { member_id: matching_members }) + } + + scope :with_status, ->(status) { + where(access_logs: { status: status }) + } + + scope :for_discipline, ->(discipline_id) { + where(access_logs: { discipline_id: discipline_id }) + } + + scope :sorted_by, ->(param) { + case param + when "date_asc" then order(access_logs: { entered_at: :asc }) + when "date_desc" then order(access_logs: { entered_at: :desc }) + else order(access_logs: { entered_at: :desc }) + end + } + end + + class_methods do + def apply_filters(params = {}) + scope = all + + scope = scope.search_text(params[:query]) if params[:query].present? + scope = scope.with_status(params[:status]) if params[:status].present? + scope = scope.for_discipline(params[:discipline_id]) if params[:discipline_id].present? + + scope.sorted_by(params[:sort]) + end + end +end diff --git a/app/models/access_policy.rb b/app/models/access_policy.rb index b87befd..b3517d2 100644 --- a/app/models/access_policy.rb +++ b/app/models/access_policy.rb @@ -6,7 +6,6 @@ class AccessPolicy validate :check_membership validate :check_subscription - validate :check_entry_limits def initialize(attributes = {}) super @@ -43,14 +42,6 @@ def check_subscription end end - def check_entry_limits - return unless subscription && entry_limit_applies? - - if subscription.out_of_entries? - errors.add(:base, "Ingressi esauriti (#{subscription.entries_used}/#{subscription.entry_limit}).") - end - end - def evaluate_warnings if discipline.requires_medical_certificate? && !member.medical_certificate_valid? @warnings << "Certificato Medico scaduto o mancante." @@ -67,7 +58,7 @@ def evaluate_warnings end def entry_limit_applies? - subscription.entry_limit.present? && subscription.entry_limit > 0 + subscription&.entry_limit.present? && subscription.entry_limit > 0 end def subscription_expiring_soon? diff --git a/app/models/concerns/date_rangeable.rb b/app/models/concerns/date_rangeable.rb deleted file mode 100644 index a013986..0000000 --- a/app/models/concerns/date_rangeable.rb +++ /dev/null @@ -1,42 +0,0 @@ -module DateRangeable - extend ActiveSupport::Concern - - included do - scope :active_at, ->(date) { where("start_date <= ? AND end_date >= ?", date, date) } - scope :active, -> { active_at(Date.current) } - scope :expired, -> { where("end_date < ?", Date.current) } - scope :upcoming, -> { where("start_date > ?", Date.current) } - - validates :start_date, :end_date, presence: true - validate :end_date_after_start_date - end - - def active?(date = Date.current) - return false unless start_date && end_date - date.between?(start_date, end_date) - end - - def future? - start_date.present? && start_date > Date.current - end - - def expired?(date = Date.current) - end_date.present? && end_date < date - end - - def days_left - return false unless end_date - (end_date - Date.current).to_i - end - - def expiring_soon? - !future? && days_left.between?(0, 7) - end - - private - def end_date_after_start_date - if start_date && end_date && end_date < start_date - errors.add(:end_date, "deve essere successiva o uguale alla data di inizio") - end - end -end diff --git a/app/models/concerns/subscription_issuer.rb b/app/models/concerns/subscription_issuer.rb index 3ec1eb5..c06c847 100644 --- a/app/models/concerns/subscription_issuer.rb +++ b/app/models/concerns/subscription_issuer.rb @@ -5,20 +5,18 @@ module SubscriptionIssuer belongs_to :subscription, optional: true, autosave: true, touch: true accepts_nested_attributes_for :subscription, reject_if: :all_blank - after_discard :discard_subscription_if_empty + after_discard :discard_subscription_if_empty after_undiscard :undiscard_subscription validate :require_active_membership_for_courses, on: :create - validate :prevent_overlapping_subscriptions, on: :create end private def discard_subscription_if_empty return unless subscription.present? - - if subscription.sales.kept.where.not(id: id).empty? - subscription.discard! unless subscription.discarded? - end + return if subscription.discarded? + return if subscription.sales.kept.where.not(id: id).exists? + subscription.discard! end def undiscard_subscription @@ -27,23 +25,12 @@ def undiscard_subscription def require_active_membership_for_courses return if product.nil? || product.associative? - return unless subscription && subscription.start_date + return unless subscription&.start_date unless member.membership_valid?(subscription.start_date) - errors.add(:base, "Impossibile vendere #{product.name}: Il socio non avrà una Quota Associativa attiva il #{I18n.l(subscription.start_date)}.") - end - end - - def prevent_overlapping_subscriptions - return unless member && product && subscription && subscription.new_record? - return unless subscription.start_date && subscription.end_date - - overlapping = member.subscriptions.kept - .where(product_id: product.id) - .where("start_date <= ? AND end_date >= ?", subscription.end_date, subscription.start_date) - - if overlapping.exists? - errors.add(:base, "Attenzione: Il socio ha già un abbonamento per '#{product.name}' che si sovrappone a queste date (dal #{I18n.l(subscription.start_date)} al #{I18n.l(subscription.end_date)}).") + errors.add(:base, "Impossibile vendere #{product.name}: " \ + "Il socio non avrà una Quota Associativa attiva " \ + "il #{I18n.l(subscription.start_date)}.") end end end diff --git a/app/models/concerns/trackable.rb b/app/models/concerns/trackable.rb index 910fc3b..61bdb8b 100644 --- a/app/models/concerns/trackable.rb +++ b/app/models/concerns/trackable.rb @@ -1,15 +1,96 @@ module Trackable extend ActiveSupport::Concern + IGNORED_FIELDS = %w[ + updated_at + created_at + password_digest + discarded_at + ].freeze + included do has_many :activity_logs, as: :subject, dependent: :destroy + + after_create_commit :track_create + after_update_commit :track_update + after_destroy_commit :track_destroy + + after_discard :track_discard rescue nil + after_undiscard :track_undiscard rescue nil end - def log_activity(user, action, details = {}) + def log_activity(action, changes = {}) + return unless Current.user + activity_logs.create!( - user: user, - action: action, - details: details + user: Current.user, + action: action, + changes_set: changes ) end + + private + + def track_create + return unless Current.user + + activity_logs.create!( + user: Current.user, + action: "created", + changes_set: sanitized_attributes + ) + end + + def track_update + return unless Current.user + + relevant = sanitized_changes + return if relevant.blank? + + activity_logs.create!( + user: Current.user, + action: "updated", + changes_set: relevant + ) + end + + def track_destroy + return unless Current.user + + activity_logs.create!( + user: Current.user, + action: "destroyed", + changes_set: sanitized_attributes + ) + end + + def track_discard + return unless Current.user + return unless respond_to?(:discarded_at) + + activity_logs.create!( + user: Current.user, + action: "discarded", + changes_set: {} + ) + end + + def track_undiscard + return unless Current.user + return unless respond_to?(:discarded_at) + + activity_logs.create!( + user: Current.user, + action: "restored", + changes_set: {} + ) + end + + def sanitized_changes + previous_changes.except(*IGNORED_FIELDS) + end + + def sanitized_attributes + attributes.except(*IGNORED_FIELDS) + end end diff --git a/app/models/discipline.rb b/app/models/discipline.rb index 9bfef79..bae21a2 100644 --- a/app/models/discipline.rb +++ b/app/models/discipline.rb @@ -1,6 +1,7 @@ class Discipline < ApplicationRecord include SoftDeletable include Refreshable + include Discipline::Filterable has_many :product_disciplines, dependent: :destroy has_many :products, through: :product_disciplines @@ -15,7 +16,7 @@ class Discipline < ApplicationRecord def recent_subscriptions subscriptions .kept - .where("end_date >= ?", 30.days.ago) + .where("subscriptions.end_date >= ?", 30.days.ago) .includes(:member, :product) end end diff --git a/app/models/discipline/filterable.rb b/app/models/discipline/filterable.rb new file mode 100644 index 0000000..6704c2a --- /dev/null +++ b/app/models/discipline/filterable.rb @@ -0,0 +1,32 @@ +module Discipline::Filterable + extend ActiveSupport::Concern + + included do + scope :search_text, ->(query) { + return all if query.blank? + + where("disciplines.name LIKE :q", q: "%#{query}%") + } + + scope :sorted_by, ->(param, has_query = false) { + case param + when "name_asc" then order(disciplines: { name: :asc }) + when "name_desc" then order(disciplines: { name: :desc }) + when "created_asc" then order(disciplines: { created_at: :asc }) + when "created_desc" then order(disciplines: { created_at: :desc }) + else + has_query ? all : order(disciplines: { name: :asc }) + end + } + end + + class_methods do + def apply_filters(params = {}) + scope = kept + + scope = scope.search_text(params[:query]) if params[:query].present? + + scope.sorted_by(params[:sort], params[:query].present?) + end + end +end diff --git a/app/models/duration.rb b/app/models/duration.rb index 7be15b9..24af5dc 100644 --- a/app/models/duration.rb +++ b/app/models/duration.rb @@ -1,109 +1,52 @@ -# frozen_string_literal: true - -class Duration - attr_reader :product, :preference_date - +Duration = Data.define(:start_date, :end_date) do CALENDAR_DURATIONS = { - 30 => 1, # Mensile - 90 => 3, # Trimestrale - 180 => 6, # Semestrale - 365 => 12, # Annuale - 366 => 12 # Bisestile + 30 => 1, + 90 => 3, + 180 => 6, + 365 => 12, + 366 => 12 }.freeze - def initialize(product, preference_date = Date.current) - @product = product - @preference_date = preference_date.to_date + def self.for(product, preference_date = Date.current) + new(**calculate(product, preference_date.to_date)) end - def calculate - if product.associative? - calculate_associative - else - calculate_institutional - end + private_class_method def self.calculate(product, date) + product.associative? ? associative(date) : institutional(product, date) end - private - - def calculate_associative - # Regola ASD: Scade sempre alla fine dell'anno sportivo - { - start_date: preference_date, - end_date: SportYear.end_date_for(preference_date) - } - end - - def calculate_institutional - case product.duration_days - when 365, 366 - # NUOVA REGOLA ANNUALE: Rolling puro (Data scelta -> +1 anno) - # Ignora Anno Sportivo. - calculate_rolling_annual - when 90 - # NUOVA REGOLA TRIMESTRALE: Snap al 1° del mese -> +3 mesi - # Ignora Anno Sportivo. - months = CALENDAR_DURATIONS[90] - calculate_calendar_aligned(months, enforce_sport_year: false) - else - # ALTRI (es. Mensile, Semestrale): - # Mantengo la logica vecchia (Snap + Limite Anno Sportivo) per sicurezza? - # Se vuoi liberare anche loro, metti enforce_sport_year: false - months_count = CALENDAR_DURATIONS[product.duration_days] - if months_count - calculate_calendar_aligned(months_count, enforce_sport_year: true) - else - calculate_days_pure(enforce_sport_year: true) - end - end - end - - # --- CALCOLATORI SPECIFICI --- - - def calculate_rolling_annual - # Iscrizione annuale è dal giorno in cui lo fanno ad 1 anno dopo - # Esempio: 2 Gennaio 2025 -> 1 Gennaio 2026 - effective_start = preference_date - theoretical_end = effective_start.advance(years: 1).yesterday - - { start_date: effective_start, end_date: theoretical_end } - end - - def calculate_calendar_aligned(months, enforce_sport_year: true) - # Trimestrale: dal primo del mese in cui lo fanno a 3 mesi dopo - effective_start = preference_date.beginning_of_month - - # Es. 1 Gennaio + (3-1) mesi = Marzo. Fine mese = 31 Marzo. - theoretical_end = effective_start.advance(months: months - 1).end_of_month - - if enforce_sport_year - apply_sport_year_limit(effective_start, theoretical_end) - else - { start_date: effective_start, end_date: theoretical_end } - end - end - - def calculate_days_pure(enforce_sport_year: true) - effective_start = preference_date - theoretical_end = effective_start.advance(days: product.duration_days).yesterday + private_class_method def self.associative(date) + { start_date: date, end_date: SportYear.end_date_for(date) } + end - if enforce_sport_year - apply_sport_year_limit(effective_start, theoretical_end) - else - { start_date: effective_start, end_date: theoretical_end } - end + private_class_method def self.institutional(product, date) + case product.duration_days + when 365, 366 then rolling_annual(date) + when 90 then calendar_aligned(date, 3, enforce_sport_year: false) + else + months = CALENDAR_DURATIONS[product.duration_days] + months ? calendar_aligned(date, months) : days_pure(product, date) end + end - def apply_sport_year_limit(start_date, end_date) - limit_date = SportYear.end_date_for(start_date) - # Se la data di inizio è già oltre il limite (es. abbonamento comprato a fine anno per l'anno dopo), - # bisogna gestire il caso, ma per ora teniamo la logica base: - final_end = [ end_date, limit_date ].min + private_class_method def self.rolling_annual(date) + { start_date: date, end_date: date.advance(years: 1).yesterday } + end - # Safety check: se start > final_end (es. compro oggi ma l'anno è finito ieri), - # gestire eccezione o ritornare date coerenti? - # Per ora ci fidiamo che SportYear.end_date_for ritorni la fine dell'anno CORRENTE alla data. + private_class_method def self.calendar_aligned(date, months, enforce_sport_year: true) + start_date = date.beginning_of_month + theoretical_end = start_date.advance(months: months - 1).end_of_month + end_date = enforce_sport_year ? + [theoretical_end, SportYear.end_date_for(start_date)].min : + theoretical_end + { start_date:, end_date: } + end - { start_date: start_date, end_date: final_end } - end + private_class_method def self.days_pure(product, date, enforce_sport_year: true) + theoretical_end = date.advance(days: product.duration_days).yesterday + end_date = enforce_sport_year ? + [theoretical_end, SportYear.end_date_for(date)].min : + theoretical_end + { start_date: date, end_date: } + end end diff --git a/app/models/gym_profile.rb b/app/models/gym_profile.rb index de57e16..5cf0349 100644 --- a/app/models/gym_profile.rb +++ b/app/models/gym_profile.rb @@ -6,6 +6,8 @@ def self.current end def full_address - [ address_line_1, address_line_2, "#{zip_code} #{city}" ].compact.reject(&:empty?).join(" - ") + [address_line_1, address_line_2, "#{zip_code} #{city}".squish.presence] + .compact_blank + .join(" - ") end end diff --git a/app/models/member.rb b/app/models/member.rb index 10d6e5c..4e88d4b 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -1,25 +1,45 @@ class Member < ApplicationRecord include FtsSearchable, SoftDeletable, Personable, HasAddress, Avatarable include Refreshable + include Member::Filterable + + RENEWAL_GRACE_PERIOD = 30 normalizes :fiscal_code, with: ->(c) { c.strip.upcase } - has_many :sales, dependent: :restrict_with_error - has_many :access_logs, dependent: :destroy + has_many :sales, dependent: :restrict_with_error + has_many :access_logs, dependent: :destroy has_many :subscriptions, dependent: :destroy - has_many :active_subscriptions, -> { kept.where("end_date >= ?", Date.current).order(:start_date) }, class_name: "Subscription" - has_many :recent_sales, -> { order(created_at: :desc).limit(5) }, class_name: "Sale" - - has_many :memberships, -> { joins(:product).merge(Product.associative) }, + has_many :active_subscriptions, + -> { truly_active.order(subscriptions: { start_date: :asc }) }, class_name: "Subscription" + has_many :recent_sales, + -> { order(created_at: :desc).limit(5) }, + class_name: "Sale" + validates :birth_date, presence: true validates :fiscal_code, presence: true, uniqueness: { conditions: -> { kept } }, format: { with: /\A[A-Z0-9]{16}\z/ } - validates :phone, phone: { possible: true, allow_blank: true, types: [ :mobile, :fixed_line ] } + validates :phone, + phone: { possible: true, allow_blank: true, types: [:mobile, :fixed_line] } + + def suggested_start_date_for(product, reference_date = Date.current, last_sub: nil) + reference_date = reference_date.to_date + last_sub ||= subscriptions.kept + .where(product:) + .order(subscriptions: { end_date: :desc }) + .first + + return reference_date unless last_sub && last_sub.end_date + + continuity_date = last_sub.end_date.next_day + gap_days = (reference_date - continuity_date).to_i + gap_days <= RENEWAL_GRACE_PERIOD ? continuity_date : reference_date + end def medical_certificate_valid?(date = Date.current) medical_certificate_expiry.present? && medical_certificate_expiry >= date @@ -31,34 +51,55 @@ def compliant?(date = Date.current) def membership_valid?(date = Date.current) if subscriptions.loaded? - subscriptions.any? do |sub| - !sub.discarded? && - sub.product&.accounting_category == Product.accounting_categories[:associative] && - sub.end_date.present? && - sub.end_date >= date + subscriptions.any? do |s| + s.kept? && + s.product&.associative? && + s.start_date && s.start_date <= date && + s.end_date && s.end_date >= date && + (s.entry_limit.nil? || s.entry_limit.zero? || s.entries_used < s.entry_limit) end else - memberships.kept.where("end_date >= ?", date).exists? + subscriptions.truly_active_at(date) + .joins(:product) + .merge(Product.associative) + .exists? end end def valid_subscription_for(discipline) - active_subscriptions - .joins(product: :disciplines) - .find_by(disciplines: { id: discipline.id }) + active_subscriptions.for_discipline(discipline).first end def relevant_subscriptions(date = Date.current) + if subscriptions.loaded? + subscriptions + .select { |s| s.kept? && s.end_date && s.end_date >= (date - 30.days) } + .sort_by { |s| s.end_date || Date.new(1970) } + .reverse + else + subscriptions.kept + .where(subscriptions: { end_date: (date - 30.days).. }) + .order(subscriptions: { end_date: :desc }) + end + end + + def relevant_subscriptions_from_loaded(date = Date.current) + return relevant_subscriptions(date) unless subscriptions.loaded? + subscriptions - .reject(&:discarded?) - .select { |s| s.end_date && s.end_date >= (date - 30.days) } + .select { |s| s.kept? && s.end_date && s.end_date >= (date - 30.days) } .sort_by { |s| s.end_date || Date.new(1970) } .reverse end def renewal_info_for(product) - dates = RenewalCalculator.new(self, product, Date.current).call - last_sub = subscriptions.kept.where(product_id: product.id).order(end_date: :desc).first - dates.merge(last_subscription_end: last_sub&.end_date) + last_sub = subscriptions.kept + .where(product:) + .order(subscriptions: { end_date: :desc }) + .first + { + start_date: suggested_start_date_for(product, last_sub:), + last_subscription_end: last_sub&.end_date + } end end diff --git a/app/models/member/filterable.rb b/app/models/member/filterable.rb new file mode 100644 index 0000000..3140f65 --- /dev/null +++ b/app/models/member/filterable.rb @@ -0,0 +1,64 @@ +module Member::Filterable + extend ActiveSupport::Concern + + included do + scope :with_active_membership, -> { + joins(:subscriptions) + .merge(Subscription.truly_active.joins(:product).merge(Product.associative)) + .distinct + } + + scope :without_active_membership, -> { + where.not(id: with_active_membership.select(:id)) + } + + scope :without_any_membership, -> { + where.missing(:subscriptions) + } + + scope :with_valid_med_cert, -> { + where(members: { medical_certificate_expiry: Date.current.. }) + } + + scope :with_expired_med_cert, -> { + where(members: { medical_certificate_expiry: ...Date.current }) + } + + scope :without_med_cert, -> { + where(members: { medical_certificate_expiry: nil }) + } + + scope :sorted_by, ->(param) { + case param + when "name_asc" then order(members: { last_name: :asc, first_name: :asc }) + when "name_desc" then order(members: { last_name: :desc, first_name: :desc }) + when "created_asc" then order(members: { created_at: :asc }) + when "created_desc" then order(members: { created_at: :desc }) + else order(members: { updated_at: :desc }) + end + } + end + + class_methods do + def apply_filters(params = {}) + scope = kept + scope = scope.search_text(params[:query]) if params[:query].present? + + scope = case params[:membership_status] + when "active" then scope.with_active_membership + when "expired" then scope.without_active_membership + when "missing" then scope.without_any_membership + else scope + end + + scope = case params[:med_cert] + when "valid" then scope.with_valid_med_cert + when "expired" then scope.with_expired_med_cert + when "missing" then scope.without_med_cert + else scope + end + + scope.sorted_by(params[:sort]) + end + end +end diff --git a/app/models/pos_draft_builder.rb b/app/models/pos_draft_builder.rb index 6997932..4744f00 100644 --- a/app/models/pos_draft_builder.rb +++ b/app/models/pos_draft_builder.rb @@ -3,7 +3,6 @@ class PosDraftBuilder def initialize(sale_params:, context_params:, existing_sale: nil) @context_params = context_params - @sale = existing_sale || Sale.new(sale_params) @sale.build_subscription unless @sale.subscription.present? end @@ -11,19 +10,35 @@ def initialize(sale_params:, context_params:, existing_sale: nil) def build setup_base_defaults - if context_params[:installment_for_subscription_id].present? + if installment? apply_installment_logic else - handle_new_subscription_flow + apply_renewal_template if renewing? + reset_draft_if_changed if form_submitted? + sync_subscription_identity + apply_smart_dates + apply_default_price end - sync_nested_data sale end private + + def installment? + context_params[:installment_for_subscription_id].present? + end + + def renewing? + context_params[:renew_subscription_id].present? + end + + def form_submitted? + context_params.has_key?(:sale) + end + def setup_base_defaults - sale.sold_on ||= Date.current + sale.sold_on ||= Date.current sale.member_id ||= context_params[:preset_member_id] || context_params[:member_id] end @@ -36,74 +51,68 @@ def apply_installment_logic sale.product_id ||= sub.product_id if sale.amount.blank? || sale.amount.zero? - missing_cents = sub.agreed_price_cents - sub.amount_paid - sale.amount_cents = [ missing_cents, 0 ].max + sale.amount_cents = [sub.agreed_price_cents - sub.amount_paid, 0].max end end - def handle_new_subscription_flow - if autosubmit? - reset_draft_if_changed - elsif context_params[:renew_subscription_id].present? - apply_renewal_template - end + def apply_renewal_template + old_sub = Subscription.find_by(id: context_params[:renew_subscription_id]) + return unless old_sub - apply_default_price + sale.product_id ||= old_sub.product_id + sale.member_id ||= old_sub.member_id + + sale.subscription.start_date = (old_sub.end_date && old_sub.end_date >= Date.current) ? + old_sub.end_date + 1.day : + Date.current end - def apply_default_price - if sale.product_id.present? - if sale.subscription.agreed_price.blank? || sale.subscription.agreed_price.zero? - sale.subscription.agreed_price = sale.product.price - end - - if sale.amount.blank? || sale.amount.zero? - sale.amount = sale.subscription.agreed_price - end + def reset_draft_if_changed + prev_product = context_params[:previous_product_id].to_i + prev_member = context_params[:previous_member_id].to_i + + if prev_product != sale.product_id.to_i || prev_member != sale.member_id.to_i + sale.amount = nil + sale.subscription.start_date = nil + sale.subscription.end_date = nil + sale.subscription.agreed_price = nil end end - def sync_nested_data - return unless sale.subscription.new_record? && sale.member.present? && sale.product.present? + # Assegna le associazioni alla subscription prima di calcolare date e prezzi + def sync_subscription_identity + return unless sale.subscription.new_record? sale.subscription.member ||= sale.member sale.subscription.product ||= sale.product + end + + def apply_smart_dates + return unless sale.subscription.new_record? && + sale.member.present? && + sale.product.present? manual_start = context_params.dig(:sale, :subscription_attributes, :start_date) if context_params[:override_end_date] == "1" manual_end = context_params.dig(:sale, :subscription_attributes, :end_date) - sale.subscription.end_date = manual_end if manual_end.present? + sale.subscription.end_date = manual_end.presence else sale.subscription.end_date = nil end - sale.subscription.assign_smart_dates(manual_start_date: manual_start) + sale.subscription.calculate_dates!(manual_start_date: manual_start) end - def reset_draft_if_changed - prev_product = context_params[:previous_product_id] - prev_member = context_params[:previous_member_id] + def apply_default_price + return unless sale.product_id.present? - if prev_product.to_s != sale.product_id.to_s || prev_member.to_s != sale.member_id.to_s - sale.amount = nil - sale.subscription.start_date = nil - sale.subscription.end_date = nil - sale.subscription.agreed_price = nil + if sale.subscription.agreed_price.blank? || sale.subscription.agreed_price.zero? + sale.subscription.agreed_price = sale.product.price end - end - def apply_renewal_template - old_sub = Subscription.find_by(id: context_params[:renew_subscription_id]) - return unless old_sub - - sale.product_id ||= old_sub.product_id - sale.member_id ||= old_sub.member_id - - sale.subscription.start_date = old_sub.end_date >= Date.current ? (old_sub.end_date + 1.day) : Date.current - end - - def autosubmit? - context_params.has_key?(:sale) + if sale.amount.blank? || sale.amount.zero? + sale.amount = sale.subscription.agreed_price + end end end diff --git a/app/models/product.rb b/app/models/product.rb index 28e9848..73d17a7 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -1,5 +1,6 @@ class Product < ApplicationRecord include SoftDeletable, Monetizable, Refreshable + include Product::Filterable monetize :price diff --git a/app/models/product/filterable.rb b/app/models/product/filterable.rb new file mode 100644 index 0000000..d6c0264 --- /dev/null +++ b/app/models/product/filterable.rb @@ -0,0 +1,44 @@ +module Product::Filterable + extend ActiveSupport::Concern + + included do + scope :search_text, ->(query) { + return all if query.blank? + + where("products.name LIKE :q", q: "%#{query}%") + } + + scope :with_accounting_category, ->(category) { + where(products: { accounting_category: category }) + } + + scope :sorted_by, ->(param, has_query = false) { + case param + when "name_asc" then order(products: { name: :asc }) + when "name_desc" then order(products: { name: :desc }) + when "price_asc" then order(products: { price_cents: :asc }) + when "price_desc" then order(products: { price_cents: :desc }) + when "created_asc" then order(products: { created_at: :asc }) + when "created_desc" then order(products: { created_at: :desc }) + else + has_query ? all : order(products: { created_at: :desc }) + end + } + end + + class_methods do + def apply_filters(params = {}) + scope = kept + + scope = scope.search_text(params[:query]) if params[:query].present? + + scope = case params[:accounting_category] + when "institutional" then scope.with_accounting_category(:institutional) + when "associative" then scope.with_accounting_category(:associative) + else scope + end + + scope.sorted_by(params[:sort], params[:query].present?) + end + end +end diff --git a/app/models/renewal_calculator.rb b/app/models/renewal_calculator.rb deleted file mode 100644 index 3b96f46..0000000 --- a/app/models/renewal_calculator.rb +++ /dev/null @@ -1,33 +0,0 @@ -class RenewalCalculator - GRACE_PERIOD_DAYS = 30 - - def initialize(member, product, reference_date = Date.current) - @member = member - @product = product - @reference_date = reference_date.to_date - end - - # Ritorna SOLO una Date (la data di partenza suggerita) - def call - return @reference_date unless @member && @product - - last_sub = @member.subscriptions.kept - .where(product: @product) - .order(end_date: :desc) - .first - - # Se non ha abbonamenti precedenti, parte dalla data contabile (oggi) - return @reference_date unless last_sub - - continuity_date = last_sub.end_date + 1.day - gap_days = (@reference_date - continuity_date).to_i - - # Se gap_days è negativo (anticipo) o nel periodo di grazia (0..30), uniamo l'abbonamento. - if gap_days <= GRACE_PERIOD_DAYS - continuity_date - else - # Buco troppo grande, si riparte da zero dalla data contabile - @reference_date - end - end -end diff --git a/app/models/sale.rb b/app/models/sale.rb index 4ecb0cd..5c84f1e 100644 --- a/app/models/sale.rb +++ b/app/models/sale.rb @@ -1,6 +1,8 @@ class Sale < ApplicationRecord - include SubscriptionIssuer, FiscalLockable, Monetizable, Trackable, SoftDeletable + include SoftDeletable include Refreshable + include SubscriptionIssuer, FiscalLockable, Monetizable, Trackable + include Sale::Filterable monetize :amount diff --git a/app/models/sale/filterable.rb b/app/models/sale/filterable.rb new file mode 100644 index 0000000..40a90b8 --- /dev/null +++ b/app/models/sale/filterable.rb @@ -0,0 +1,55 @@ +module Sale::Filterable + extend ActiveSupport::Concern + + included do + scope :search_text, ->(query) { + return all if query.blank? + + term = "%#{query}%" + joins(:member, :product).where( + "CAST(sales.receipt_number AS TEXT) LIKE :q " \ + "OR members.first_name LIKE :q " \ + "OR members.last_name LIKE :q " \ + "OR products.name LIKE :q", + q: term + ) + } + + scope :by_payment_method, ->(method) { + where(sales: { payment_method: method }) if method.present? + } + + scope :by_product, ->(product_id) { + where(sales: { product_id: product_id }) if product_id.present? + } + + scope :sorted_by, ->(param, has_query: false) { + case param + when "name_asc" then joins(:product).order(products: { name: :asc }) + when "name_desc" then joins(:product).order(products: { name: :desc }) + when "created_asc" then order(sales: { created_at: :asc }) + when "created_desc" then order(sales: { created_at: :desc }) + else + if param.blank? + order(sales: { sold_on: :desc, created_at: :desc }) + else + has_query ? all : order(sales: { updated_at: :desc }) + end + end + } + end + + class_methods do + def apply_filters(params = {}) + scope = params[:state] == "discarded" ? discarded : kept + + has_query = params[:query].present? + scope = scope.search_text(params[:query]) if has_query + + scope = scope.by_payment_method(params[:payment_method]) + .by_product(params[:product_id]) + + scope.sorted_by(params[:sort], has_query: has_query) + end + end +end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 3426bfd..38e6097 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -1,28 +1,53 @@ class Subscription < ApplicationRecord - include SoftDeletable, DateRangeable, Monetizable + include SoftDeletable, Monetizable + include Subscription::Filterable monetize :agreed_price - belongs_to :member, touch: true + belongs_to :member, touch: true belongs_to :product - - has_many :sales, inverse_of: :subscription, dependent: :nullify + has_many :sales, inverse_of: :subscription, dependent: :nullify has_many :access_logs, dependent: :nullify + validates :start_date, :end_date, presence: true + validate :end_date_after_start_date + + before_validation :apply_business_rules, on: :create before_validation :set_default_agreed_price, on: :create - before_validation :apply_business_rules, on: :create + + validate :prevent_overlapping_subscriptions, on: :create + + scope :active, -> { where(subscriptions: { end_date: Date.current.. }) } + scope :expired, -> { where(subscriptions: { end_date: ...Date.current }) } + scope :upcoming, -> { where(subscriptions: { start_date: (Date.current + 1.day).. }) } + + scope :truly_active_at, ->(date) { + kept + .where(subscriptions: { start_date: ..date, end_date: date.. }) + .where("subscriptions.entry_limit IS NULL OR subscriptions.entry_limit = 0 OR subscriptions.entries_used < subscriptions.entry_limit") + } + + scope :truly_active, -> { truly_active_at(Date.current) } + + scope :for_discipline, ->(discipline) { + joins(product: :disciplines).where(disciplines: { id: discipline.id }) + } def status @status ||= SubscriptionStatus.new(self) end - def assign_smart_dates(manual_start_date: nil) + def calculate_dates!(manual_start_date: nil) self.start_date = manual_start_date if manual_start_date.present? apply_business_rules end def amount_paid - sales.reject(&:discarded?).sum(&:amount_cents) + if sales.loaded? + sales.reject(&:discarded?).sum(&:amount_cents) + else + sales.kept.sum(:amount_cents) + end end def fully_paid? @@ -34,48 +59,80 @@ def unlimited_entries? end def entries_used - return 0 if unlimited_entries? - access_logs.valid_entries.count + unlimited_entries? ? 0 : self[:entries_used] end def entries_remaining return nil if unlimited_entries? - [ entry_limit - entries_used, 0 ].max + [entry_limit - entries_used, 0].max end def out_of_entries? - !unlimited_entries? && entries_remaining.zero? + !unlimited_entries? && entries_used >= entry_limit + end + + def active?(date = Date.current) + return false unless start_date && end_date + date.between?(start_date, end_date) + end + + def future? + start_date.present? && start_date > Date.current + end + + def expired?(date = Date.current) + end_date.present? && end_date < date + end + + def days_left + return nil unless end_date + (end_date - Date.current).to_i end def expiring_soon? return false unless end_date return false if out_of_entries? - - days_left.between?(0, 7) + !future? && days_left&.between?(0, 7) || false end private - def set_default_agreed_price - if (agreed_price_cents.nil? || agreed_price_cents.zero?) && product.present? - self.agreed_price_cents = product.price_cents - end - end - def apply_business_rules return unless product.present? && member.present? - self.entry_limit ||= product.respond_to?(:entry_limit) ? product.entry_limit : nil - + self.entry_limit ||= product.entry_limit return if end_date.present? if start_date.blank? - reference_date = sales.first&.sold_on || Date.current - self.start_date = RenewalCalculator.new(member, product, reference_date).call + reference_date = sales.first&.sold_on || Date.current + self.start_date = member.suggested_start_date_for(product, reference_date) end - result = Duration.new(product, start_date).calculate + duration = Duration.for(product, start_date) + self.start_date = duration.start_date + self.end_date = duration.end_date + end - self.start_date = result[:start_date] - self.end_date = result[:end_date] + def set_default_agreed_price + return unless product.present? + return unless agreed_price_cents.nil? || agreed_price_cents.zero? + self.agreed_price_cents = product.price_cents + end + + def prevent_overlapping_subscriptions + return unless member && product + return unless start_date && end_date + + if member.subscriptions.kept + .where(product_id: product.id) + .where(subscriptions: { start_date: ..end_date, end_date: start_date.. }) + .exists? + errors.add(:base, "Già un abbonamento per '#{product.name}' in queste date.") + end + end + + def end_date_after_start_date + if start_date && end_date && end_date < start_date + errors.add(:end_date, "deve essere successiva o uguale alla data di inizio") + end end end diff --git a/app/models/subscription/filterable.rb b/app/models/subscription/filterable.rb new file mode 100644 index 0000000..fa71ba1 --- /dev/null +++ b/app/models/subscription/filterable.rb @@ -0,0 +1,74 @@ +module Subscription::Filterable + extend ActiveSupport::Concern + + included do + scope :search_text, ->(query) { + return all if query.blank? + joins(:member).merge(Member.search_text(query)) + } + + scope :by_state, ->(state) { + case state + when "active" then active + when "expired" then expired + when "upcoming" then upcoming + else all + end + } + + scope :by_product, ->(product_id) { + where(subscriptions: { product_id: product_id }) if product_id.present? + } + + scope :by_membership_status, ->(status) { + return all if status.blank? + + case status + when "active" then joins(:member).merge(Member.with_active_membership) + when "expired" then joins(:member).merge(Member.without_active_membership) + when "missing" then joins(:member).merge(Member.without_any_membership) + else all + end + } + + scope :by_med_cert, ->(status) { + return all if status.blank? + + case status + when "valid" then joins(:member).merge(Member.with_valid_med_cert) + when "expired" then joins(:member).merge(Member.with_expired_med_cert) + when "missing" then joins(:member).merge(Member.without_med_cert) + else all + end + } + + scope :sorted_by, ->(param) { + case param + when "expiring_asc" then order(subscriptions: { end_date: :asc }) + when "expiring_desc" then order(subscriptions: { end_date: :desc }) + when "recent" then order(subscriptions: { created_at: :desc }) + else order(subscriptions: { end_date: :asc }) + end + } + + scope :deduplicate_by_member, -> { + where(id: select("MAX(subscriptions.id)").group("subscriptions.member_id")) + } + end + + class_methods do + def apply_filters(params = {}) + scope = all + + scope = scope.search_text(params[:query]) + .by_state(params[:state]) + .by_product(params[:product_id]) + .by_membership_status(params[:membership_status]) + .by_med_cert(params[:med_cert]) + .deduplicate_by_member + + has_query = params[:query].present? + has_query && params[:sort].blank? ? scope : scope.sorted_by(params[:sort]) + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index ee7793d..cc88558 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,7 @@ class User < ApplicationRecord include SoftDeletable, Personable, UserPreferences, Avatarable include Refreshable + include User::Filterable has_secure_password has_many :sessions, dependent: :destroy diff --git a/app/models/user/filterable.rb b/app/models/user/filterable.rb new file mode 100644 index 0000000..28f3480 --- /dev/null +++ b/app/models/user/filterable.rb @@ -0,0 +1,46 @@ +module User::Filterable + extend ActiveSupport::Concern + + included do + scope :search_text, ->(query) { + return all if query.blank? + + term = "%#{query}%" + where( + "users.first_name LIKE :q OR users.last_name LIKE :q OR users.email_address LIKE :q OR users.username LIKE :q", + q: term + ) + } + + scope :with_role, ->(role) { + where(users: { role: role }) + } + + scope :sorted_by, ->(param) { + case param + when "name_asc" then order(users: { last_name: :asc, first_name: :asc }) + when "name_desc" then order(users: { last_name: :desc, first_name: :desc }) + when "username_asc" then order(users: { username: :asc }) + when "created_asc" then order(users: { created_at: :asc }) + when "created_desc" then order(users: { created_at: :desc }) + else order(users: { updated_at: :desc }) + end + } + end + + class_methods do + def apply_filters(params = {}) + scope = kept + + scope = scope.search_text(params[:query]) if params[:query].present? + + scope = case params[:role] + when "admin" then scope.with_role(:admin) + when "staff" then scope.with_role(:staff) + else scope + end + + scope.sorted_by(params[:sort]) + end + end +end diff --git a/app/queries/access_logs_query.rb b/app/queries/access_logs_query.rb deleted file mode 100644 index 28b10fb..0000000 --- a/app/queries/access_logs_query.rb +++ /dev/null @@ -1,42 +0,0 @@ -class AccessLogsQuery < ApplicationQuery - private - def default_relation - AccessLog.all - end - - def filter_by_state(scope) - scope - end - - def filter_by_search(scope) - return scope if @params[:query].blank? - - matching_members = Member.search_text(@params[:query]) - scope.where(member_id: matching_members.select(:id)) - end - - def apply_custom_filters(scope) - scope - .then { |s| filter_by_status(s) } - .then { |s| filter_by_discipline(s) } - end - - def filter_by_status(scope) - return scope if @params[:status].blank? - scope.where(status: @params[:status]) - end - - def filter_by_discipline(scope) - return scope if @params[:discipline_id].blank? - scope.where(discipline_id: @params[:discipline_id]) - end - - def apply_sorting(scope) - case @params[:sort] - when "date_asc" then scope.order(entered_at: :asc) - when "date_desc" then scope.order(entered_at: :desc) - else - scope.order(entered_at: :desc) - end - end -end diff --git a/app/queries/application_query.rb b/app/queries/application_query.rb deleted file mode 100644 index 43795ca..0000000 --- a/app/queries/application_query.rb +++ /dev/null @@ -1,45 +0,0 @@ -class ApplicationQuery - def initialize(params = {}, relation = default_relation) - @params = params - @relation = relation - end - - def results - @relation - .then { |scope| filter_by_state(scope) } - .then { |scope| filter_by_search(scope) } - .then { |scope| apply_custom_filters(scope) } - .then { |scope| apply_sorting(scope) } - end - - private - def default_relation - raise NotImplementedError, "Le sottoclassi devono definire default_relation" - end - - def apply_custom_filters(scope) - scope - end - - def filter_by_state(scope) - return scope.discarded if @params[:state] == "discarded" - - scope.kept - end - - def filter_by_search(scope) - return scope if @params[:query].blank? - scope.search_text(@params[:query]) - end - - def apply_sorting(scope) - case @params[:sort] - when "created_asc" then scope.order(created_at: :asc) - when "name_asc" then scope.order(last_name: :asc, first_name: :asc) - when "name_desc" then scope.order(last_name: :desc, first_name: :desc) - when "created_desc" then scope.order(created_at: :desc) - else - @params[:query].present? ? scope : scope.order(updated_at: :desc) - end - end -end diff --git a/app/queries/discipline_subscriptions_query.rb b/app/queries/discipline_subscriptions_query.rb deleted file mode 100644 index 40cb7eb..0000000 --- a/app/queries/discipline_subscriptions_query.rb +++ /dev/null @@ -1,85 +0,0 @@ -class DisciplineSubscriptionsQuery < ApplicationQuery - private - def default_relation - raise ArgumentError, "Richiesta la relation base (es. @discipline.recent_subscriptions)" - end - - def filter_by_state(scope) - base_scope = super(scope) - - case @params[:state] - when "active" then base_scope.active - when "expired" then base_scope.expired - when "upcoming" then base_scope.upcoming - else base_scope - end - end - - def filter_by_search(scope) - return scope if @params[:query].blank? - scope.joins(:member).merge(Member.search_text(@params[:query])) - end - - def apply_custom_filters(scope) - # Uniamo la tabella members a prescindere se usiamo il filtro del certificato - s = @params[:med_cert].present? ? scope.joins(:member) : scope - - s.then { |q| filter_by_product(q) } - .then { |q| filter_by_membership_status(q) } - .then { |q| filter_by_med_cert(q) } - end - - def filter_by_product(scope) - return scope if @params[:product_id].blank? - - # Specifichiamo la tabella 'subscriptions' per evitare ambiguità SQL - scope.where(subscriptions: { product_id: @params[:product_id] }) - end - - def filter_by_membership_status(scope) - return scope if @params[:membership_status].blank? - - # 1. Troviamo gli ID di chi ha un "Tesseramento" (prodotto associativo) attivo in questo momento - active_member_ids = Subscription.active - .joins(:product) - .where(products: { accounting_category: "associative" }) - .select(:member_id) - - # 2. Filtriamo la query principale basandoci su quegli ID - if @params[:membership_status] == "active" - scope.where(member_id: active_member_ids) - else - scope.where.not(member_id: active_member_ids) - end - end - - def filter_by_med_cert(scope) - return scope if @params[:med_cert].blank? - - today = Date.current - - case @params[:med_cert] - when "valid" - # Scadenza futura o uguale a oggi - scope.where("members.medical_certificate_expiry >= ?", today) - when "expiring" - # Scade nei prossimi 30 giorni - scope.where(members: { medical_certificate_expiry: today..(today + 30.days) }) - when "invalid" - # Scaduto (passato) o mai inserito (NULL) - scope.where("members.medical_certificate_expiry < ? OR members.medical_certificate_expiry IS NULL", today) - else - scope - end - end - - def apply_sorting(scope) - case @params[:sort] - when "expiring_asc" then scope.order(end_date: :asc) - when "expiring_desc" then scope.order(end_date: :desc) - when "recent" then scope.order(created_at: :desc) - else - @params[:query].present? ? scope : scope.order(end_date: :asc) - end - end -end diff --git a/app/queries/disciplines_query.rb b/app/queries/disciplines_query.rb deleted file mode 100644 index af490bd..0000000 --- a/app/queries/disciplines_query.rb +++ /dev/null @@ -1,23 +0,0 @@ -class DisciplinesQuery < ApplicationQuery - private - def default_relation - Discipline.all - end - - def filter_by_search(scope) - return scope if @params[:query].blank? - - scope.where("disciplines.name LIKE ?", "%#{@params[:query]}%") - end - - def apply_sorting(scope) - case @params[:sort] - when "name_desc" then scope.order(name: :desc) - when "created_asc" then scope.order(created_at: :asc) - when "created_desc" then scope.order(created_at: :desc) - when "name_asc" then scope.order(name: :asc) - else - @params[:query].present? ? scope : scope.order(name: :asc) - end - end -end diff --git a/app/queries/members_query.rb b/app/queries/members_query.rb deleted file mode 100644 index 4a0d37d..0000000 --- a/app/queries/members_query.rb +++ /dev/null @@ -1,38 +0,0 @@ -class MembersQuery < ApplicationQuery - private - def default_relation - Member.all - end - - def apply_custom_filters(scope) - scope - .then { |s| filter_by_membership(s) } - .then { |s| filter_by_med_cert(s) } - end - - def filter_by_membership(scope) - case @params[:membership_status] - when "active" - scope.joins(:memberships).where("subscriptions.end_date >= ?", Date.current).distinct - when "expired" - scope.joins(:memberships).where("subscriptions.end_date < ?", Date.current).distinct - when "missing" - scope.where.missing(:memberships) - else - scope - end - end - - def filter_by_med_cert(scope) - case @params[:med_cert] - when "valid" - scope.where("medical_certificate_expiry >= ?", Date.current) - when "expired" - scope.where("medical_certificate_expiry < ?", Date.current) - when "missing" - scope.where(medical_certificate_expiry: nil) - else - scope - end - end -end diff --git a/app/queries/products_query.rb b/app/queries/products_query.rb deleted file mode 100644 index d163c33..0000000 --- a/app/queries/products_query.rb +++ /dev/null @@ -1,36 +0,0 @@ -class ProductsQuery < ApplicationQuery - private - def default_relation - Product.all - end - - def filter_by_search(scope) - return scope if @params[:query].blank? - - scope.where("products.name LIKE ?", "%#{@params[:query]}%") - end - - def apply_custom_filters(scope) - scope - .then { |s| filter_by_category(s) } - end - - def filter_by_category(scope) - return scope if @params[:accounting_category].blank? - - scope.where(accounting_category: @params[:accounting_category]) - end - - def apply_sorting(scope) - case @params[:sort] - when "name_asc" then scope.order(name: :asc) - when "name_desc" then scope.order(name: :desc) - when "price_asc" then scope.order(price_cents: :asc) - when "price_desc" then scope.order(price_cents: :desc) - when "created_asc" then scope.order(created_at: :asc) - when "created_desc" then scope.order(created_at: :desc) - else - @params[:query].present? ? scope : scope.order(created_at: :desc) - end - end -end diff --git a/app/queries/sales_query.rb b/app/queries/sales_query.rb deleted file mode 100644 index 712b909..0000000 --- a/app/queries/sales_query.rb +++ /dev/null @@ -1,51 +0,0 @@ -class SalesQuery < ApplicationQuery - private - def default_relation - Sale.all - end - - def apply_custom_filters(scope) - scope - .then { |s| filter_by_payment_method(s) } - .then { |s| filter_by_product(s) } - end - - def filter_by_search(scope) - return scope if @params[:query].blank? - - term = "%#{@params[:query]}%" - - scope.joins(:member, :product).where( - "CAST(sales.receipt_number AS TEXT) LIKE :q " \ - "OR members.first_name LIKE :q " \ - "OR members.last_name LIKE :q " \ - "OR products.name LIKE :q", - q: term - ) - end - - def filter_by_payment_method(scope) - return scope if @params[:payment_method].blank? - - scope.where(payment_method: @params[:payment_method]) - end - - def filter_by_product(scope) - return scope if @params[:product_id].blank? - - scope.where(product_id: @params[:product_id]) - end - - def apply_sorting(scope) - return scope.order(sold_on: :desc, created_at: :desc) if @params[:sort].blank? - - case @params[:sort] - when "name_asc" - scope.joins(:product).order("products.name ASC") - when "name_desc" - scope.joins(:product).order("products.name DESC") - else - super - end - end -end diff --git a/app/queries/users_query.rb b/app/queries/users_query.rb deleted file mode 100644 index 721a96b..0000000 --- a/app/queries/users_query.rb +++ /dev/null @@ -1,26 +0,0 @@ -class UsersQuery < ApplicationQuery - private - def default_relation - User.all - end - - def filter_by_search(scope) - return scope if @params[:query].blank? - - term = "%#{@params[:query]}%" - scope.where( - "users.first_name LIKE :q OR users.last_name LIKE :q OR users.email_address LIKE :q OR users.username LIKE :q", - q: term - ) - end - - def apply_custom_filters(scope) - filter_by_role(scope) - end - - def filter_by_role(scope) - return scope if @params[:role].blank? - - scope.where(role: @params[:role]) - end -end diff --git a/app/views/disciplines/index.html.erb b/app/views/disciplines/index.html.erb index 243402a..4f37d1e 100644 --- a/app/views/disciplines/index.html.erb +++ b/app/views/disciplines/index.html.erb @@ -37,41 +37,18 @@ class: "grow", data: { action: "input->autosubmit#submit" } %> - -
      - <%# Ordinamento (se applicabile anche a discipline) %>
      <%= render "shared/filter/sort", form: form %>
      - - <%# IL CASSETTO DEI FILTRI %> - <%= render "shared/filter/drawer", title: "Filtri Discipline" do %> -
      - - <%# Esempio di filtro: adattalo ai tuoi scope reali delle discipline %> -
      - - <%= form.select :state, state_filters, - { include_blank: "Tutti gli stati", selected: params[:state] }, - { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> -
      - -
      - <% end %> <% end %> <% end %> <%= render "shared/header/page" %> <%= turbo_frame_tag "disciplines_list" do %> - <%= render "shared/filter/active", filter_keys: @keys %> -
      <% if @disciplines.empty? %> <%= render "shared/empty_state", diff --git a/app/views/members/index.html.erb b/app/views/members/index.html.erb index be45aa5..ce53680 100644 --- a/app/views/members/index.html.erb +++ b/app/views/members/index.html.erb @@ -67,13 +67,6 @@ { include_blank: "Tutte le scadenze", selected: params[:med_cert] }, { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> - -
      - - <%= form.select :state, state_filters, - { include_blank: "Mostra solo Attivi", selected: params[:state] }, - { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> -
      <% end %> <% end %> @@ -82,7 +75,7 @@ <%= render "shared/header/page" %> <%= turbo_frame_tag "members_list" do %> - <%= render "shared/filter/active", filter_keys: [:membership_status, :med_cert, :state] %> + <%= render "shared/filter/active", filter_keys: [:membership_status, :med_cert] %> <%# --- INIZIO LISTA --- %>
      diff --git a/app/views/members/sales/index.html.erb b/app/views/members/sales/index.html.erb index 2fcd66c..b66a860 100644 --- a/app/views/members/sales/index.html.erb +++ b/app/views/members/sales/index.html.erb @@ -89,9 +89,9 @@ <%= render "shared/filter/active", filter_keys: [:product_id, :payment_method, :state] %>
      - <% if @sales.empty? %> + <% if @pagy.count == 0 %> <%= render "shared/empty_state", - icon_name: "search_off", + icon_name: "receipt", title: "Nessun acquisto trovato", message: "Prova a cambiare i criteri di ricerca o rimuovere i filtri." %> <% else %> diff --git a/app/views/products/index.html.erb b/app/views/products/index.html.erb index 4c6f99c..fedd177 100644 --- a/app/views/products/index.html.erb +++ b/app/views/products/index.html.erb @@ -36,31 +36,18 @@ class: "grow", data: { action: "input->autosubmit#submit" } %> - -
      <%= render "shared/filter/sort", form: form %>
      - - <%= render "shared/filter/drawer", title: "Filtri Prodotti" do %> -
      -

      Aggiungi qui i filtri per categoria o durata in futuro.

      -
      - <% end %> <% end %> <% end %> <%= render "shared/header/page" %> <%= turbo_frame_tag "products_list" do %> - <%= render "shared/filter/active", filter_keys: @keys %> -
      <% if @products.empty? %> <%= render "shared/empty_state", diff --git a/app/views/sales/_sale_row.html.erb b/app/views/sales/_row.html.erb similarity index 98% rename from app/views/sales/_sale_row.html.erb rename to app/views/sales/_row.html.erb index fb69d38..aa56457 100644 --- a/app/views/sales/_sale_row.html.erb +++ b/app/views/sales/_row.html.erb @@ -27,7 +27,7 @@ <%# Riga C: Metadati (Data, Ricevuta, Operatore) %>
      - <%= format_date(sale.sold_on, format: :short) %> + <%= format_date(sale.sold_on) %> #<%= display_value(sale.receipt_code) %> diff --git a/app/views/sales/index.html.erb b/app/views/sales/index.html.erb index a3b1d97..1c9ab3e 100644 --- a/app/views/sales/index.html.erb +++ b/app/views/sales/index.html.erb @@ -68,7 +68,7 @@ <%= render "shared/filter/active", filter_keys: @keys || [] %>
      - <% if @sales.empty? %> + <% if @pagy.count == 0 %> <%= render "shared/empty_state", icon_name: "receipt", title: "Nessuna vendita trovata", @@ -77,7 +77,7 @@ <%= filtered_results_counter(@pagy) %>
        - <%= render partial: "sale_row", collection: @sales, as: :sale %> + <%= render partial: "sales/row", collection: @sales, as: :sale, cached: true %>
      <%= render "shared/pagination" %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index c6e7a7d..0d000a5 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -61,13 +61,6 @@ { include_blank: "Tutti i ruoli", selected: params[:role] }, { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> - -
      - - <%= form.select :state, state_filters, - { include_blank: "Mostra solo Attivi", selected: params[:state] }, - { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> -
      <% end %> <% end %> @@ -76,7 +69,7 @@ <%= render "shared/header/page" %> <%= turbo_frame_tag "users_list" do %> - <%= render "shared/filter/active", filter_keys: [:role, :state] %> + <%= render "shared/filter/active", filter_keys: [:role] %> <%# --- INIZIO LISTA --- %>
      diff --git a/db/migrate/20260428102113_add_entries_used_to_subscriptions.rb b/db/migrate/20260428102113_add_entries_used_to_subscriptions.rb new file mode 100644 index 0000000..b108b0b --- /dev/null +++ b/db/migrate/20260428102113_add_entries_used_to_subscriptions.rb @@ -0,0 +1,5 @@ +class AddEntriesUsedToSubscriptions < ActiveRecord::Migration[8.1] + def change + add_column :subscriptions, :entries_used, :integer, default: 0, null: false + end +end diff --git a/db/structure.sql b/db/structure.sql index a7e137c..640ccfb 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -116,7 +116,7 @@ CREATE UNIQUE INDEX "idx_on_receipt_year_receipt_sequence_receipt_number_3689acd CREATE INDEX "index_sales_on_sold_on" ON "sales" ("sold_on") /*application='ActiveCore'*/; CREATE INDEX "index_sales_on_user_id" ON "sales" ("user_id") /*application='ActiveCore'*/; CREATE INDEX "index_sales_on_subscription_id" ON "sales" ("subscription_id") /*application='ActiveCore'*/; -CREATE TABLE IF NOT EXISTS "subscriptions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "end_date" date NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "start_date" date NOT NULL, "suspension_days_count" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, "entry_limit" integer, "agreed_price_cents" integer DEFAULT 0 NOT NULL /*application='ActiveCore'*/, CONSTRAINT "fk_rails_52a3b81fce" +CREATE TABLE IF NOT EXISTS "subscriptions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "discarded_at" datetime(6), "end_date" date NOT NULL, "member_id" integer NOT NULL, "product_id" integer NOT NULL, "start_date" date NOT NULL, "suspension_days_count" integer DEFAULT 0 NOT NULL, "updated_at" datetime(6) NOT NULL, "entry_limit" integer, "agreed_price_cents" integer DEFAULT 0 NOT NULL /*application='ActiveCore'*/, "entries_used" integer DEFAULT 0 NOT NULL /*application='ActiveCore'*/, CONSTRAINT "fk_rails_52a3b81fce" FOREIGN KEY ("product_id") REFERENCES "products" ("id") , CONSTRAINT "fk_rails_bfac3ecd2f" @@ -128,6 +128,7 @@ CREATE INDEX "index_subscriptions_on_member_id_and_end_date" ON "subscriptions" CREATE INDEX "index_subscriptions_on_member_id" ON "subscriptions" ("member_id") /*application='ActiveCore'*/; CREATE INDEX "index_subscriptions_on_product_id" ON "subscriptions" ("product_id") /*application='ActiveCore'*/; INSERT INTO "schema_migrations" (version) VALUES +('20260428102113'), ('20260410181021'), ('20260401155455'), ('20260401155446'), diff --git a/test/models/renewal_calculator_test.rb b/test/models/renewal_calculator_test.rb deleted file mode 100644 index ef033ba..0000000 --- a/test/models/renewal_calculator_test.rb +++ /dev/null @@ -1,87 +0,0 @@ -require "test_helper" - -class RenewalCalculatorTest < ActiveSupport::TestCase - include ActiveSupport::Testing::TimeHelpers - - setup do - @member = members(:alice) - @product = products(:yoga_monthly) - @member.subscriptions.destroy_all - end - - test "returns the reference_date if no history exists" do - today = Date.new(2025, 1, 20) - - travel_to today do - calculator = RenewalCalculator.new(@member, @product, today) - suggested_start = calculator.call - - # Senza storico, lo Stratega dice: "Parti dalla data contabile" - assert_equal today, suggested_start - end - end - - test "continuity: anticipated renewal connects to previous end_date" do - today = Date.new(2025, 1, 20) - current_expiry = Date.new(2025, 1, 31) - - travel_to today do - create_past_subscription(end_date: current_expiry) - - calculator = RenewalCalculator.new(@member, @product, today) - suggested_start = calculator.call - - # Scade il 31, lo Stratega dice: "Parti dal 1° Febbraio" - assert_equal Date.new(2025, 2, 1), suggested_start - end - end - - test "continuity: small gap (grace period) backdates to previous end_date" do - today = Date.new(2025, 1, 20) - past_expiry = Date.new(2025, 1, 5) # Gap di 15gg - - travel_to today do - create_past_subscription(end_date: past_expiry) - - calculator = RenewalCalculator.new(@member, @product, today) - suggested_start = calculator.call - - # Scaduto da poco, lo Stratega dice: "Recupera il buco, parti dal 6 Gennaio" - assert_equal Date.new(2025, 1, 6), suggested_start - end - end - - test "reset: huge gap starts fresh from reference_date" do - today = Date.new(2025, 1, 20) - past_expiry = Date.new(2024, 10, 31) # Gap enorme - - travel_to today do - create_past_subscription(end_date: past_expiry) - - calculator = RenewalCalculator.new(@member, @product, today) - suggested_start = calculator.call - - # Buco troppo grosso, lo Stratega dice: "Ricomincia da oggi" - assert_equal today, suggested_start - end - end - - private - def create_past_subscription(end_date:) - start_date = end_date.beginning_of_month - - # 1. Creiamo la vendita base - sale = Sale.create!(member: @member, user: users(:staff), product: @product, sold_on: start_date) - - # 2. Creiamo l'abbonamento (lasciando che Duration calcoli le sue date reali per passare le validazioni) - sub = Subscription.create!( - member: @member, - product: @product, - sale: sale - ) - - # 3. FORZIAMO la data di fine nel database ignorando le regole, - # solo per simulare lo scenario di questo specifico test! - sub.update_columns(end_date: end_date) - end -end From a6550809c94650a014ec5f0b33e30148c25e66a4 Mon Sep 17 00:00:00 2001 From: jcostd Date: Thu, 30 Apr 2026 12:25:10 +0200 Subject: [PATCH 32/34] Big Update --- .ruby-version | 2 +- Dockerfile | 2 +- Gemfile.lock | 16 +- .../disciplines/members_controller.rb | 3 +- app/controllers/disciplines_controller.rb | 1 + .../kiosk/access_logs_controller.rb | 30 +-- .../kiosk/disciplines_controller.rb | 22 +- .../kiosk/member_searches_controller.rb | 4 +- app/controllers/members/sales_controller.rb | 2 +- app/controllers/members_controller.rb | 2 +- app/controllers/sales_controller.rb | 2 +- app/controllers/subscriptions_controller.rb | 2 +- app/models/access_log.rb | 14 +- app/models/access_log/filterable.rb | 2 +- app/models/concerns/subscription_issuer.rb | 36 ---- app/models/concerns/user_preferences.rb | 13 +- app/models/duration.rb | 4 +- app/models/gym_profile.rb | 2 +- app/models/member.rb | 4 +- app/models/member/filterable.rb | 34 ++- app/models/pos_draft_builder.rb | 2 +- app/models/product/filterable.rb | 8 +- app/models/sale.rb | 41 +++- app/models/sale/filterable.rb | 22 ++ app/models/subscription.rb | 11 +- app/models/subscription/filterable.rb | 12 +- app/models/user/filterable.rb | 8 +- app/views/disciplines/_row.html.erb | 2 +- app/views/disciplines/index.html.erb | 2 +- app/views/disciplines/members/index.html.erb | 7 +- .../kiosk/members/_checked_in_card.html.erb | 2 +- app/views/sales/index.html.erb | 56 ++++- lib/tasks/release.rake | 14 ++ test/helpers/icons_helper_test.rb | 58 +---- .../subscription_lifecycle_test.rb | 2 +- test/models/access_log_test.rb | 7 +- .../concerns/subscription_issuer_test.rb | 200 ------------------ test/models/concerns/user_preferences_test.rb | 45 ++-- test/models/duration_test.rb | 46 ++-- test/models/sale_test.rb | 159 +++++++++++++- test/models/subscription_test.rb | 21 +- test/test_helper.rb | 34 +-- 42 files changed, 477 insertions(+), 479 deletions(-) delete mode 100644 app/models/concerns/subscription_issuer.rb create mode 100644 lib/tasks/release.rake delete mode 100644 test/models/concerns/subscription_issuer_test.rb diff --git a/.ruby-version b/.ruby-version index 4d54dad..c4e41f9 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.2 +4.0.3 diff --git a/Dockerfile b/Dockerfile index c5c8ed1..0c838b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=4.0.2 +ARG RUBY_VERSION=4.0.3 FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here diff --git a/Gemfile.lock b/Gemfile.lock index ef00588..eac3200 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,7 +118,6 @@ GEM ffi (1.17.4-aarch64-linux-musl) ffi (1.17.4-arm-linux-gnu) ffi (1.17.4-arm-linux-musl) - ffi (1.17.4-arm64-darwin) ffi (1.17.4-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-musl) fugit (1.12.1) @@ -200,8 +199,6 @@ GEM racc (~> 1.4) nokogiri (1.19.3-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.3-arm64-darwin) - racc (~> 1.4) nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.19.3-x86_64-linux-musl) @@ -350,7 +347,6 @@ GEM sqlite3 (2.9.3-aarch64-linux-musl) sqlite3 (2.9.3-arm-linux-gnu) sqlite3 (2.9.3-arm-linux-musl) - sqlite3 (2.9.3-arm64-darwin) sqlite3 (2.9.3-x86_64-linux-gnu) sqlite3 (2.9.3-x86_64-linux-musl) sshkit (1.25.0) @@ -369,13 +365,11 @@ GEM tailwindcss-ruby (4.2.4) tailwindcss-ruby (4.2.4-aarch64-linux-gnu) tailwindcss-ruby (4.2.4-aarch64-linux-musl) - tailwindcss-ruby (4.2.4-arm64-darwin) tailwindcss-ruby (4.2.4-x86_64-linux-gnu) tailwindcss-ruby (4.2.4-x86_64-linux-musl) thor (1.5.0) thruster (0.1.20) thruster (0.1.20-aarch64-linux) - thruster (0.1.20-arm64-darwin) thruster (0.1.20-x86_64-linux) timeout (0.6.1) tsort (0.2.0) @@ -411,8 +405,6 @@ PLATFORMS aarch64-linux-musl arm-linux-gnu arm-linux-musl - arm64-darwin-24 - x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -471,6 +463,7 @@ CHECKSUMS bootsnap (1.24.1) sha256=d7faea1dc24aa5b22dacc049c9236b64ebf60b14dd49c615e15d8402375d39ef brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler (4.0.11) sha256=5bcec0fb78302e48d02ee46f10ee6e6942be647ba5b44a6d1ddfda9a240ce785 bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab @@ -488,7 +481,6 @@ CHECKSUMS ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 - ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 @@ -524,7 +516,6 @@ CHECKSUMS nokogiri (1.19.3-aarch64-linux-musl) sha256=8392dfdcd21be7a94dbbe9ccc138dea01b97b24cb2dc02a114ca98bfb1d9a0b7 nokogiri (1.19.3-arm-linux-gnu) sha256=3919d5ffc334ad778a4a9eb88fda7dcb8b1fb58c8a52ac640c6dcd2f038e774f nokogiri (1.19.3-arm-linux-musl) sha256=9ce1cb6346bb9c67b1550eb537aa183ead91e4b6eadb2f36ade02d8dd2a79fb6 - nokogiri (1.19.3-arm64-darwin) sha256=71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42 nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976 nokogiri (1.19.3-x86_64-linux-musl) sha256=248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 @@ -576,7 +567,6 @@ CHECKSUMS sqlite3 (2.9.3-aarch64-linux-musl) sha256=ff017a36c463d02e9f0be7a6224521371128024e6a05ed16994afa5c037afbba sqlite3 (2.9.3-arm-linux-gnu) sha256=fd8b74337a66bdaf746b97d65e6c9a2faff803c8f72d6b107fb880972815d072 sqlite3 (2.9.3-arm-linux-musl) sha256=792ae9a786bb37dbdc4c443c527bc91df423aac10e472f76d5cf5a9ac6d51980 - sqlite3 (2.9.3-arm64-darwin) sha256=76b265d3d57362d3e38338f24f50a0c9cd47a4599c9cfbb578fac125d2299906 sqlite3 (2.9.3-x86_64-linux-gnu) sha256=85200a10c6cf5c60085fcca411a3168c5fba8fda3e2b1b0109ec277d7c226d46 sqlite3 (2.9.3-x86_64-linux-musl) sha256=b6d0437046d9180335dea1aa0592802e65c4f7b57409d63f14408211bf28536b sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 @@ -586,13 +576,11 @@ CHECKSUMS tailwindcss-ruby (4.2.4) sha256=f3025ba442aa1436168a6df07cf44f9f43f0124a69b5375db1d359ad39c12446 tailwindcss-ruby (4.2.4-aarch64-linux-gnu) sha256=8f73d4faf9e36ef9a4f0691cbab7b63cd0ecaf955c7878ee6a96033a29f30e8c tailwindcss-ruby (4.2.4-aarch64-linux-musl) sha256=e96e9f5ba4743179d3731a91ea0b002383915f8bf7d6a23c9ef82b0e146a6abf - tailwindcss-ruby (4.2.4-arm64-darwin) sha256=a052b8b1307957a760ec71529341a5295177ef49e5ef391dba031ebb79fb7bfc tailwindcss-ruby (4.2.4-x86_64-linux-gnu) sha256=8bac2ad4a1a1d7e1ff387499f322491a4bcbac622f12e396222cdd29a3499917 tailwindcss-ruby (4.2.4-x86_64-linux-musl) sha256=a54ec6b5e7b3903328950ef7314825d10f43d581488fbb3f53c9172d40e478b7 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thruster (0.1.20) sha256=c05f2fbcae527bbe093a6e6d84fb12d9d680617e7c162325d9b97e8e9d4b5201 thruster (0.1.20-aarch64-linux) sha256=754f1701061235235165dde31e7a3bc87ec88066a02981ff4241fcda0d76d397 - thruster (0.1.20-arm64-darwin) sha256=630cf8c273f562063b92ea5ccd7a721d7ba6130cc422c823727f4744f6d0770e thruster (0.1.20-x86_64-linux) sha256=d579f252bf67aee6ba6d957e48f566b72e019d7657ba2f267a5db1e4d91d2479 timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f @@ -612,4 +600,4 @@ CHECKSUMS zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd BUNDLED WITH - 4.0.9 + 4.0.11 diff --git a/app/controllers/disciplines/members_controller.rb b/app/controllers/disciplines/members_controller.rb index 6b49a0f..85eb0a5 100644 --- a/app/controllers/disciplines/members_controller.rb +++ b/app/controllers/disciplines/members_controller.rb @@ -8,8 +8,7 @@ def index @query = @discipline.recent_subscriptions .apply_filters(filter_params) - .includes(:product, member: [:subscriptions]) - .references(:subscriptions) + .includes(:product, member: [ :subscriptions ]) @pagy, @subscriptions = pagy(@query) end diff --git a/app/controllers/disciplines_controller.rb b/app/controllers/disciplines_controller.rb index 6c5a871..820d8e7 100644 --- a/app/controllers/disciplines_controller.rb +++ b/app/controllers/disciplines_controller.rb @@ -10,6 +10,7 @@ def index @pagy, @disciplines = pagy( Discipline .apply_filters(filter_params) + .includes(:products) ) end diff --git a/app/controllers/kiosk/access_logs_controller.rb b/app/controllers/kiosk/access_logs_controller.rb index 401755b..6b58619 100644 --- a/app/controllers/kiosk/access_logs_controller.rb +++ b/app/controllers/kiosk/access_logs_controller.rb @@ -1,13 +1,10 @@ class Kiosk::AccessLogsController < Kiosk::BaseController - def create - @discipline = Discipline.find(params[:discipline_id]) - @member = Member.find(params[:member_id]) + before_action :set_discipline + before_action :set_discipline_access_log, only: [ :destroy ] + before_action :set_member, only: [ :create ] - @access_log = AccessLog.new( - member: @member, - discipline: @discipline, - checkin_by_user: current_user - ) + def create + @access_log = @discipline.access_logs.build(member: @member, checkin_by_user: current_user) if @access_log.save if @access_log.status == "ok" @@ -23,11 +20,20 @@ def create end def destroy - @discipline = Discipline.find(params[:discipline_id]) - @access_log = @discipline.access_logs.find(params[:id]) - @access_log.destroy - redirect_to kiosk_discipline_path(@discipline), notice: "Check-in annullato per #{@access_log.member.first_name}" end + + private + def set_discipline + @discipline = Discipline.find(params[:discipline_id]) + end + + def set_discipline_access_log + @access_log = @discipline.access_logs.find(params[:id]) + end + + def set_member + @member = Member.find(params[:member_id]) + end end diff --git a/app/controllers/kiosk/disciplines_controller.rb b/app/controllers/kiosk/disciplines_controller.rb index fc86b8b..728119e 100644 --- a/app/controllers/kiosk/disciplines_controller.rb +++ b/app/controllers/kiosk/disciplines_controller.rb @@ -6,25 +6,15 @@ def index def show @discipline = Discipline.kept.find(params[:id]) - @today_accesses = @discipline.access_logs - .where(entered_at: Time.current.all_day) + @today_accesses = @discipline + .access_logs + .today .includes(:member) .order(entered_at: :desc) - checked_in_member_ids = @today_accesses.map(&:member_id) - - @pending_members = Member.kept - .joins(subscriptions: { product: :disciplines }) - .where(disciplines: { id: @discipline.id }) - .where("subscriptions.start_date <= :today AND subscriptions.end_date >= :today", today: Date.current) - .where(subscriptions: { discarded_at: nil }) - .distinct - - if checked_in_member_ids.any? - @pending_members = @pending_members.where.not(id: checked_in_member_ids) - end - - @pending_members = @pending_members.order(:first_name, :last_name) + .with_active_subscription_for(@discipline) + .without_recent_checkin_for(@discipline) + .order(:first_name, :last_name) end end diff --git a/app/controllers/kiosk/member_searches_controller.rb b/app/controllers/kiosk/member_searches_controller.rb index 4f30605..c9491cc 100644 --- a/app/controllers/kiosk/member_searches_controller.rb +++ b/app/controllers/kiosk/member_searches_controller.rb @@ -6,9 +6,7 @@ def index if params[:query].present? @members = Member.search_text(params[:query]).limit(10) - @checked_in_ids = @discipline.access_logs - .where(entered_at: Time.current.all_day) - .pluck(:member_id) + @checked_in_ids = @discipline.access_logs.recent_for_kiosk.pluck(:member_id) else @members = Member.none end diff --git a/app/controllers/members/sales_controller.rb b/app/controllers/members/sales_controller.rb index 9e37a37..8cfdedf 100644 --- a/app/controllers/members/sales_controller.rb +++ b/app/controllers/members/sales_controller.rb @@ -5,7 +5,7 @@ class Members::SalesController < MembersController def index @query = @member.sales .apply_filters(filter_params) - .includes(:product, :user, subscription: [:product, :sales]) + .includes(:product, :user, subscription: [ :product, :sales ]) @pagy, @sales = pagy(@query) @total_amount_cents = @query.sum(:amount_cents) diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index e1eb524..e452c4a 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -10,7 +10,7 @@ def index @pagy, @members = pagy( Member .apply_filters(filter_params) - .includes(subscriptions: [:product, :sales]) + .includes(subscriptions: [ :product, :sales ]) ) end diff --git a/app/controllers/sales_controller.rb b/app/controllers/sales_controller.rb index f1f03a9..7bf931c 100644 --- a/app/controllers/sales_controller.rb +++ b/app/controllers/sales_controller.rb @@ -94,6 +94,6 @@ def sale_params end def filter_params - params.permit(:query, :sort, :payment_method) + params.permit(:query, :sort, :state, :period, :payment_method, :accounting_category, :operator_id) end end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index 027671d..858437c 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -41,6 +41,6 @@ def set_subscription end def subscription_params - params.require(:subscription).permit([ :start_date, :end_date ]) + params.require(:subscription).permit([ :start_date, :end_date, :entry_limit ]) end end diff --git a/app/models/access_log.rb b/app/models/access_log.rb index 602b61a..f65602b 100644 --- a/app/models/access_log.rb +++ b/app/models/access_log.rb @@ -2,6 +2,9 @@ class AccessLog < ApplicationRecord include Refreshable include AccessLog::Filterable + DOUBLE_TAP_TIMEOUT = 10.minutes + KIOSK_COOLDOWN = 60.minutes + belongs_to :member, touch: true belongs_to :subscription, optional: true, touch: true belongs_to :checkin_by_user, class_name: "User" @@ -22,10 +25,11 @@ class AccessLog < ApplicationRecord validate :prevent_double_tap, on: :create validate :subscription_belongs_to_member - scope :valid_entries, -> { where(status: [ :ok, :warning ]) } + scope :valid_entries, -> { where(access_logs: { status: [ :ok, :warning ] }) } + scope :today, -> { where(access_logs: { entered_at: Time.current.all_day }) } + scope :recent_for_kiosk, -> { where("access_logs.entered_at >= ?", KIOSK_COOLDOWN.ago) } private - def set_defaults self.entered_at ||= Time.current end @@ -42,9 +46,9 @@ def prevent_double_tap return unless member_id && discipline_id if AccessLog.where(member_id: member_id, discipline_id: discipline_id) - .where("entered_at >= ?", 10.minutes.ago) - .exists? - errors.add(:base, "Check-in già effettuato negli ultimi 10 minuti.") + .where("entered_at >= ?", DOUBLE_TAP_TIMEOUT.ago) + .exists? + errors.add(:base, "Check-in già effettuato negli ultimi #{DOUBLE_TAP_TIMEOUT.in_minutes.to_i} minuti.") end end diff --git a/app/models/access_log/filterable.rb b/app/models/access_log/filterable.rb index 8884727..09cf734 100644 --- a/app/models/access_log/filterable.rb +++ b/app/models/access_log/filterable.rb @@ -5,7 +5,7 @@ module AccessLog::Filterable scope :search_text, ->(query) { return all if query.blank? - matching_members = Member.search_text(query).select(:id) + matching_members = Member.search_text(query).select("members.id") where(access_logs: { member_id: matching_members }) } diff --git a/app/models/concerns/subscription_issuer.rb b/app/models/concerns/subscription_issuer.rb deleted file mode 100644 index c06c847..0000000 --- a/app/models/concerns/subscription_issuer.rb +++ /dev/null @@ -1,36 +0,0 @@ -module SubscriptionIssuer - extend ActiveSupport::Concern - - included do - belongs_to :subscription, optional: true, autosave: true, touch: true - accepts_nested_attributes_for :subscription, reject_if: :all_blank - - after_discard :discard_subscription_if_empty - after_undiscard :undiscard_subscription - - validate :require_active_membership_for_courses, on: :create - end - - private - def discard_subscription_if_empty - return unless subscription.present? - return if subscription.discarded? - return if subscription.sales.kept.where.not(id: id).exists? - subscription.discard! - end - - def undiscard_subscription - subscription.undiscard! if subscription.present? && subscription.discarded? - end - - def require_active_membership_for_courses - return if product.nil? || product.associative? - return unless subscription&.start_date - - unless member.membership_valid?(subscription.start_date) - errors.add(:base, "Impossibile vendere #{product.name}: " \ - "Il socio non avrà una Quota Associativa attiva " \ - "il #{I18n.l(subscription.start_date)}.") - end - end -end diff --git a/app/models/concerns/user_preferences.rb b/app/models/concerns/user_preferences.rb index c9fea3e..9909214 100644 --- a/app/models/concerns/user_preferences.rb +++ b/app/models/concerns/user_preferences.rb @@ -8,14 +8,13 @@ module UserPreferences validates :theme, inclusion: { in: THEMES }, allow_blank: true validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }, allow_blank: true - end - def theme - saved_theme = super.presence - THEMES.include?(saved_theme) ? saved_theme : "corporate" - end + def theme + super.presence || "corporate" + end - def locale - super.presence || I18n.default_locale.to_s + def locale + super.presence || I18n.default_locale.to_s + end end end diff --git a/app/models/duration.rb b/app/models/duration.rb index 24af5dc..5b2bf41 100644 --- a/app/models/duration.rb +++ b/app/models/duration.rb @@ -37,7 +37,7 @@ def self.for(product, preference_date = Date.current) start_date = date.beginning_of_month theoretical_end = start_date.advance(months: months - 1).end_of_month end_date = enforce_sport_year ? - [theoretical_end, SportYear.end_date_for(start_date)].min : + [ theoretical_end, SportYear.end_date_for(start_date) ].min : theoretical_end { start_date:, end_date: } end @@ -45,7 +45,7 @@ def self.for(product, preference_date = Date.current) private_class_method def self.days_pure(product, date, enforce_sport_year: true) theoretical_end = date.advance(days: product.duration_days).yesterday end_date = enforce_sport_year ? - [theoretical_end, SportYear.end_date_for(date)].min : + [ theoretical_end, SportYear.end_date_for(date) ].min : theoretical_end { start_date: date, end_date: } end diff --git a/app/models/gym_profile.rb b/app/models/gym_profile.rb index 5cf0349..828f685 100644 --- a/app/models/gym_profile.rb +++ b/app/models/gym_profile.rb @@ -6,7 +6,7 @@ def self.current end def full_address - [address_line_1, address_line_2, "#{zip_code} #{city}".squish.presence] + [ address_line_1, address_line_2, "#{zip_code} #{city}".squish.presence ] .compact_blank .join(" - ") end diff --git a/app/models/member.rb b/app/models/member.rb index 4e88d4b..5013192 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -16,7 +16,7 @@ class Member < ApplicationRecord class_name: "Subscription" has_many :recent_sales, - -> { order(created_at: :desc).limit(5) }, + -> { order(sales: { created_at: :desc }).limit(5) }, class_name: "Sale" validates :birth_date, presence: true @@ -25,7 +25,7 @@ class Member < ApplicationRecord uniqueness: { conditions: -> { kept } }, format: { with: /\A[A-Z0-9]{16}\z/ } validates :phone, - phone: { possible: true, allow_blank: true, types: [:mobile, :fixed_line] } + phone: { possible: true, allow_blank: true, types: [ :mobile, :fixed_line ] } def suggested_start_date_for(product, reference_date = Date.current, last_sub: nil) reference_date = reference_date.to_date diff --git a/app/models/member/filterable.rb b/app/models/member/filterable.rb index 3140f65..b36f0ad 100644 --- a/app/models/member/filterable.rb +++ b/app/models/member/filterable.rb @@ -9,7 +9,19 @@ module Member::Filterable } scope :without_active_membership, -> { - where.not(id: with_active_membership.select(:id)) + where.not(id: with_active_membership.select("members.id")) + } + + scope :with_active_subscription_for, ->(discipline) { + joins(subscriptions: { product: :disciplines }) + .where(disciplines: { id: discipline.id }) + .where("subscriptions.start_date <= :today AND subscriptions.end_date >= :today", today: Date.current) + .where(subscriptions: { discarded_at: nil }) + .distinct + } + + scope :without_recent_checkin_for, ->(discipline) { + where.not(id: AccessLog.where(discipline: discipline).recent_for_kiosk.select(:member_id)) } scope :without_any_membership, -> { @@ -45,18 +57,18 @@ def apply_filters(params = {}) scope = scope.search_text(params[:query]) if params[:query].present? scope = case params[:membership_status] - when "active" then scope.with_active_membership - when "expired" then scope.without_active_membership - when "missing" then scope.without_any_membership - else scope - end + when "active" then scope.with_active_membership + when "expired" then scope.without_active_membership + when "missing" then scope.without_any_membership + else scope + end scope = case params[:med_cert] - when "valid" then scope.with_valid_med_cert - when "expired" then scope.with_expired_med_cert - when "missing" then scope.without_med_cert - else scope - end + when "valid" then scope.with_valid_med_cert + when "expired" then scope.with_expired_med_cert + when "missing" then scope.without_med_cert + else scope + end scope.sorted_by(params[:sort]) end diff --git a/app/models/pos_draft_builder.rb b/app/models/pos_draft_builder.rb index 4744f00..fdd3b46 100644 --- a/app/models/pos_draft_builder.rb +++ b/app/models/pos_draft_builder.rb @@ -51,7 +51,7 @@ def apply_installment_logic sale.product_id ||= sub.product_id if sale.amount.blank? || sale.amount.zero? - sale.amount_cents = [sub.agreed_price_cents - sub.amount_paid, 0].max + sale.amount_cents = [ sub.agreed_price_cents - sub.amount_paid, 0 ].max end end diff --git a/app/models/product/filterable.rb b/app/models/product/filterable.rb index d6c0264..e28d511 100644 --- a/app/models/product/filterable.rb +++ b/app/models/product/filterable.rb @@ -33,10 +33,10 @@ def apply_filters(params = {}) scope = scope.search_text(params[:query]) if params[:query].present? scope = case params[:accounting_category] - when "institutional" then scope.with_accounting_category(:institutional) - when "associative" then scope.with_accounting_category(:associative) - else scope - end + when "institutional" then scope.with_accounting_category(:institutional) + when "associative" then scope.with_accounting_category(:associative) + else scope + end scope.sorted_by(params[:sort], params[:query].present?) end diff --git a/app/models/sale.rb b/app/models/sale.rb index 5c84f1e..1199af2 100644 --- a/app/models/sale.rb +++ b/app/models/sale.rb @@ -1,7 +1,7 @@ class Sale < ApplicationRecord include SoftDeletable include Refreshable - include SubscriptionIssuer, FiscalLockable, Monetizable, Trackable + include FiscalLockable, Monetizable, Trackable include Sale::Filterable monetize :amount @@ -10,6 +10,13 @@ class Sale < ApplicationRecord belongs_to :user belongs_to :product + belongs_to :subscription, optional: true, autosave: true, touch: true + accepts_nested_attributes_for :subscription, reject_if: :all_blank + + after_discard :discard_subscription_if_empty + after_undiscard :undiscard_subscription + validate :require_active_membership_for_courses, on: :create + enum :payment_method, { cash: 1, credit_card: 2, bank_transfer: 3, other: 4 }, default: :credit_card, validate: true @@ -23,6 +30,16 @@ class Sale < ApplicationRecord before_validation :sync_subscription_data before_validation :assign_receipt_number, on: :create + def prepare_draft(context_params = {}) + if context_params[:manual_start_date].present? + context_params[:sale] ||= {} + context_params[:sale][:subscription_attributes] ||= {} + context_params[:sale][:subscription_attributes][:start_date] = context_params[:manual_start_date] + end + + PosDraftBuilder.new(sale_params: {}, context_params: context_params, existing_sale: self).build + end + private def sync_subscription_data return unless subscription.present? && subscription.new_record? && member.present? && product.present? @@ -47,4 +64,26 @@ def assign_receipt_number self.receipt_number = ReceiptCounter.next_number(receipt_year, receipt_sequence) end end + + def discard_subscription_if_empty + return unless subscription.present? + return if subscription.discarded? + return if subscription.sales.kept.where.not(id: id).exists? + subscription.discard! + end + + def undiscard_subscription + subscription.undiscard! if subscription.present? && subscription.discarded? + end + + def require_active_membership_for_courses + return if product.nil? || product.associative? + return unless subscription&.start_date + + unless member.membership_valid?(subscription.start_date) + errors.add(:base, "Impossibile vendere #{product.name}: " \ + "Il socio non avrà una Quota Associativa attiva " \ + "il #{I18n.l(subscription.start_date)}.") + end + end end diff --git a/app/models/sale/filterable.rb b/app/models/sale/filterable.rb index 40a90b8..ff1a1db 100644 --- a/app/models/sale/filterable.rb +++ b/app/models/sale/filterable.rb @@ -23,6 +23,25 @@ module Sale::Filterable where(sales: { product_id: product_id }) if product_id.present? } + # NUOVO: Filtro temporale rapido per le chiusure di cassa e bilanci + scope :by_period, ->(period) { + case period + when "today" then where(sales: { sold_on: Date.current }) + when "this_month" then where(sales: { sold_on: Date.current.all_month }) + when "last_month" then where(sales: { sold_on: 1.month.ago.all_month }) + when "this_year" then where(sales: { sold_on: Date.current.all_year }) + else all + end + } + + scope :by_accounting_category, ->(category) { + joins(:product).where(products: { accounting_category: category }) if category.present? + } + + scope :by_operator, ->(user_id) { + where(sales: { user_id: user_id }) if user_id.present? + } + scope :sorted_by, ->(param, has_query: false) { case param when "name_asc" then joins(:product).order(products: { name: :asc }) @@ -48,6 +67,9 @@ def apply_filters(params = {}) scope = scope.by_payment_method(params[:payment_method]) .by_product(params[:product_id]) + .by_period(params[:period]) + .by_accounting_category(params[:accounting_category]) + .by_operator(params[:operator_id]) scope.sorted_by(params[:sort], has_query: has_query) end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 38e6097..7f5399f 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -17,7 +17,7 @@ class Subscription < ApplicationRecord validate :prevent_overlapping_subscriptions, on: :create - scope :active, -> { where(subscriptions: { end_date: Date.current.. }) } + scope :active, -> { where(subscriptions: { start_date: ..Date.current, end_date: Date.current.. }) } scope :expired, -> { where(subscriptions: { end_date: ...Date.current }) } scope :upcoming, -> { where(subscriptions: { start_date: (Date.current + 1.day).. }) } @@ -64,7 +64,7 @@ def entries_used def entries_remaining return nil if unlimited_entries? - [entry_limit - entries_used, 0].max + [ entry_limit - entries_used, 0 ].max end def out_of_entries? @@ -102,13 +102,16 @@ def apply_business_rules self.entry_limit ||= product.entry_limit return if end_date.present? + was_start_provided = start_date.present? + if start_date.blank? reference_date = sales.first&.sold_on || Date.current self.start_date = member.suggested_start_date_for(product, reference_date) end - duration = Duration.for(product, start_date) - self.start_date = duration.start_date + duration = Duration.for(product, start_date) + + self.start_date = duration.start_date unless was_start_provided self.end_date = duration.end_date end diff --git a/app/models/subscription/filterable.rb b/app/models/subscription/filterable.rb index fa71ba1..5d7d212 100644 --- a/app/models/subscription/filterable.rb +++ b/app/models/subscription/filterable.rb @@ -24,9 +24,9 @@ module Subscription::Filterable return all if status.blank? case status - when "active" then joins(:member).merge(Member.with_active_membership) - when "expired" then joins(:member).merge(Member.without_active_membership) - when "missing" then joins(:member).merge(Member.without_any_membership) + when "active" then where(member_id: Member.with_active_membership.select(:id)) + when "expired" then where(member_id: Member.without_active_membership.select(:id)) + when "missing" then where(member_id: Member.without_any_membership.select(:id)) else all end } @@ -35,9 +35,9 @@ module Subscription::Filterable return all if status.blank? case status - when "valid" then joins(:member).merge(Member.with_valid_med_cert) - when "expired" then joins(:member).merge(Member.with_expired_med_cert) - when "missing" then joins(:member).merge(Member.without_med_cert) + when "valid" then where(member_id: Member.with_valid_med_cert.select(:id)) + when "expired" then where(member_id: Member.with_expired_med_cert.select(:id)) + when "missing" then where(member_id: Member.without_med_cert.select(:id)) else all end } diff --git a/app/models/user/filterable.rb b/app/models/user/filterable.rb index 28f3480..73b359b 100644 --- a/app/models/user/filterable.rb +++ b/app/models/user/filterable.rb @@ -35,10 +35,10 @@ def apply_filters(params = {}) scope = scope.search_text(params[:query]) if params[:query].present? scope = case params[:role] - when "admin" then scope.with_role(:admin) - when "staff" then scope.with_role(:staff) - else scope - end + when "admin" then scope.with_role(:admin) + when "staff" then scope.with_role(:staff) + else scope + end scope.sorted_by(params[:sort]) end diff --git a/app/views/disciplines/_row.html.erb b/app/views/disciplines/_row.html.erb index 468a09a..e99c72a 100644 --- a/app/views/disciplines/_row.html.erb +++ b/app/views/disciplines/_row.html.erb @@ -21,7 +21,7 @@ <%# Riga B: Dati numerici %>
      - <%= display_value(discipline.products.count) %> Prodotti collegati + <%= display_value(discipline.products.size) %> Prodotti collegati
      <%# Riga C: Requirements %> diff --git a/app/views/disciplines/index.html.erb b/app/views/disciplines/index.html.erb index 4f37d1e..fa71326 100644 --- a/app/views/disciplines/index.html.erb +++ b/app/views/disciplines/index.html.erb @@ -50,7 +50,7 @@ <%= turbo_frame_tag "disciplines_list" do %>
      - <% if @disciplines.empty? %> + <% if @pagy.count == 0 %> <%= render "shared/empty_state", icon_name: "category", title: "Nessuna disciplina trovata", diff --git a/app/views/disciplines/members/index.html.erb b/app/views/disciplines/members/index.html.erb index e913c8c..7b89290 100644 --- a/app/views/disciplines/members/index.html.erb +++ b/app/views/disciplines/members/index.html.erb @@ -71,11 +71,10 @@ { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> - <%# Filtro Stato Tesseramento (Member) %>
      <%= form.select :membership_status, - options_for_select([["Attivo", "active"], ["Scaduto", "expired"]], params[:membership_status]), + options_for_select([["Attivo", "active"], ["Scaduto", "expired"], ["Mancante", "missing"]], params[:membership_status]), { include_blank: "Qualsiasi" }, { class: "select w-full", data: { action: "change->autosubmit#submit" } } %>
      @@ -84,7 +83,7 @@
      <%= form.select :med_cert, - options_for_select([["Valido", "valid"], ["In Scadenza", "expiring"], ["Scaduto/Assente", "invalid"]], params[:med_cert]), + options_for_select([["Valido", "valid"], ["Scaduto", "expired"], ["Assente", "missing"]], params[:med_cert]), { include_blank: "Qualsiasi" }, { class: "select w-full", data: { action: "change->autosubmit#submit" } } %>
      @@ -98,7 +97,7 @@ <%= render "shared/filter/active", filter_keys: [:product_id, :sort, :state, :membership_status, :med_cert] %>
      - <% if @subscriptions.empty? %> + <% if @pagy.count == 0 %> <%= render "shared/empty_state", icon_name: "filter_off", title: "Nessun socio trovato", diff --git a/app/views/kiosk/members/_checked_in_card.html.erb b/app/views/kiosk/members/_checked_in_card.html.erb index 61c4631..ecb4ef9 100644 --- a/app/views/kiosk/members/_checked_in_card.html.erb +++ b/app/views/kiosk/members/_checked_in_card.html.erb @@ -31,7 +31,7 @@ <% sub = member.valid_subscription_for(log.discipline) %> <% if sub && !sub.unlimited_entries? %> - <%= icon("confirmation_number", classes: "size-3") %> PT: Rimanenti <%= sub.entries_remaining %> + <%= icon("access", classes: "size-3") %> Entrate Rimanenti: <%= sub.entries_remaining %> <% end %> diff --git a/app/views/sales/index.html.erb b/app/views/sales/index.html.erb index 1c9ab3e..6a4429a 100644 --- a/app/views/sales/index.html.erb +++ b/app/views/sales/index.html.erb @@ -53,10 +53,56 @@ <%# IL CASSETTO DEI FILTRI %> <%= render "shared/filter/drawer", title: "Filtri Vendite" do %> -
      -
      - Nessun filtro avanzato ancora configurato per le vendite. -
      +
      + +
      + + <%= form.select :state, + options_for_select([["Valide", "active"], ["Annullate", "discarded"]], params[:state]), + { include_blank: "Tutte" }, + { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> +
      + +
      + + <%= form.select :period, + options_for_select([ + ["Oggi", "today"], + ["Questo Mese", "this_month"], + ["Mese Scorso", "last_month"], + ["Quest'Anno", "this_year"] + ], params[:period]), + { include_blank: "Sempre" }, + { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> +
      + +
      + + <%= form.select :payment_method, + Sale.payment_methods.keys.map { |pm| [pm.humanize, pm] }, + { selected: params[:payment_method], include_blank: "Qualsiasi" }, + { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> +
      + +
      + + <%= form.select :accounting_category, + options_for_select([ + ["Istituzionale", "institutional"], + ["Associativo", "associative"] + ], params[:accounting_category]), + { include_blank: "Qualsiasi" }, + { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> +
      + +
      + + <%= form.select :operator_id, + options_from_collection_for_select(User.where(discarded_at: nil), :id, :username, params[:operator_id]), + { include_blank: "Tutti gli operatori" }, + { class: "select w-full", data: { action: "change->autosubmit#submit" } } %> +
      +
      <% end %> <% end %> @@ -65,7 +111,7 @@ <%= render "shared/header/page" %> <%= turbo_frame_tag "sales_list" do %> - <%= render "shared/filter/active", filter_keys: @keys || [] %> + <%= render "shared/filter/active", filter_keys: [:sort, :state, :period, :payment_method, :accounting_category, :operator_id] %>
      <% if @pagy.count == 0 %> diff --git a/lib/tasks/release.rake b/lib/tasks/release.rake new file mode 100644 index 0000000..b94913a --- /dev/null +++ b/lib/tasks/release.rake @@ -0,0 +1,14 @@ +namespace :release do + desc "Esegue tutte le migrazioni dati e le bonifiche per il lancio della Versione 1" + task v1: :environment do + puts "🚀 Inizio procedura di roll-out per la Versione 1.0..." + + puts "\n▶ Step 1: Ricostruzione indici Full-Text (FTS5)" + Rake::Task["fts:rebuild"].invoke + + puts "\n▶ Step 2: Bonifica temi utente legacy" + Rake::Task["maintenance:sanitize_themes"].invoke + + puts "\n✅ Rilascio V1 completato con successo. Sistema pronto!" + end +end diff --git a/test/helpers/icons_helper_test.rb b/test/helpers/icons_helper_test.rb index 70be4db..bc720bf 100644 --- a/test/helpers/icons_helper_test.rb +++ b/test/helpers/icons_helper_test.rb @@ -1,82 +1,46 @@ require "test_helper" class IconsHelperTest < ActionView::TestCase - # ========================================== - # TEST: Icona Esistente (Happy Path) - # ========================================== - test "icon renders existing svg with defaults" do - # Usiamo 'chat_bubble' che sappiamo esistere result = icon("chat_bubble") - # Verifica che sia un SVG assert_match /#{name.first.upcase} Logica Calendario - - grant_membership_to(@member) - end - - # --- TEST BASE --- - - test "creates sale and subscription together (Nested Attributes)" do - sale_params = { - member: @member, - user: @user, - product: @product, - sold_on: Date.current, - payment_method: :cash, - subscription_attributes: { - member: @member, - product: @product, - start_date: Date.current, - end_date: Date.current + 1.year # Manuale: non scatta logica automatica - } - } - - assert_difference [ "Sale.count", "Subscription.count" ], 1 do - sale = Sale.create!(sale_params) - assert sale.subscription.present? - assert_equal sale, sale.subscription.sale - end - end - - # --- TEST SMART RENEWAL (NUOVA LOGICA CALENDARIO) --- - - test "smart renewal: continuity for anticipated renewal snaps to month start" do - # Scenario: Oggi è 20 Gennaio. - # Il vecchio abbonamento scade il 31 Gennaio. - today = Date.new(2025, 1, 20) - current_expiry = Date.new(2025, 1, 31) - - travel_to today do - create_past_subscription(end_date: current_expiry) - - sale = create_sale_with_smart_subscription - - # LOGICA: - # 1. Fine vecchio: 31 Gennaio. - # 2. Continuità: 1 Febbraio. - # 3. Duration Snap: 1 Febbraio è già inizio mese -> OK. - - expected_start = Date.new(2025, 2, 1) - expected_end = Date.new(2025, 2, 28) - - assert_equal expected_start, sale.subscription.start_date - assert_equal expected_end, sale.subscription.end_date - end - end - - test "smart renewal: continuity (punishment) for small gap snaps to gap month start" do - # Scenario: Oggi è 20 Gennaio. - # Il vecchio abbonamento è scaduto il 5 Gennaio (Buco di 15gg). - today = Date.new(2025, 1, 20) - past_expiry = Date.new(2025, 1, 5) - - travel_to today do - create_past_subscription(end_date: past_expiry) - - sale = create_sale_with_smart_subscription - - # LOGICA: - # 1. Fine vecchio: 5 Gennaio. - # 2. Continuità "Punitiva": 6 Gennaio. - # 3. Duration Snap: Il 6 Gennaio appartiene a Gennaio -> SNAP al 1° Gennaio. - - # Risultato: Il cliente paga per Gennaio intero anche se rinnova il 20. - expected_start = Date.new(2025, 1, 1) - expected_end = Date.new(2025, 1, 31) - - assert_equal expected_start, sale.subscription.start_date - assert_equal expected_end, sale.subscription.end_date - end - end - - test "smart renewal: reset to today for huge gap snaps to current month start" do - # Scenario: Oggi è 20 Gennaio. - # Il vecchio abbonamento è scaduto a Ottobre (Buco enorme). - today = Date.new(2025, 1, 20) - past_expiry = Date.new(2024, 10, 31) - - travel_to today do - create_past_subscription(end_date: past_expiry) - - sale = create_sale_with_smart_subscription - - # LOGICA: - # 1. Buco > Grace Period -> Reset a "Oggi" (20 Gennaio). - # 2. Duration Snap: Il 20 Gennaio appartiene a Gennaio -> SNAP al 1° Gennaio. - - expected_start = Date.new(2025, 1, 1) - expected_end = Date.new(2025, 1, 31) - - assert_equal expected_start, sale.subscription.start_date - assert_equal expected_end, sale.subscription.end_date - end - end - - test "smart renewal: staff manual start date snaps to month start for calendar products" do - # Scenario: Operatore (Staff) forza inizio al 15 Gennaio dal form. - manual_date = Date.new(2025, 1, 15) - - sale_params = default_sale_params - sale_params[:subscription_attributes][:start_date] = manual_date - - sale = Sale.create!(sale_params) - - # 1. Start Date: Duration intercetta il 15 Gennaio e applica lo SNAP - # al 1° del mese per coprire i giorni antecedenti non pagati (Regola Palestra). - expected_start = Date.new(2025, 1, 1) - - # 2. End Date: Calcolata da Duration (31 Gennaio). - expected_end = Date.new(2025, 1, 31) - - assert_equal expected_start, sale.subscription.start_date - assert_equal expected_end, sale.subscription.end_date - end - - # AGGIUNGI QUESTO NUOVO TEST per blindare il potere dell'Admin - test "admin override: explicitly providing both dates completely bypasses calculation" do - start_override = Date.new(2025, 1, 15) - end_override = Date.new(2025, 3, 10) # Una data totalmente arbitraria - - sale_params = default_sale_params - sale_params[:subscription_attributes][:start_date] = start_override - sale_params[:subscription_attributes][:end_date] = end_override - - sale = Sale.create!(sale_params) - - # Il sistema accetta le date così come sono, senza snappare o ricalcolare nulla - assert_equal start_override, sale.subscription.start_date - assert_equal end_override, sale.subscription.end_date - end - - # --- TEST SOFT DELETE --- - # Questi rimangono invariati perché testano la logica del DB, non le date. - - test "discarding sale cascades to subscription" do - sale = create_sale_with_smart_subscription - subscription = sale.subscription - - sale.discard! - assert subscription.reload.discarded? - end - - test "undiscarding sale cascades to subscription" do - sale = create_sale_with_smart_subscription - sale.discard! - sale.undiscard! - assert_not sale.subscription.reload.discarded? - end - - private - - def default_sale_params - { - member: @member, - user: @user, - product: @product, - sold_on: Date.current, - payment_method: :cash, - subscription_attributes: { - member: @member, - product: @product - # start_date/end_date vuote per triggerare smart renewal - } - } - end - - def create_sale_with_smart_subscription - Sale.create!(default_sale_params) - end - - def create_past_subscription(end_date:) - # Creiamo un abbonamento passato "pulito" (es. mese precedente) - start_date = end_date.beginning_of_month - - Subscription.create!( - member: @member, - product: @product, - start_date: start_date, - end_date: end_date, - sale: Sale.create!(member: @member, user: @user, product: @product, sold_on: start_date) - ) - end -end diff --git a/test/models/concerns/user_preferences_test.rb b/test/models/concerns/user_preferences_test.rb index 72c88c0..69dd3ae 100644 --- a/test/models/concerns/user_preferences_test.rb +++ b/test/models/concerns/user_preferences_test.rb @@ -2,7 +2,6 @@ class UserPreferencesTest < ActiveSupport::TestCase def setup - # Creiamo un utente "pulito" per ogni test @user = User.new( username: "pref_tester", password: "password", @@ -13,57 +12,44 @@ def setup end test "initializes with empty preferences hash" do - # Verifica il callback after_initialize assert_not_nil @user.preferences assert_equal({}, @user.preferences) end test "can write and read theme via accessor" do - # Verifica che store_accessor funzioni - @user.theme = "dracula" - assert_equal "dracula", @user.theme - - # Verifica che sia salvato davvero nell'hash JSON - assert_equal "dracula", @user.preferences["theme"] + @user.theme = "dim" + assert_equal "dim", @user.theme + assert_equal "dim", @user.preferences["theme"] end test "validates allowed themes" do - # Caso Felice - @user.theme = "cyberpunk" + @user.theme = "business" assert @user.valid? - # Caso Errore (Tema non in lista) @user.theme = "windows_95_ugly_theme" assert_not @user.valid? assert_includes @user.errors[:theme], "is not included in the list" - # Caso Nil (Consentito da allow_nil: true) @user.theme = nil assert @user.valid? end test "returns correct theme fallback" do - # Se nil -> Default ("light") + # Il getter "theme" restituisce "corporate" come default se nil/blank @user.theme = nil - assert_equal "light", @user.theme_or_default + assert_equal "corporate", @user.theme - # Se vuoto -> Default ("light") @user.theme = "" - assert_equal "light", @user.theme_or_default + assert_equal "corporate", @user.theme - # Se settato -> Valore settato @user.theme = "dim" - assert_equal "dim", @user.theme_or_default + assert_equal "dim", @user.theme end test "validates available locales" do - # Assumiamo che :it e :en siano disponibili in config/application.rb - - # Caso Felice @user.locale = I18n.default_locale.to_s assert @user.valid? - # Caso Errore @user.locale = "klingon" assert_not @user.valid? assert_includes @user.errors[:locale], "is not included in the list" @@ -72,20 +58,21 @@ def setup test "returns correct locale fallback" do default = I18n.default_locale.to_s + # Il getter "locale" fa da fallback automatico @user.locale = nil - assert_equal default, @user.locale_or_default + assert_equal default, @user.locale - @user.locale = "it" - assert_equal "it", @user.locale_or_default + @user.locale = "it" # Assumendo che :it sia tra gli available_locales + assert_equal "it", @user.locale end test "persists preferences to database" do - @user.theme = "coffee" + # Usiamo un tema valido + @user.theme = "business" @user.save! - # Ricarichiamo dal DB per essere sicuri che sia stato salvato nel JSON loaded_user = User.find(@user.id) - assert_equal "coffee", loaded_user.preferences["theme"] - assert_equal "coffee", loaded_user.theme + assert_equal "business", loaded_user.preferences["theme"] + assert_equal "business", loaded_user.theme end end diff --git a/test/models/duration_test.rb b/test/models/duration_test.rb index 2907d54..85d8b99 100644 --- a/test/models/duration_test.rb +++ b/test/models/duration_test.rb @@ -8,47 +8,57 @@ class DurationTest < ActiveSupport::TestCase test "institutional monthly SNAPS to beginning of month and CAPS at Sport Year" do preference_date = Date.new(2025, 8, 15) - result = Duration.new(@course, preference_date).calculate + expected_start = Date.new(2025, 8, 1) + expected_end = Date.new(2025, 8, 31) - assert_equal Date.new(2025, 8, 1), result[:start_date] - assert_equal Date.new(2025, 8, 31), result[:end_date] + result = Duration.for(@course, preference_date) + assert_equal expected_start, result.start_date + assert_equal expected_end, result.end_date end test "institutional quarterly SNAPS and CROSSES Sport Year boundary" do @course.update!(duration_days: 90) - preference_date = Date.new(2025, 7, 15) - result = Duration.new(@course, preference_date).calculate + preference_date = Date.new(2025, 7, 15) + expected_start = Date.new(2025, 7, 1) + expected_end = Date.new(2025, 9, 30) - assert_equal Date.new(2025, 7, 1), result[:start_date] - assert_equal Date.new(2025, 9, 30), result[:end_date] + result = Duration.for(@course, preference_date) + assert_equal expected_start, result.start_date + assert_equal expected_end, result.end_date end test "institutional annual uses ROLLING logic and IGNORES Sport Year" do @course.update!(duration_days: 365) - preference_date = Date.new(2025, 5, 14) - result = Duration.new(@course, preference_date).calculate + preference_date = Date.new(2025, 5, 14) + expected_start = Date.new(2025, 5, 14) + expected_end = Date.new(2026, 5, 13) - assert_equal Date.new(2025, 5, 14), result[:start_date] - assert_equal Date.new(2026, 5, 13), result[:end_date] + result = Duration.for(@course, preference_date) + assert_equal expected_start, result.start_date + assert_equal expected_end, result.end_date end test "institutional custom duration uses PURE DAYS logic with Cap" do @course.update!(duration_days: 45) - preference_date = Date.new(2025, 1, 10) - result = Duration.new(@course, preference_date).calculate + preference_date = Date.new(2025, 1, 10) + expected_start = Date.new(2025, 1, 10) + expected_end = Date.new(2025, 2, 23) - assert_equal Date.new(2025, 1, 10), result[:start_date] - assert_equal Date.new(2025, 2, 23), result[:end_date] + result = Duration.for(@course, preference_date) + assert_equal expected_start, result.start_date + assert_equal expected_end, result.end_date end test "associative membership ALWAYS CAPS at Sport Year End" do preference_date = Date.new(2025, 5, 15) - result = Duration.new(@membership, preference_date).calculate + expected_start = Date.new(2025, 5, 15) + expected_end = Date.new(2025, 8, 31) - assert_equal preference_date, result[:start_date] - assert_equal Date.new(2025, 8, 31), result[:end_date] + result = Duration.for(@membership, preference_date) + assert_equal expected_start, result.start_date + assert_equal expected_end, result.end_date end end diff --git a/test/models/sale_test.rb b/test/models/sale_test.rb index 178ac48..5f23647 100644 --- a/test/models/sale_test.rb +++ b/test/models/sale_test.rb @@ -1,17 +1,22 @@ require "test_helper" class SaleTest < ActiveSupport::TestCase + include ActiveSupport::Testing::TimeHelpers + setup do Sale.delete_all ReceiptCounter.delete_all + Subscription.delete_all @member = members(:bob) @user = users(:staff) + @prod_inst = products(:yoga_monthly) @prod_inst.update_columns( name: "Yoga Course", price_cents: 5000, - accounting_category: "institutional" + accounting_category: "institutional", + duration_days: 30 ) @prod_assoc = products(:annual_membership) @@ -77,7 +82,6 @@ class SaleTest < ActiveSupport::TestCase end test "sequences are independent even with mixed payments" do - # Leggiamo l'ultimo numero emesso (potrebbe essere > 0 a causa dell'helper grant_membership_to) initial_assoc_max = Sale.where(receipt_sequence: "associative").maximum(:receipt_number).to_i s1 = Sale.create!(member: @member, product: @prod_inst, user: @user, payment_method: :cash, sold_on: Date.today) @@ -85,7 +89,6 @@ class SaleTest < ActiveSupport::TestCase assert_equal "institutional", s1.receipt_sequence s2 = Sale.create!(member: @member, product: @prod_assoc, user: @user, payment_method: :cash, sold_on: Date.today) - # Assicuriamoci che faccia +1 rispetto a quello che c'era prima assert_equal initial_assoc_max + 1, s2.receipt_number assert_equal "associative", s2.receipt_sequence @@ -149,4 +152,154 @@ class SaleTest < ActiveSupport::TestCase assert_equal forced_date, sale.subscription.start_date end + + # --- TEST SMART RENEWAL (Ex SubscriptionIssuerTest) --- + + test "creates sale and subscription together (Nested Attributes)" do + sale_params = { + member: @member, + user: @user, + product: @prod_inst, + sold_on: Date.current, + payment_method: :cash, + subscription_attributes: { + member: @member, + product: @prod_inst, + start_date: Date.current, + end_date: Date.current + 1.year + } + } + + assert_difference [ "Sale.count", "Subscription.count" ], 1 do + sale = Sale.create!(sale_params) + assert sale.subscription.present? + # Verifichiamo la corretta impostazione della relazione "has_many :sales" + assert_includes sale.subscription.sales, sale + end + end + + test "smart renewal: continuity for anticipated renewal snaps to month start" do + today = Date.new(2025, 1, 20) + current_expiry = Date.new(2025, 1, 31) + + travel_to today do + create_past_subscription(end_date: current_expiry) + sale = create_sale_with_smart_subscription + + expected_start = Date.new(2025, 2, 1) + expected_end = Date.new(2025, 2, 28) + + assert_equal expected_start, sale.subscription.start_date + assert_equal expected_end, sale.subscription.end_date + end + end + + test "smart renewal: continuity (punishment) for small gap snaps to gap month start" do + today = Date.new(2025, 1, 20) + past_expiry = Date.new(2025, 1, 5) + + travel_to today do + create_past_subscription(end_date: past_expiry) + sale = create_sale_with_smart_subscription + + expected_start = Date.new(2025, 1, 1) + expected_end = Date.new(2025, 1, 31) + + assert_equal expected_start, sale.subscription.start_date + assert_equal expected_end, sale.subscription.end_date + end + end + + test "smart renewal: reset to today for huge gap snaps to current month start" do + today = Date.new(2025, 1, 20) + past_expiry = Date.new(2024, 10, 31) + + travel_to today do + create_past_subscription(end_date: past_expiry) + sale = create_sale_with_smart_subscription + + expected_start = Date.new(2025, 1, 1) + expected_end = Date.new(2025, 1, 31) + + assert_equal expected_start, sale.subscription.start_date + assert_equal expected_end, sale.subscription.end_date + end + end + + test "smart renewal: staff manual start date snaps to month start for calendar products" do + manual_date = Date.new(2025, 1, 15) + sale_params = default_sale_params + sale_params[:subscription_attributes][:start_date] = manual_date + + sale = Sale.create!(sale_params) + + expected_start = Date.new(2025, 1, 1) + expected_end = Date.new(2025, 1, 31) + + assert_equal expected_start, sale.subscription.start_date + assert_equal expected_end, sale.subscription.end_date + end + + test "admin override: explicitly providing both dates completely bypasses calculation" do + start_override = Date.new(2025, 1, 15) + end_override = Date.new(2025, 3, 10) + + sale_params = default_sale_params + sale_params[:subscription_attributes][:start_date] = start_override + sale_params[:subscription_attributes][:end_date] = end_override + + sale = Sale.create!(sale_params) + + assert_equal start_override, sale.subscription.start_date + assert_equal end_override, sale.subscription.end_date + end + + # --- TEST SOFT DELETE --- + + test "discarding sale cascades to subscription" do + sale = create_sale_with_smart_subscription + subscription = sale.subscription + + sale.discard! + assert subscription.reload.discarded? + end + + test "undiscarding sale cascades to subscription" do + sale = create_sale_with_smart_subscription + sale.discard! + sale.undiscard! + assert_not sale.subscription.reload.discarded? + end + + private + + def default_sale_params + { + member: @member, + user: @user, + product: @prod_inst, + sold_on: Date.current, + payment_method: :cash, + subscription_attributes: { + member: @member, + product: @prod_inst + } + } + end + + def create_sale_with_smart_subscription + Sale.create!(default_sale_params) + end + + def create_past_subscription(end_date:) + start_date = end_date.beginning_of_month + + Subscription.create!( + member: @member, + product: @prod_inst, + start_date: start_date, + end_date: end_date, + sales: [ Sale.create!(member: @member, user: @user, product: @prod_inst, sold_on: start_date) ] + ) + end end diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb index 957004c..e802b2f 100644 --- a/test/models/subscription_test.rb +++ b/test/models/subscription_test.rb @@ -47,7 +47,7 @@ class SubscriptionTest < ActiveSupport::TestCase ) sub = Subscription.create!( - member: @member, product: @prod_inst, sale: sale, + member: @member, product: @prod_inst, sales: [ sale ], start_date: future_start ) @@ -64,19 +64,19 @@ class SubscriptionTest < ActiveSupport::TestCase # 1. Scaduto expired = Subscription.create!( - member: @member, product: @prod_inst, sale: sale, + member: @member, product: @prod_inst, sales: [ sale ], start_date: today - 2.months, end_date: today - 1.month ) # 2. Attivo active = Subscription.create!( - member: @member, product: @prod_inst, sale: sale, + member: @member, product: @prod_inst, sales: [ sale ], start_date: today.beginning_of_month, end_date: today.end_of_month ) # 3. Futuro upcoming = Subscription.create!( - member: @member, product: @prod_inst, sale: sale, + member: @member, product: @prod_inst, sales: [ sale ], start_date: today + 1.month, end_date: today + 2.months ) @@ -89,19 +89,20 @@ class SubscriptionTest < ActiveSupport::TestCase end test "admin override: prevents Duration calculator from modifying explicitly provided end_dates" do - invalid_end_date = Date.current + 50.days # Una data sballata + invalid_end_date = Date.current + 50.days + + sale = Sale.create!(member: @member, product: @prod_inst, user: @staff, sold_on: Date.current) subscription = Subscription.new( member: @member, - product: @product, - sale: @sale, + product: @prod_inst, + sales: [ sale ], start_date: Date.current, - end_date: invalid_end_date # Simuliamo l'Admin che la inserisce a mano + end_date: invalid_end_date ) - subscription.valid? # Scatena le before_validation + subscription.valid? - # Ora ci aspettiamo che il sistema NON l'abbia toccata! assert_equal invalid_end_date, subscription.end_date end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 091cc17..3321bc0 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -12,25 +12,29 @@ class TestCase fixtures :all # Add more helper methods to be used by all tests here... - def grant_membership_to(member, start_date: 1.month.ago) + def grant_membership_to(member, start_date: Date.current) membership_product = products(:annual_membership) staff_user = users(:staff) - # Creiamo la vendita che genera la sottoscrizione - Sale.create!( - member: member, - user: staff_user, - product: membership_product, - sold_on: start_date, - amount_cents: 3000, - payment_method: :cash, - # SubscriptionIssuer creerà la subscription automaticamente - subscription_attributes: { + base_date = start_date.beginning_of_year + + (-3..3).each do |offset| + d = base_date + offset.years + Sale.create!( member: member, - product: membership_product - # Le date vengono calcolate automaticamente (Duration) - } - ) + user: staff_user, + product: membership_product, + sold_on: d, + amount_cents: 3000, + payment_method: :cash, + subscription_attributes: { + member: member, + product: membership_product, + start_date: d, + end_date: d.end_of_year + } + ) + end end end end From 8c703459efed6ff75b01e9914269ec4b502cacd1 Mon Sep 17 00:00:00 2001 From: jcostd Date: Thu, 30 Apr 2026 13:58:17 +0200 Subject: [PATCH 33/34] Updated Pos flow --- app/controllers/sales_controller.rb | 29 ++++++---- app/models/pos_draft_builder.rb | 89 ++++++++++++++++++----------- app/models/sale.rb | 11 +--- 3 files changed, 75 insertions(+), 54 deletions(-) diff --git a/app/controllers/sales_controller.rb b/app/controllers/sales_controller.rb index 7bf931c..33f1000 100644 --- a/app/controllers/sales_controller.rb +++ b/app/controllers/sales_controller.rb @@ -29,10 +29,7 @@ def show end def new - @sale = PosDraftBuilder.new( - sale_params: sale_params_for_build, - context_params: params - ).build + @sale = build_draft(sale_params_for_build) end def create @@ -42,11 +39,7 @@ def create if @sale.save redirect_to sale_path(@sale), notice: t(".created", default: "Vendita registrata con successo.") else - @sale = PosDraftBuilder.new( - sale_params: sale_params, - context_params: params, - existing_sale: @sale - ).build + @sale = build_draft(sale_params, existing_sale: @sale) respond_to do |format| format.turbo_stream do @@ -74,6 +67,22 @@ def set_sale @sale = Sale.find(params[:id]) end + def build_draft(sale_params, existing_sale: nil) + context = params.to_unsafe_h.deep_symbolize_keys + + if context[:manual_start_date].present? + context[:sale] ||= {} + context[:sale][:subscription_attributes] ||= {} + context[:sale][:subscription_attributes][:start_date] = context[:manual_start_date] + end + + PosDraftBuilder.new( + sale_params: sale_params, + context_params: context, + existing_sale: existing_sale + ).build + end + def sale_params_for_build params.has_key?(:sale) ? sale_params : {} end @@ -81,7 +90,7 @@ def sale_params_for_build def sale_params permitted_sub_attrs = [ :start_date ] - if current_user.respond_to?(:admin?) && current_user.admin? + if current_user.admin? permitted_sub_attrs << :end_date permitted_sub_attrs << :agreed_price end diff --git a/app/models/pos_draft_builder.rb b/app/models/pos_draft_builder.rb index fdd3b46..b4117d2 100644 --- a/app/models/pos_draft_builder.rb +++ b/app/models/pos_draft_builder.rb @@ -2,7 +2,9 @@ class PosDraftBuilder attr_reader :context_params, :sale def initialize(sale_params:, context_params:, existing_sale: nil) - @context_params = context_params + @context_params = context_params.is_a?(ActionController::Parameters) ? + context_params.to_unsafe_h.deep_symbolize_keys : + context_params.deep_symbolize_keys @sale = existing_sale || Sale.new(sale_params) @sale.build_subscription unless @sale.subscription.present? end @@ -13,9 +15,9 @@ def build if installment? apply_installment_logic else - apply_renewal_template if renewing? - reset_draft_if_changed if form_submitted? + apply_renewal_template if renewing? sync_subscription_identity + reset_prices_if_identity_changed apply_smart_dates apply_default_price end @@ -24,7 +26,6 @@ def build end private - def installment? context_params[:installment_for_subscription_id].present? end @@ -33,8 +34,29 @@ def renewing? context_params[:renew_subscription_id].present? end - def form_submitted? - context_params.has_key?(:sale) + def override_end_date? + context_params[:override_end_date] == "1" + end + + def reset_prices_if_identity_changed + return unless sale.subscription.new_record? + + current_product_id = context_params.dig(:sale, :product_id).to_i + current_member_id = context_params.dig(:sale, :member_id).to_i + previous_product_id = context_params[:previous_product_id].to_i + previous_member_id = context_params[:previous_member_id].to_i + + return if previous_product_id.zero? && previous_member_id.zero? + + product_changed = previous_product_id != 0 && current_product_id != previous_product_id + member_changed = previous_member_id != 0 && current_member_id != previous_member_id + + return unless product_changed || member_changed + + sale.amount_cents = nil + sale.subscription.agreed_price_cents = nil + sale.subscription.start_date = nil + sale.subscription.end_date = nil unless override_end_date? end def setup_base_defaults @@ -46,9 +68,9 @@ def apply_installment_logic sub = Subscription.find_by(id: context_params[:installment_for_subscription_id]) return unless sub - sale.subscription = sub - sale.member_id ||= sub.member_id - sale.product_id ||= sub.product_id + sale.subscription = sub + sale.member_id ||= sub.member_id + sale.product_id ||= sub.product_id if sale.amount.blank? || sale.amount.zero? sale.amount_cents = [ sub.agreed_price_cents - sub.amount_paid, 0 ].max @@ -62,46 +84,45 @@ def apply_renewal_template sale.product_id ||= old_sub.product_id sale.member_id ||= old_sub.member_id - sale.subscription.start_date = (old_sub.end_date && old_sub.end_date >= Date.current) ? - old_sub.end_date + 1.day : - Date.current + sale.subscription.start_date ||= + if old_sub.end_date && old_sub.end_date >= Date.current + old_sub.end_date + 1.day + else + Date.current + end end - def reset_draft_if_changed - prev_product = context_params[:previous_product_id].to_i - prev_member = context_params[:previous_member_id].to_i - - if prev_product != sale.product_id.to_i || prev_member != sale.member_id.to_i - sale.amount = nil - sale.subscription.start_date = nil - sale.subscription.end_date = nil - sale.subscription.agreed_price = nil - end - end - - # Assegna le associazioni alla subscription prima di calcolare date e prezzi def sync_subscription_identity return unless sale.subscription.new_record? - sale.subscription.member ||= sale.member sale.subscription.product ||= sale.product end def apply_smart_dates - return unless sale.subscription.new_record? && - sale.member.present? && - sale.product.present? + return unless sale.subscription.new_record? && sale.member.present? && sale.product.present? manual_start = context_params.dig(:sale, :subscription_attributes, :start_date) + manual_end = context_params.dig(:sale, :subscription_attributes, :end_date) + + if manual_start.present? + sale.subscription.start_date = manual_start + elsif sale.subscription.start_date.blank? + sale.subscription.start_date = sale.member.suggested_start_date_for(sale.product, Date.current) + end + + unless manual_start.present? + duration = Duration.for(sale.product, sale.subscription.start_date) + sale.subscription.start_date = duration.start_date + end - if context_params[:override_end_date] == "1" - manual_end = context_params.dig(:sale, :subscription_attributes, :end_date) - sale.subscription.end_date = manual_end.presence + if override_end_date? && manual_end.present? + sale.subscription.end_date = manual_end else - sale.subscription.end_date = nil + duration = Duration.for(sale.product, sale.subscription.start_date) + sale.subscription.end_date = duration.end_date end - sale.subscription.calculate_dates!(manual_start_date: manual_start) + sale.subscription.entry_limit ||= sale.product.entry_limit end def apply_default_price diff --git a/app/models/sale.rb b/app/models/sale.rb index 1199af2..9cb9ab2 100644 --- a/app/models/sale.rb +++ b/app/models/sale.rb @@ -15,6 +15,7 @@ class Sale < ApplicationRecord after_discard :discard_subscription_if_empty after_undiscard :undiscard_subscription + validate :require_active_membership_for_courses, on: :create enum :payment_method, { @@ -30,16 +31,6 @@ class Sale < ApplicationRecord before_validation :sync_subscription_data before_validation :assign_receipt_number, on: :create - def prepare_draft(context_params = {}) - if context_params[:manual_start_date].present? - context_params[:sale] ||= {} - context_params[:sale][:subscription_attributes] ||= {} - context_params[:sale][:subscription_attributes][:start_date] = context_params[:manual_start_date] - end - - PosDraftBuilder.new(sale_params: {}, context_params: context_params, existing_sale: self).build - end - private def sync_subscription_data return unless subscription.present? && subscription.new_record? && member.present? && product.present? From 6130202cdff93d6b535e07470a161045c7b889ec Mon Sep 17 00:00:00 2001 From: jcostd Date: Thu, 30 Apr 2026 17:24:05 +0200 Subject: [PATCH 34/34] release: v1.0.0 - Core subscription logic and access management - Smart renewal logic with institutional snap - Membership guard for product sales - Non-blocking AccessLog registration - Security audits and CI green --- app/controllers/users_controller.rb | 2 +- app/models/sale.rb | 7 ++-- app/models/subscription.rb | 20 +++++------ .../subscription_lifecycle_test.rb | 33 +++++-------------- test/models/access_log_test.rb | 6 ++-- test/models/sale_test.rb | 23 +++++++------ 6 files changed, 42 insertions(+), 49 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ce7c0e4..895e6bb 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -71,6 +71,6 @@ def user_params end def filter_params - params.permit(:query, :sort, :role) + params.permit(:query, :sort).merge(role: params[:role]) end end diff --git a/app/models/sale.rb b/app/models/sale.rb index 9cb9ab2..c396808 100644 --- a/app/models/sale.rb +++ b/app/models/sale.rb @@ -36,6 +36,7 @@ def sync_subscription_data return unless subscription.present? && subscription.new_record? && member.present? && product.present? subscription.member ||= self.member subscription.product ||= self.product + subscription.reference_date ||= self.sold_on end def snapshot_product_details @@ -71,10 +72,12 @@ def require_active_membership_for_courses return if product.nil? || product.associative? return unless subscription&.start_date - unless member.membership_valid?(subscription.start_date) + check_date = sold_on || subscription.start_date + + unless member.membership_valid?(check_date) errors.add(:base, "Impossibile vendere #{product.name}: " \ "Il socio non avrà una Quota Associativa attiva " \ - "il #{I18n.l(subscription.start_date)}.") + "il #{I18n.l(check_date)}.") end end end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 7f5399f..dacb3cd 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -2,6 +2,8 @@ class Subscription < ApplicationRecord include SoftDeletable, Monetizable include Subscription::Filterable + attr_accessor :reference_date + monetize :agreed_price belongs_to :member, touch: true @@ -102,16 +104,13 @@ def apply_business_rules self.entry_limit ||= product.entry_limit return if end_date.present? - was_start_provided = start_date.present? - if start_date.blank? - reference_date = sales.first&.sold_on || Date.current - self.start_date = member.suggested_start_date_for(product, reference_date) + ref_date = self.reference_date || Array(sales).map(&:sold_on).compact.first || Date.current + self.start_date = member.suggested_start_date_for(product, ref_date) end - duration = Duration.for(product, start_date) - - self.start_date = duration.start_date unless was_start_provided + duration = Duration.for(product, start_date) + self.start_date = duration.start_date self.end_date = duration.end_date end @@ -126,9 +125,10 @@ def prevent_overlapping_subscriptions return unless start_date && end_date if member.subscriptions.kept - .where(product_id: product.id) - .where(subscriptions: { start_date: ..end_date, end_date: start_date.. }) - .exists? + .where(product_id: product.id) + .where.not(id: id) + .where(subscriptions: { start_date: ..end_date, end_date: start_date.. }) + .exists? errors.add(:base, "Già un abbonamento per '#{product.name}' in queste date.") end end diff --git a/test/integration/subscription_lifecycle_test.rb b/test/integration/subscription_lifecycle_test.rb index e438add..a53148b 100644 --- a/test/integration/subscription_lifecycle_test.rb +++ b/test/integration/subscription_lifecycle_test.rb @@ -61,30 +61,15 @@ class SubscriptionLifecycleTest < ActiveSupport::TestCase end test "The August 31st Wall (Institutional Snap)" do - # Simuliamo una vendita fatta il 15 Agosto per un mensile. - # NUOVA LOGICA: Poiché è istituzionale, anche se arrivi il 15, - # l'abbonamento viene "snappato" al 1° Agosto per coprire la mensilità contabile. - - # Usiamo un anno sicuro (es. 2025) - august_15 = Date.new(2025, 8, 15) - august_01 = Date.new(2025, 8, 1) # Start atteso (Snap) - august_31 = Date.new(2025, 8, 31) # End atteso (Wall) - - sale = Sale.create!( - member: @member, - user: @user, - product: @monthly_course, - sold_on: august_15, - subscription_attributes: { member: @member, product: @monthly_course } - ) - - # Verifica Inizio (FIXED: Ora ci aspettiamo il 1° del mese) - assert_equal august_01, sale.subscription.start_date, - "Institutional Snap failed: Start date should be snapped to Aug 1st" - - # Verifica Fine (Muro SportYear) - assert_equal august_31, sale.subscription.end_date, - "SportYear wall failed: Should end on Aug 31st" + travel_to Date.new(2025, 8, 15) do + sale = Sale.create!( + member: @member, user: @user, product: @monthly_course, + sold_on: Date.current, + subscription_attributes: { member: @member, product: @monthly_course } + ) + assert_equal Date.new(2025, 8, 1), sale.subscription.start_date + assert_equal Date.new(2025, 8, 31), sale.subscription.end_date + end end test "Membership Guard: Cannot buy course without active membership" do diff --git a/test/models/access_log_test.rb b/test/models/access_log_test.rb index 5339345..d5b978c 100644 --- a/test/models/access_log_test.rb +++ b/test/models/access_log_test.rb @@ -26,7 +26,7 @@ class AccessLogTest < ActiveSupport::TestCase assert_not_nil log.entered_at # Verifica che il callback abbia funzionato end - test "prevents access with expired subscription" do + test "registers access with expired subscription (non-blocking)" do # Mandiamo l'abbonamento nel passato # Start: 60 giorni fa, End: 30 giorni fa @subscription.update_columns(start_date: 60.days.ago, end_date: 30.days.ago) @@ -37,7 +37,9 @@ class AccessLogTest < ActiveSupport::TestCase checkin_by_user: @staff ) - assert_not log.valid? + # L'ingresso NON deve essere bloccato a livello di database. + # Il sistema lo salva, poi sarà la AccessPolicy a gestirne lo 'status' (ok, warning, error) + assert log.valid?, "AccessLog dovrebbe essere valido e salvabile anche con abbonamento scaduto" end test "prevents access with subscription of another member" do diff --git a/test/models/sale_test.rb b/test/models/sale_test.rb index 5f23647..acaf026 100644 --- a/test/models/sale_test.rb +++ b/test/models/sale_test.rb @@ -135,8 +135,10 @@ class SaleTest < ActiveSupport::TestCase # --- TEST LOGICA DRAFT / FORM LIVE --- test "prepare_draft sets sold_on to today if empty and builds subscription" do - sale = Sale.new(member: @member, product: @prod_inst) - sale.prepare_draft + sale = PosDraftBuilder.new( + sale_params: { member: @member, product: @prod_inst }, + context_params: {} + ).build assert_equal Date.current, sale.sold_on assert_not_nil sale.subscription @@ -145,10 +147,14 @@ class SaleTest < ActiveSupport::TestCase end test "prepare_draft with manual_start_date forces the subscription start date" do - sale = Sale.new(member: @member, product: @prod_inst) forced_date = 5.days.from_now.to_date - sale.prepare_draft(manual_start_date: forced_date.to_s) + sale = PosDraftBuilder.new( + sale_params: { member_id: @member.id, product_id: @prod_inst.id }, + context_params: { + sale: { subscription_attributes: { start_date: forced_date.to_s } } + } + ).build assert_equal forced_date, sale.subscription.start_date end @@ -200,13 +206,10 @@ class SaleTest < ActiveSupport::TestCase travel_to today do create_past_subscription(end_date: past_expiry) - sale = create_sale_with_smart_subscription - expected_start = Date.new(2025, 1, 1) - expected_end = Date.new(2025, 1, 31) - - assert_equal expected_start, sale.subscription.start_date - assert_equal expected_end, sale.subscription.end_date + assert_raises(ActiveRecord::RecordInvalid) do + create_sale_with_smart_subscription + end end end