diff --git a/app/controllers/reservations_controller.rb b/app/controllers/reservations_controller.rb index d1581be..0ef0877 100644 --- a/app/controllers/reservations_controller.rb +++ b/app/controllers/reservations_controller.rb @@ -14,9 +14,22 @@ class ReservationsController < ApplicationController # GET /reservations/new def new - @reservation = Reservation.new - @reservation.team = @company.teams.first - @customers = @company.customers + # logger.debug "--- Reservations#new --- Params received: #{params.inspect}" + + # Use Time.zone.parse to interpret times within the application's configured timezone + start_time_param = params[:start_time] + end_time_param = params[:end_time] + # logger.debug "--- Reservations#new --- Start Param: #{start_time_param}, End Param: #{end_time_param}" + + start_time = start_time_param ? Time.zone.parse(start_time_param) : Time.current + end_time = end_time_param ? Time.zone.parse(end_time_param) : Time.current + 30.minutes + # logger.debug "--- Reservations#new --- Parsed Start: #{start_time}, Parsed End: #{end_time}" + + @reservation = Reservation.new(start_time: start_time, end_time: end_time) + # logger.debug "--- Reservations#new --- Reservation object initialized: #{@reservation.attributes.inspect}" + + @reservation.team = @company.teams.first # Assign default team + @customers = @company.customers # Preload customers for potential dropdown end # GET /reservations/1/edit @@ -28,8 +41,9 @@ class ReservationsController < ApplicationController reservation_params.except(:customer_id, :customer_birth_year) ) - # Find or create customer + # Find or create customer based on submitted attributes find_or_create_customer + # Associate the reservation with the found/created customer's primary key attributes assign_customer_to_reservation if @reservation.save @@ -39,6 +53,7 @@ class ReservationsController < ApplicationController render :new, status: :unprocessable_entity end rescue ActiveRecord::RecordInvalid => e + # If customer creation/validation fails @reservation.errors.add(:base, "Failed to save customer: #{e.message}") @customers = @company.customers render :new, status: :unprocessable_entity @@ -46,15 +61,55 @@ class ReservationsController < ApplicationController # PATCH/PUT /reservations/1 or /reservations/1.json def update + # Separate reservation attributes from customer attributes + customer_attrs = build_customer_attributes # Use existing helper + reservation_attrs = reservation_params.except( + :customer_id, :customer_first_name, :customer_surname, + :customer_original_phone, :customer_birth_year + ) + + # Find the customer identified by the submitted name/phone + @customer = Customer.find_by( + first_name: customer_attrs[:first_name], + surname: customer_attrs[:surname], + original_phone: customer_attrs[:original_phone], + company_id: @company.id + ) + + # Update customer phone/birthyear if found (identifiers assumed immutable here) + customer_updated = @customer ? @customer.update(customer_attrs.slice(:phone, :birthyear)) : false + + # Assign the correct customer foreign keys to the reservation attributes + if @customer + reservation_attrs[:customer_first_name] = @customer.first_name + reservation_attrs[:customer_surname] = @customer.surname + reservation_attrs[:customer_original_phone] = @customer.original_phone + else + # Fall back to original keys if form customer wasn't found (e.g., identifiers changed) + # Consider adding an error or different handling if customer *must* be found. + reservation_attrs[:customer_first_name] = @reservation.customer_first_name + reservation_attrs[:customer_surname] = @reservation.customer_surname + reservation_attrs[:customer_original_phone] = @reservation.customer_original_phone + end + + reservation_updated = @reservation.update(reservation_attrs) + respond_to do |format| - if @reservation.update(reservation_params) + # Check if reservation update was successful + # (We might ignore customer_updated status for now, or add more complex checks) + if reservation_updated format.html { redirect_to reservation_url(@reservation), notice: t('.reservation_updated') } format.json { render :show, status: :ok, location: @reservation } else + @customers = @company.customers # Reload for form format.html { render :edit, status: :unprocessable_entity } format.json { render json: @reservation.errors, status: :unprocessable_entity } end end + rescue ActiveRecord::RecordInvalid => e # Catch potential customer update errors + @reservation.errors.add(:base, "Failed to save customer: #{e.message}") if @customer&.invalid? + @customers = @company.customers + render :edit, status: :unprocessable_entity end # DELETE /reservations/1 or /reservations/1.json @@ -76,17 +131,17 @@ class ReservationsController < ApplicationController # Only allow a list of trusted parameters through. def reservation_params + # Permit composite key if form uses it, otherwise permit individual fields + # params.require(:reservation).permit(:customer_composite_key, ...) params.require(:reservation).permit( - :customer_id, :team_id, :start_time, :end_time, - :title, - :description, :customer_first_name, :customer_surname, :customer_original_phone, - :customer_birth_year # Keep this in permitted params + :customer_birth_year, + :customer_id # Allow this if select still sends it sometimes ) end @@ -94,6 +149,7 @@ class ReservationsController < ApplicationController action_name == 'index' ? 'calendar' : 'application' end + # Finds or creates customer based on submitted fields def find_or_create_customer customer_params = build_customer_attributes @@ -104,33 +160,43 @@ class ReservationsController < ApplicationController end end + # Extracts customer attributes from reservation form parameters def build_customer_attributes { first_name: params[:reservation][:customer_first_name], surname: params[:reservation][:customer_surname], original_phone: params[:reservation][:customer_original_phone], - phone: params[:reservation][:customer_original_phone], + phone: params[:reservation][:customer_original_phone], # Assuming phone is same as original_phone for now birthyear: params[:reservation][:customer_birth_year], company_id: @company.id } end + # Checks if the submitted customer ID indicates a new customer def new_customer? - params[:reservation][:customer_id].present? && - params[:reservation][:customer_id].end_with?('__new') + # Check based on the specific value format used by TomSelect create function + params[:reservation][:customer_composite_key]&.end_with?('__new') || + params[:reservation][:customer_id]&.end_with?('__new') # Fallback if old key is used end + # Finds customer by composite key or creates them def find_or_initialize_customer(attributes) + # Find using the composite key fields Customer.find_or_create_by!( first_name: attributes[:first_name], surname: attributes[:surname], original_phone: attributes[:original_phone] + # company_id: attributes[:company_id] # Scope to company if needed ) do |customer| - customer.assign_attributes(attributes) + # Assign other attributes only if creating + customer.assign_attributes(attributes.slice(:phone, :birthyear, :company_id)) end end + # Sets the foreign key fields on the reservation based on the found/created customer def assign_customer_to_reservation + return unless @customer # Guard clause + @reservation.customer_first_name = @customer.first_name @reservation.customer_surname = @customer.surname @reservation.customer_original_phone = @customer.original_phone diff --git a/app/javascript/controllers/customer_search_controller.js b/app/javascript/controllers/customer_search_controller.js index 34c9803..9aba290 100644 --- a/app/javascript/controllers/customer_search_controller.js +++ b/app/javascript/controllers/customer_search_controller.js @@ -2,8 +2,20 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["select", "phoneField", "birthYearField", "firstNameField", "surnameField", "newCustomerFields"] + static values = { + existingId: String, + existingLabel: String + } connect() { + let initialOptions = []; + let initialValue = null; + + if (this.hasExistingIdValue && this.hasExistingLabelValue && this.existingIdValue.length > 0) { + initialOptions = [{ id: this.existingIdValue, label: this.existingLabelValue }]; + initialValue = this.existingIdValue; + } + this.selectInstance = new TomSelect(this.selectTarget, { valueField: 'id', labelField: 'label', @@ -20,10 +32,11 @@ export default class extends Controller { }; }, - options: [], + options: initialOptions, + items: initialValue ? [initialValue] : [], load: async (query, callback) => { - if (!query.length) return callback(); + if (!query.length && !initialValue) return callback(); try { const response = await fetch(`/customers/search?q=${encodeURIComponent(query)}`, { @@ -33,7 +46,11 @@ export default class extends Controller { } }); const data = await response.json(); - callback(data); + + const existingOptionId = this.hasExistingIdValue ? this.existingIdValue : null; + const filteredData = data.filter(item => item.id !== existingOptionId); + + callback(filteredData); } catch (error) { console.error('Error loading customers:', error); callback(); @@ -53,17 +70,18 @@ export default class extends Controller { } }, - // Events onLoad: (data) => { - if (!data || data.length === 0) { + if (!this.selectInstance.getValue() && (!data || data.length === 0)) { this.showNewCustomerFields(); } }, onChange: (value) => { - if (value === null) { + if (value === null || value === '') { this.showNewCustomerFields(); - } + } else if (!value.endsWith('__new')) { + this.newCustomerFieldsTarget.classList.add('hidden'); + } }, onItemAdd: (value) => { @@ -76,6 +94,11 @@ export default class extends Controller { } } }); + + if (initialValue) { + this.newCustomerFieldsTarget.classList.add('hidden'); + this.customerSelected(initialValue); + } } customerSelected(value) { @@ -97,9 +120,15 @@ export default class extends Controller { if (customerData && customerData.birthyear) { this.birthYearFieldTarget.value = customerData.birthyear; + } else { + this.birthYearFieldTarget.value = ''; } this.newCustomerFieldsTarget.classList.add('hidden'); + } else { + console.warn("Selected customer value format unexpected:", value); + this.clearFields(); + this.showNewCustomerFields(); } } diff --git a/app/javascript/controllers/main_calendar_controller.js b/app/javascript/controllers/main_calendar_controller.js index ad85d95..142cf64 100644 --- a/app/javascript/controllers/main_calendar_controller.js +++ b/app/javascript/controllers/main_calendar_controller.js @@ -20,6 +20,17 @@ export default class extends Controller { hourStart: 4, hourEnd: 21, }, + timezone: { + zones: [ + { + timezoneName: 'UTC', + displayLabel: 'UTC' // Optional: Label for the timezone + } + ] + // You might need `primaryTimezone: 'UTC'` here as well, + // depending on the exact library version and desired behavior. + // Let's start with just zones. + }, // This is important - set the height to 100% height: '100%', // Make sure it takes full width @@ -52,6 +63,103 @@ export default class extends Controller { useDetailPopup: true, }); + // Listener for clicks on empty time slots + calendar.on('selectDateTime', (eventData) => { + const startTime = new Date(eventData.start); + const endTime = new Date(startTime.getTime() + 30 * 60000); // Add 30 minutes + + const formatForUrl = (date) => { + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}:00`; + }; + + const startTimeParam = formatForUrl(startTime); + const endTimeParam = formatForUrl(endTime); + + const newReservationUrl = `/reservations/new?start_time=${encodeURIComponent(startTimeParam)}&end_time=${encodeURIComponent(endTimeParam)}`; + + if (window.Turbo) { + Turbo.visit(newReservationUrl); + } else { + window.location.href = newReservationUrl; // Fallback: Full page reload + } + + // Prevent TUI Calendar from creating its default event/guide element + return false; + }); + + // Listener for delete button in popup + calendar.on('beforeDeleteEvent', async (eventObj) => { + const reservationId = eventObj.id; + const calendarId = eventObj.calendarId; + + if (!reservationId) { + console.error("Reservation ID not found in event object."); + return false; + } + + const csrfToken = this.getCsrfToken(); + if (!csrfToken) { + console.error("CSRF token not found."); + alert("Error: Could not verify request security token."); + return false; + } + + try { + const response = await fetch(`/reservations/${reservationId}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': csrfToken, + 'Accept': 'application/json' + } + }); + + if (response.ok) { + calendar.deleteEvent(reservationId, calendarId); + alert('Reservation deleted.'); + } else { + console.error(`Failed to delete reservation ${reservationId}. Status: ${response.status}`); + let errorMessage = 'Error deleting reservation.'; + try { + const errorData = await response.json(); + errorMessage += ` Server says: ${errorData.error || JSON.stringify(errorData)}`; + } catch (e) { /* Ignore */ } + alert(errorMessage); + } + } catch (error) { + console.error("Network error or exception during delete:", error); + alert("Error deleting reservation due to a network or script issue."); + } + + return false; // Prevent TUI default delete handling + }); + + // Listener for edit button in popup (or drag/resize completion) + calendar.on('beforeUpdateEvent', (eventInfo) => { + const eventId = eventInfo.event?.id; + + if (!eventId) { + console.error("Cannot edit: Event ID not found."); + return false; + } + + // Navigate to edit page for both edit clicks and drag/resize events + const editUrl = `/reservations/${eventId}/edit`; + + if (window.Turbo) { + Turbo.visit(editUrl); + } else { + window.location.href = editUrl; + } + + // Prevent TUI's default update action + return false; + }); + window.calendar = calendar; this.getCalendardata(); @@ -81,6 +189,12 @@ export default class extends Controller { }); } + // Helper function to get CSRF token from meta tag + getCsrfToken() { + const token = document.querySelector('meta[name="csrf-token"]')?.content; + return token; + } + // Navigation methods - using the global window.calendar prev() { window.calendar.prev(); diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index ae039d7..ecbddf7 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -9,7 +9,7 @@ <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> diff --git a/app/views/layouts/calendar.html.erb b/app/views/layouts/calendar.html.erb index 1db93dd..2d4def9 100644 --- a/app/views/layouts/calendar.html.erb +++ b/app/views/layouts/calendar.html.erb @@ -9,7 +9,7 @@ <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> - <%= stylesheet_link_tag "calendar.tailwind", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %>