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 %>