diff --git a/Gemfile b/Gemfile index 7ead32c..420f8f7 100644 --- a/Gemfile +++ b/Gemfile @@ -93,6 +93,5 @@ group :test do gem "rspec_junit_formatter" gem "simplecov", require: false gem "simplecov-cobertura", require: false - gem "vcr" gem "webmock" end diff --git a/Gemfile.lock b/Gemfile.lock index 3ea81f1..7f70629 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -398,8 +398,6 @@ GEM unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uniform_notifier (1.16.0) - vcr (6.3.1) - base64 web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -470,7 +468,6 @@ DEPENDENCIES tailwindcss-rails turbo-rails tzinfo-data - vcr web-console webmock diff --git a/app/assets/stylesheets/calendar.css b/app/assets/stylesheets/calendar.css new file mode 100644 index 0000000..a841bf2 --- /dev/null +++ b/app/assets/stylesheets/calendar.css @@ -0,0 +1,30 @@ +.calendar-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + height: 100vh; + overflow: auto; + padding: 0; + margin: 0; +} + +.calendar-grid { + width: 100%; + height: calc(100vh - 50px); /* Adjust based on your header height */ + display: grid; + grid-template-columns: repeat(7, 1fr); /* 7 days */ +} + +.calendar-header { + position: sticky; + top: 0; + background-color: white; + z-index: 10; +} + +.calendar-time-slot { + min-height: 50px; /* Adjust as needed */ +} \ No newline at end of file diff --git a/app/assets/stylesheets/calendar.tailwind.css b/app/assets/stylesheets/calendar.tailwind.css index 575ee47..5dbd28f 100644 --- a/app/assets/stylesheets/calendar.tailwind.css +++ b/app/assets/stylesheets/calendar.tailwind.css @@ -11,14 +11,60 @@ } */ -body.calendar { - height: 99vw; - overflow: hidden; +body.calendar { + height: 100vh !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden !important; } -toastui-calendar-time { - height: 99% !important; +.toastui-calendar-time { + height: 100% !important; } -.toastui-calendar-timegrid { height: 99%; } -.toastui-calendar-panel.toastui-calendar-time { overflow-y: inherit; } \ No newline at end of file +.toastui-calendar-timegrid { + height: 100% !important; +} + +.toastui-calendar-panel.toastui-calendar-time { + overflow-y: inherit !important; +} + +div[data-controller="main-calendar"] { + height: 100vh !important; + margin: 0 !important; + padding: 0 !important; +} + +#main-calendar { + height: 100% !important; + width: 100% !important; +} + +body.calendar main { + height: 100vh !important; + width: 100% !important; + margin: 0 !important; + padding: 0 !important; +} + +.w-full { + margin: 0 !important; + padding: 0 !important; +} + +.flex.justify-between.items-center { + padding: 0.5rem 1rem !important; + margin-bottom: 0 !important; +} + +.flex.justify-between.items-center-calendar { + padding: 0.5rem 1rem !important; + margin-bottom: 0 !important; + position: relative; +} + +.px-5 { + padding-left: 20px !important; + padding-right: 20px !important; +} diff --git a/app/assets/stylesheets/fullscreen_calendar.css b/app/assets/stylesheets/fullscreen_calendar.css new file mode 100644 index 0000000..aa383d0 --- /dev/null +++ b/app/assets/stylesheets/fullscreen_calendar.css @@ -0,0 +1,113 @@ +/* Force fullscreen for calendar elements */ +.fc, /* FullCalendar main container */ +.calendar-container, +.simple-calendar, +.calendar, +[data-controller="calendar"], +#calendar, +.calendar-wrapper { + width: 100% !important; + max-width: 100% !important; + margin: 0 !important; + padding: 0 !important; + overflow: auto !important; +} + +/* Force table and grid elements to stretch */ +.fc-view-container, +.fc-view, +.fc-time-grid, +.fc-slats, +.fc-content-skeleton, +table.calendar-table, +.calendar-grid, +.calendar-row, +.time-slot-container { + width: 100% !important; + max-width: 100% !important; +} + +/* If there's a container around the calendar */ +body > div.container, +main.container, +div.container-fluid, +.content-wrapper, +#main-content { + width: 100% !important; + max-width: 100% !important; + padding: 0 !important; + margin: 0 !important; +} + +/* Fix for responsive issues */ +@media (min-width: 768px) { + .container, .container-md, .container-sm { + max-width: 100% !important; + } +} + +@media (min-width: 992px) { + .container, .container-lg, .container-md, .container-sm { + max-width: 100% !important; + } +} + +@media (min-width: 1200px) { + .container, .container-lg, .container-md, .container-sm, .container-xl { + max-width: 100% !important; + } +} + +/* Reset container constraints */ +body.calendar { + margin: 0; + padding: 0; + overflow: hidden; +} + +body.calendar main { + height: 100vh !important; /* Fix the 100vw typo in the layout */ + width: 100vw !important; + margin: 0 !important; + padding: 0 !important; + max-width: none !important; +} + +/* Make the calendar container fill the space */ +.w-full, .min-w-full { + width: 100vw !important; + max-width: 100vw !important; +} + +div[data-controller="main-calendar"] { + height: 100vh !important; /* Was 90vh */ + width: 100vw !important; + padding: 0 !important; + margin: 0 !important; +} + +#main-calendar { + width: 100% !important; + height: 100% !important; +} + +/* Toast UI Calendar specific fixes */ +.toastui-calendar-layout, +.toastui-calendar-week-view, +.toastui-calendar-panel, +.toastui-calendar-timegrid-container { + width: 100% !important; + max-width: 100% !important; +} + +/* Remove unnecessary padding */ +.container, .mx-auto { + padding: 0 !important; + margin: 0 !important; + max-width: none !important; + width: 100vw !important; +} + +.flex.justify-between { + padding: 0 1rem; +} \ No newline at end of file diff --git a/app/controllers/customers_controller.rb b/app/controllers/customers_controller.rb index fa7c725..f98a146 100644 --- a/app/controllers/customers_controller.rb +++ b/app/controllers/customers_controller.rb @@ -21,7 +21,7 @@ class CustomersController < ApplicationController # POST /customers or /customers.json def create @customer = Customer.new(customer_params) - @customer.company = @company + @customer.company = Company.first # Set the first company respond_to do |format| if @customer.save @@ -57,15 +57,35 @@ class CustomersController < ApplicationController end end + def search + @customers = @company.customers.where( + "LOWER(first_name) LIKE :query OR LOWER(surname) LIKE :query OR original_phone LIKE :query", + query: "%#{params[:q].downcase}%" + ).limit(10) + + render json: @customers.map { |c| + { + id: "#{c.first_name}_#{c.surname}_#{c.original_phone}", + label: "#{c.full_name} (#{c.original_phone})", + birthyear: c.birthyear + } + } + end + private # Use callbacks to share common setup or constraints between actions. def set_customer - @customer = Customer.find(params[:id]) + first_name, surname, original_phone = params[:composite_key].split('_') + @customer = Customer.find_by!( + first_name: first_name, + surname: surname, + original_phone: original_phone + ) end # Only allow a list of trusted parameters through. def customer_params - params.require(:customer).permit(:name, :phone, :notes, :email, :birthyear) + params.require(:customer).permit(:first_name, :surname, :phone, :notes, :email, :birthyear) end end diff --git a/app/controllers/reservations_controller.rb b/app/controllers/reservations_controller.rb index a61ec09..0ef0877 100644 --- a/app/controllers/reservations_controller.rb +++ b/app/controllers/reservations_controller.rb @@ -14,8 +14,22 @@ class ReservationsController < ApplicationController # GET /reservations/new def new - @reservation = Reservation.new - @reservation.team = @company.teams.first + # 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 @@ -23,31 +37,79 @@ class ReservationsController < ApplicationController # POST /reservations or /reservations.json def create - @reservation = Reservation.new(reservation_params) - @reservation.company = @company + @reservation = @company.reservations.new( + reservation_params.except(:customer_id, :customer_birth_year) + ) - respond_to do |format| - if @reservation.save - format.html { redirect_to reservation_url(@reservation), notice: t('.reservation_created') } - format.json { render :show, status: :created, location: @reservation } - else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @reservation.errors, status: :unprocessable_entity } - end + # 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 + redirect_to @reservation, notice: t('.reservation_created') + else + @customers = @company.customers + 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 end # 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 @@ -69,11 +131,74 @@ class ReservationsController < ApplicationController # Only allow a list of trusted parameters through. def reservation_params - params.require(:reservation).permit(:company_id, :customer_id, :team_id, :title, :description, :start_time, - :end_time) + # Permit composite key if form uses it, otherwise permit individual fields + # params.require(:reservation).permit(:customer_composite_key, ...) + params.require(:reservation).permit( + :team_id, + :start_time, + :end_time, + :customer_first_name, + :customer_surname, + :customer_original_phone, + :customer_birth_year, + :customer_id # Allow this if select still sends it sometimes + ) end def determine_layout action_name == 'index' ? 'calendar' : 'application' end + + # Finds or creates customer based on submitted fields + def find_or_create_customer + customer_params = build_customer_attributes + + @customer = if new_customer? + Customer.create!(customer_params) + else + find_or_initialize_customer(customer_params) + 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], # 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? + # 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| + # 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 + end end diff --git a/app/javascript/controllers/calendar_controller.js b/app/javascript/controllers/calendar_controller.js new file mode 100644 index 0000000..e838408 --- /dev/null +++ b/app/javascript/controllers/calendar_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.initializeCalendar(); + } + + initializeCalendar() { + // If using a library like FullCalendar + const calendar = new FullCalendar.Calendar(this.element, { + height: '80vh', // Full viewport height + width: '100%', + // Other calendar options... + }); + + calendar.render(); + } +} \ No newline at end of file diff --git a/app/javascript/controllers/customer_search_controller.js b/app/javascript/controllers/customer_search_controller.js new file mode 100644 index 0000000..9aba290 --- /dev/null +++ b/app/javascript/controllers/customer_search_controller.js @@ -0,0 +1,151 @@ +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', + searchField: ['label'], + maxItems: 1, + create: true, + createOnBlur: true, + placeholder: 'Type to search customers...', + + create: function(input) { + return { + id: `${input}__new`, + label: `${input} (New Customer)` + }; + }, + + options: initialOptions, + items: initialValue ? [initialValue] : [], + + load: async (query, callback) => { + if (!query.length && !initialValue) return callback(); + + try { + const response = await fetch(`/customers/search?q=${encodeURIComponent(query)}`, { + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }); + const data = await response.json(); + + 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(); + } + }, + + shouldLoad: function(query) { + return query.length >= 2; + }, + + render: { + no_results: (data, escape) => { + return '
- Name: - <%= customer.name %> + First Name: + <%= customer.first_name %> +
+ ++ Surname: + <%= customer.surname %>
diff --git a/app/views/customers/_form.html.erb b/app/views/customers/_form.html.erb index 63b5b59..e2bb48b 100644 --- a/app/views/customers/_form.html.erb +++ b/app/views/customers/_form.html.erb @@ -12,8 +12,13 @@ <% end %>
- <%= link_to "Show this customer", customer, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> + <%= link_to "Show this customer", customer_path(customer), class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<% end %>