diff --git a/app/controllers/promoter/ticket_types_controller.rb b/app/controllers/promoter/ticket_types_controller.rb new file mode 100644 index 0000000..8a5b57b --- /dev/null +++ b/app/controllers/promoter/ticket_types_controller.rb @@ -0,0 +1,104 @@ +# Promoter Ticket Types Controller +# +# Handles ticket type (bundle) management for promoters +# Allows promoters to create, edit, delete and manage ticket types for their events +class Promoter::TicketTypesController < ApplicationController + before_action :authenticate_user! + before_action :ensure_can_manage_events! + before_action :set_event + before_action :set_ticket_type, only: [:show, :edit, :update, :destroy] + + # Display all ticket types for an event + def index + @ticket_types = @event.ticket_types.order(:created_at) + end + + # Display a specific ticket type + def show + # Ticket type is set by set_ticket_type callback + end + + # Show form to create a new ticket type + def new + @ticket_type = @event.ticket_types.build + # Set default values + @ticket_type.sale_start_at = Time.current + @ticket_type.sale_end_at = @event.start_time || 1.week.from_now + @ticket_type.requires_id = false + end + + # Create a new ticket type + def create + @ticket_type = @event.ticket_types.build(ticket_type_params) + + if @ticket_type.save + redirect_to promoter_event_ticket_types_path(@event), notice: 'Type de billet créé avec succès!' + else + render :new, status: :unprocessable_entity + end + end + + # Show form to edit an existing ticket type + def edit + # Ticket type is set by set_ticket_type callback + end + + # Update an existing ticket type + def update + if @ticket_type.update(ticket_type_params) + redirect_to promoter_event_ticket_type_path(@event, @ticket_type), notice: 'Type de billet mis à jour avec succès!' + else + render :edit, status: :unprocessable_entity + end + end + + # Delete a ticket type + def destroy + if @ticket_type.tickets.any? + redirect_to promoter_event_ticket_types_path(@event), alert: 'Impossible de supprimer ce type de billet car des billets ont déjà été vendus.' + else + @ticket_type.destroy + redirect_to promoter_event_ticket_types_path(@event), notice: 'Type de billet supprimé avec succès!' + end + end + + # Duplicate an existing ticket type + def duplicate + original = @event.ticket_types.find(params[:id]) + @ticket_type = original.dup + @ticket_type.name = "#{original.name} (Copie)" + + if @ticket_type.save + redirect_to edit_promoter_event_ticket_type_path(@event, @ticket_type), notice: 'Type de billet dupliqué avec succès!' + else + redirect_to promoter_event_ticket_types_path(@event), alert: 'Erreur lors de la duplication.' + end + end + + private + + def ensure_can_manage_events! + unless current_user.can_manage_events? + redirect_to dashboard_path, alert: 'Vous n\'avez pas les permissions nécessaires pour gérer des événements.' + end + end + + def set_event + @event = current_user.events.find(params[:event_id]) + rescue ActiveRecord::RecordNotFound + redirect_to promoter_events_path, alert: 'Event non trouvé ou vous n\'avez pas accès à cet event.' + end + + def set_ticket_type + @ticket_type = @event.ticket_types.find(params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_to promoter_event_ticket_types_path(@event), alert: 'Type de billet non trouvé.' + end + + def ticket_type_params + params.require(:ticket_type).permit( + :name, :description, :price_euros, :quantity, + :sale_start_at, :sale_end_at, :minimum_age, :requires_id + ) + end +end \ No newline at end of file diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 593b714..7712b3d 100755 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -22,6 +22,9 @@ application.register("header", HeaderController); import EventFormController from "./event_form_controller" application.register("event-form", EventFormController); +import TicketTypeFormController from "./ticket_type_form_controller" +application.register("ticket-type-form", TicketTypeFormController); + diff --git a/app/javascript/controllers/ticket_type_form_controller.js b/app/javascript/controllers/ticket_type_form_controller.js new file mode 100644 index 0000000..3a7a73f --- /dev/null +++ b/app/javascript/controllers/ticket_type_form_controller.js @@ -0,0 +1,61 @@ +import { Controller } from "@hotwired/stimulus" + +// Ticket Type Form Controller +// Handles dynamic pricing calculations and form interactions +export default class extends Controller { + static targets = ["price", "quantity", "total"] + + connect() { + console.log("Ticket type form controller connected") + this.updateTotal() + } + + // Update total revenue calculation when price or quantity changes + updateTotal() { + const price = parseFloat(this.priceTarget.value) || 0 + const quantity = parseInt(this.quantityTarget.value) || 0 + const total = price * quantity + + // Format as currency + const formatter = new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2 + }) + + if (this.hasQuantityTarget && this.hasTotalTarget) { + // For new ticket types, calculate potential revenue + this.totalTarget.textContent = formatter.format(total) + } else if (this.hasTotalTarget) { + // For edit forms, calculate remaining potential revenue + const soldTickets = parseInt(this.element.dataset.soldTickets) || 0 + const remainingQuantity = Math.max(0, quantity - soldTickets) + const remainingRevenue = price * remainingQuantity + this.totalTarget.textContent = formatter.format(remainingRevenue) + } + } + + // Validate minimum quantity (for edit forms with sold tickets) + validateQuantity() { + const soldTickets = parseInt(this.element.dataset.soldTickets) || 0 + const quantity = parseInt(this.quantityTarget.value) || 0 + + if (quantity < soldTickets) { + this.quantityTarget.value = soldTickets + this.quantityTarget.setCustomValidity(`La quantité ne peut pas être inférieure à ${soldTickets} (billets déjà vendus)`) + } else { + this.quantityTarget.setCustomValidity('') + } + + this.updateTotal() + } + + // Format price input to ensure proper decimal places + formatPrice() { + const price = parseFloat(this.priceTarget.value) + if (!isNaN(price)) { + this.priceTarget.value = price.toFixed(2) + } + this.updateTotal() + } +} \ No newline at end of file diff --git a/app/models/ticket_type.rb b/app/models/ticket_type.rb index 89e53f3..d3e8073 100755 --- a/app/models/ticket_type.rb +++ b/app/models/ticket_type.rb @@ -12,7 +12,64 @@ class TicketType < ApplicationRecord validates :sale_end_at, presence: true validates :minimum_age, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }, allow_nil: true validates :event_id, presence: true + validates :requires_id, inclusion: { in: [true, false] } + # Custom validations + validate :sale_end_after_start + validate :sale_times_within_event_period + + # Scopes + scope :available_now, -> { where("sale_start_at <= ? AND sale_end_at >= ?", Time.current, Time.current) } + scope :upcoming, -> { where("sale_start_at > ?", Time.current) } + scope :expired, -> { where("sale_end_at < ?", Time.current) } + + # Helper methods + def price_euros + return 0.0 if price_cents.nil? + price_cents / 100.0 + end + + def price_euros=(value) + self.price_cents = (value.to_f * 100).to_i + end + + def available? + return false if sale_start_at.nil? || sale_end_at.nil? + sale_start_at <= Time.current && sale_end_at >= Time.current + end + + def sold_out? + return false if quantity.nil? + tickets.count >= quantity + end + + def available_quantity + return 0 if quantity.nil? + [quantity - tickets.count, 0].max + end + + def sales_status + return :draft if sale_start_at.nil? || sale_end_at.nil? + return :expired if sale_end_at < Time.current + return :upcoming if sale_start_at > Time.current + return :sold_out if sold_out? + return :available + end + + def total_potential_revenue + return 0.0 if quantity.nil? || price_cents.nil? + quantity * price_euros + end + + def current_revenue + return 0.0 if price_cents.nil? + tickets.count * price_euros + end + + def remaining_potential_revenue + return 0.0 if quantity.nil? || price_cents.nil? + available_quantity * price_euros + end private @@ -20,4 +77,9 @@ class TicketType < ApplicationRecord return unless sale_start_at && sale_end_at errors.add(:sale_end_at, "must be after sale start") if sale_end_at <= sale_start_at end + + def sale_times_within_event_period + return unless event&.start_time && sale_end_at + errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time + end end diff --git a/app/views/promoter/events/show.html.erb b/app/views/promoter/events/show.html.erb index f43bbf5..77c3eba 100644 --- a/app/views/promoter/events/show.html.erb +++ b/app/views/promoter/events/show.html.erb @@ -209,9 +209,9 @@
<%= @ticket_type.name %>
++ <%= pluralize(@ticket_type.tickets.count, 'billet') %> de ce type ont déjà été vendus. + Modifier certains paramètres pourrait impacter les acheteurs existants. +
++ + Modifier le prix n'affectera pas les billets déjà vendus +
+ <% end %> ++ Minimum: <%= @ticket_type.tickets.count %> (billets déjà vendus) +
+ <% else %> +Nombre total de billets de ce type
+ <% end %> ++ + Des ventes ont déjà eu lieu +
+ <% end %> +
+ Événement: <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %>
+ Les ventes doivent se terminer avant le début de l'événement.
+
+ Cochez si une pièce d'identité sera vérifiée à l'entrée.
+ <% if @ticket_type.tickets.any? && @ticket_type.requires_id != params.dig(:ticket_type, :requires_id) %>
+
Attention: Cette modification affectera l'expérience des acheteurs existants.
+ <% end %>
+
+ + <%= pluralize(@ticket_type.tickets.count, 'billet') %> déjà vendu(s) +
+ <% end %> ++ <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %> +
++ Cet événement est en brouillon. Les types de billets ne seront visibles qu'une fois l'événement publié. +
+<%= ticket_type.description %>
+Créez des types de billets pour permettre aux utilisateurs d'acheter des places pour votre événement.
+ <%= link_to new_promoter_event_ticket_type_path(@event), class: "inline-flex items-center px-6 py-3 bg-black text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %> + + Créer mon premier type de billet + <% end %> ++ <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %> +
+Nom affiché aux acheteurs
+Description visible lors de l'achat
+Prix unitaire du billet
+Nombre total de billets de ce type
+Les ventes s'arrêtent automatiquement à cette date
+
+ Événement: <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %>
+ Les ventes doivent se terminer avant le début de l'événement.
+
Âge minimum requis (optionnel)
++ Cochez si une pièce d'identité sera vérifiée à l'entrée. Les noms des participants seront collectés lors de l'achat. +
++ <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %> +
+Ce type de billet est actuellement disponible à l'achat.
+Les ventes commenceront le <%= @ticket_type.sale_start_at.strftime("%d/%m/%Y à %H:%M") %>.
+Tous les billets de ce type ont été vendus.
+La période de vente pour ce type de billet est terminée.
+<%= simple_format(@ticket_type.description) %>
+<%= ticket.first_name %> <%= ticket.last_name %>
+<%= ticket.user.email %>
++ <%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %> +
++ <%= ticket.created_at.strftime("%d/%m/%Y") %> +
++ Et <%= @ticket_type.tickets.count - 10 %> autre(s) acheteur(s)... +
+ <% end %> +Des billets ont été vendus
+ <% end %> +<%= @ticket_type.created_at.strftime("%d/%m/%Y à %H:%M") %>
+<%= @ticket_type.updated_at.strftime("%d/%m/%Y à %H:%M") %>
+