Added customer composite key
This commit is contained in:
30
app/assets/stylesheets/calendar.css
Normal file
30
app/assets/stylesheets/calendar.css
Normal file
@@ -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 */
|
||||
}
|
||||
@@ -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; }
|
||||
.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;
|
||||
}
|
||||
|
||||
113
app/assets/stylesheets/fullscreen_calendar.css
Normal file
113
app/assets/stylesheets/fullscreen_calendar.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ class ReservationsController < ApplicationController
|
||||
def new
|
||||
@reservation = Reservation.new
|
||||
@reservation.team = @company.teams.first
|
||||
@customers = @company.customers
|
||||
end
|
||||
|
||||
# GET /reservations/1/edit
|
||||
@@ -23,18 +24,24 @@ 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
|
||||
find_or_create_customer
|
||||
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
|
||||
@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
|
||||
@@ -69,11 +76,63 @@ 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)
|
||||
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
|
||||
)
|
||||
end
|
||||
|
||||
def determine_layout
|
||||
action_name == 'index' ? 'calendar' : 'application'
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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],
|
||||
birthyear: params[:reservation][:customer_birth_year],
|
||||
company_id: @company.id
|
||||
}
|
||||
end
|
||||
|
||||
def new_customer?
|
||||
params[:reservation][:customer_id].present? &&
|
||||
params[:reservation][:customer_id].end_with?('__new')
|
||||
end
|
||||
|
||||
def find_or_initialize_customer(attributes)
|
||||
Customer.find_or_create_by!(
|
||||
first_name: attributes[:first_name],
|
||||
surname: attributes[:surname],
|
||||
original_phone: attributes[:original_phone]
|
||||
) do |customer|
|
||||
customer.assign_attributes(attributes)
|
||||
end
|
||||
end
|
||||
|
||||
def assign_customer_to_reservation
|
||||
@reservation.customer_first_name = @customer.first_name
|
||||
@reservation.customer_surname = @customer.surname
|
||||
@reservation.customer_original_phone = @customer.original_phone
|
||||
end
|
||||
end
|
||||
|
||||
18
app/javascript/controllers/calendar_controller.js
Normal file
18
app/javascript/controllers/calendar_controller.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
122
app/javascript/controllers/customer_search_controller.js
Normal file
122
app/javascript/controllers/customer_search_controller.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["select", "phoneField", "birthYearField", "firstNameField", "surnameField", "newCustomerFields"]
|
||||
|
||||
connect() {
|
||||
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: [],
|
||||
|
||||
load: async (query, callback) => {
|
||||
if (!query.length) 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();
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading customers:', error);
|
||||
callback();
|
||||
}
|
||||
},
|
||||
|
||||
shouldLoad: function(query) {
|
||||
return query.length >= 2;
|
||||
},
|
||||
|
||||
render: {
|
||||
no_results: (data, escape) => {
|
||||
return '<div class="no-results">No customers found. Fill in the details below.</div>';
|
||||
},
|
||||
option: function(item) {
|
||||
return `<div>${item.label}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// Events
|
||||
onLoad: (data) => {
|
||||
if (!data || data.length === 0) {
|
||||
this.showNewCustomerFields();
|
||||
}
|
||||
},
|
||||
|
||||
onChange: (value) => {
|
||||
if (value === null) {
|
||||
this.showNewCustomerFields();
|
||||
}
|
||||
},
|
||||
|
||||
onItemAdd: (value) => {
|
||||
this.customerSelected(value);
|
||||
},
|
||||
|
||||
onDropdownClose: (dropdown) => {
|
||||
if (!this.selectInstance.getValue()) {
|
||||
this.showNewCustomerFields();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
customerSelected(value) {
|
||||
if (value.endsWith('__new')) {
|
||||
const firstName = value.replace('__new', '');
|
||||
this.firstNameFieldTarget.value = firstName;
|
||||
this.showNewCustomerFields();
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstName, surname, phone] = value.split('_');
|
||||
|
||||
if (firstName && surname && phone) {
|
||||
const customerData = this.selectInstance.options[value];
|
||||
|
||||
this.phoneFieldTarget.value = phone;
|
||||
this.firstNameFieldTarget.value = firstName;
|
||||
this.surnameFieldTarget.value = surname;
|
||||
|
||||
if (customerData && customerData.birthyear) {
|
||||
this.birthYearFieldTarget.value = customerData.birthyear;
|
||||
}
|
||||
|
||||
this.newCustomerFieldsTarget.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
showNewCustomerFields() {
|
||||
this.newCustomerFieldsTarget.classList.remove('hidden');
|
||||
}
|
||||
|
||||
clearFields() {
|
||||
this.phoneFieldTarget.value = '';
|
||||
this.firstNameFieldTarget.value = '';
|
||||
this.surnameFieldTarget.value = '';
|
||||
this.birthYearFieldTarget.value = '';
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.selectInstance) {
|
||||
this.selectInstance.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,13 @@ import {Controller} from "@hotwired/stimulus"
|
||||
// Connects to data-controller="main-calendar"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["dateDisplay", "navigation"]
|
||||
|
||||
connect() {
|
||||
// Set height to full viewport
|
||||
document.getElementById('main-calendar').style.height = '100vh';
|
||||
document.getElementById('main-calendar').style.width = '100vw';
|
||||
|
||||
const calendar = new tui.Calendar(document.getElementById('main-calendar'), {
|
||||
defaultView: 'week',
|
||||
usageStatistics: false,
|
||||
@@ -10,12 +16,29 @@ export default class extends Controller {
|
||||
taskView: false,
|
||||
scheduleView: false,
|
||||
eventView: ['time'],
|
||||
startDayOfWeek: 1, // Start week on Monday
|
||||
hourStart: 4,
|
||||
hourEnd: 21,
|
||||
},
|
||||
// This is important - set the height to 100%
|
||||
height: '100%',
|
||||
// Make sure it takes full width
|
||||
width: '100%',
|
||||
template: {
|
||||
timegridDisplayPrimaryTime({time}) {
|
||||
return `${time.getHours()} sati`;
|
||||
},
|
||||
popupDetailLocation(eventObj) {
|
||||
return ''; // Empty location as requested
|
||||
},
|
||||
popupDetailAttendees(eventObj) {
|
||||
return eventObj.attendees[0]; // Show team name
|
||||
},
|
||||
popupDetailState(eventObj) {
|
||||
return '';
|
||||
},
|
||||
popupDetailBody(eventObj) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
calendars: [
|
||||
@@ -25,11 +48,17 @@ export default class extends Controller {
|
||||
backgroundColor: '#00a9ff',
|
||||
},
|
||||
],
|
||||
// Enable the built-in popup
|
||||
useDetailPopup: true,
|
||||
});
|
||||
|
||||
window.calendar = calendar;
|
||||
this.getCalendardata();
|
||||
|
||||
calendar.render();
|
||||
|
||||
// Update the date display after rendering
|
||||
this.updateDateDisplay();
|
||||
}
|
||||
|
||||
getCalendardata() {
|
||||
@@ -40,14 +69,75 @@ export default class extends Controller {
|
||||
{
|
||||
id: reservation.id,
|
||||
calendarId: 'cal1',
|
||||
title: reservation.customer.name,
|
||||
title: reservation.customer.first_name + ' ' + reservation.customer.surname + ' (' + reservation.customer.phone + ')',
|
||||
category: 'time',
|
||||
dueDateClass: reservation.dueDateClass,
|
||||
location: reservation.team.name,
|
||||
location: '', // Empty location as requested
|
||||
attendees: [reservation.team.name], // Team name as attendee
|
||||
start: reservation.start_time,
|
||||
end: reservation.end_time
|
||||
}
|
||||
])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation methods - using the global window.calendar
|
||||
prev() {
|
||||
window.calendar.prev();
|
||||
this.updateDateDisplay();
|
||||
}
|
||||
|
||||
next() {
|
||||
window.calendar.next();
|
||||
this.updateDateDisplay();
|
||||
}
|
||||
|
||||
today() {
|
||||
window.calendar.today();
|
||||
this.updateDateDisplay();
|
||||
}
|
||||
|
||||
// Helper for updating the date display
|
||||
updateDateDisplay() {
|
||||
if (this.hasDateDisplayTarget) {
|
||||
const calendar = window.calendar;
|
||||
const currentDate = calendar.getDate();
|
||||
|
||||
// Format the date range based on the current view
|
||||
const view = calendar.getViewName();
|
||||
|
||||
if (view === 'day') {
|
||||
this.dateDisplayTarget.textContent = this.formatDate(currentDate);
|
||||
} else if (view === 'week') {
|
||||
// For week view, show range (e.g., "Aug 1 - 7, 2023")
|
||||
const weekStart = new Date(currentDate);
|
||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay() + (weekStart.getDay() === 0 ? -6 : 1));
|
||||
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
|
||||
this.dateDisplayTarget.textContent = `${this.formatDateShort(weekStart)} - ${this.formatDateShort(weekEnd)}`;
|
||||
} else if (view === 'month') {
|
||||
// For month view, show month and year (e.g., "August 2023")
|
||||
this.dateDisplayTarget.textContent = currentDate.toLocaleString('default', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for formatting dates
|
||||
formatDate(date) {
|
||||
return date.toLocaleDateString('default', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
formatDateShort(date) {
|
||||
return date.toLocaleDateString('default', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,40 @@
|
||||
class Customer < ApplicationRecord
|
||||
# Use Rails 7.1's native composite primary key
|
||||
self.primary_key = %i[first_name surname original_phone]
|
||||
|
||||
belongs_to :company
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :company_id }
|
||||
validates :first_name, presence: true
|
||||
validates :surname, presence: true
|
||||
validates :phone, presence: true
|
||||
validates :original_phone, presence: true
|
||||
validates :company_id, presence: true
|
||||
|
||||
validates :first_name, uniqueness: {
|
||||
scope: %i[surname original_phone company_id],
|
||||
message: -> { I18n.t('customers.customer.already_exists') }
|
||||
}
|
||||
|
||||
validates :birthyear, numericality: {
|
||||
only_integer: true,
|
||||
greater_than: 1900,
|
||||
less_than_or_equal_to: -> { Time.current.year }
|
||||
}, allow_nil: true
|
||||
|
||||
before_validation :set_original_phone, on: :create
|
||||
|
||||
def full_name
|
||||
[first_name, surname].compact_blank.join(' ')
|
||||
end
|
||||
|
||||
# Add method for URL generation
|
||||
def to_param
|
||||
[first_name, surname, original_phone].join('_')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_original_phone
|
||||
self.original_phone = phone if original_phone.blank?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
class Reservation < ApplicationRecord
|
||||
belongs_to :company
|
||||
belongs_to :customer
|
||||
belongs_to :customer,
|
||||
primary_key: %i[first_name surname original_phone],
|
||||
query_constraints: %i[customer_first_name customer_surname customer_original_phone]
|
||||
belongs_to :team
|
||||
|
||||
validates :company_id, presence: true
|
||||
validates :customer_id, presence: true
|
||||
validates :team_id, presence: true
|
||||
|
||||
# Remove customer_id validation since we're using composite key
|
||||
validates :customer_first_name, :customer_surname, :customer_original_phone, presence: true
|
||||
|
||||
# Add validation to ensure customer exists
|
||||
validate :customer_must_exist
|
||||
|
||||
private
|
||||
|
||||
def customer_must_exist
|
||||
unless Customer.exists?(first_name: customer_first_name,
|
||||
surname: customer_surname,
|
||||
original_phone: customer_original_phone)
|
||||
errors.add(:base, "Selected customer does not exist")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class ReservationSerializer < ActiveModel::Serializer
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<div id="<%= dom_id customer %>">
|
||||
<p class="my-5">
|
||||
<strong class="block font-medium mb-1">Name:</strong>
|
||||
<%= customer.name %>
|
||||
<strong class="block font-medium mb-1">First Name:</strong>
|
||||
<%= customer.first_name %>
|
||||
</p>
|
||||
|
||||
<p class="my-5">
|
||||
<strong class="block font-medium mb-1">Surname:</strong>
|
||||
<%= customer.surname %>
|
||||
</p>
|
||||
|
||||
<p class="my-5">
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
<% end %>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :name %>
|
||||
<%= form.text_field :name, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
|
||||
<%= form.label :first_name %>
|
||||
<%= form.text_field :first_name, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :surname %>
|
||||
<%= form.text_field :surname, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<% @customers.each do |customer| %>
|
||||
<%= render customer %>
|
||||
<p>
|
||||
<%= 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" %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
|
||||
<%= javascript_importmap_tags %>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -12,10 +12,49 @@
|
||||
<%= stylesheet_link_tag "calendar.tailwind", "data-turbo-track": "reload" %>
|
||||
|
||||
<%= javascript_importmap_tags %>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
body.calendar {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
body.calendar main {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.w-full, .min-w-full, div[data-controller="main-calendar"] {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#main-calendar {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
.flex.justify-between.items-center {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 1000;
|
||||
background: rgba(255,255,255,0.8);
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="calendar">
|
||||
<main style="height: 100vw;">
|
||||
<main>
|
||||
<%= yield %>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -1,38 +1,115 @@
|
||||
<%= form_with(model: reservation, class: "contents") do |form| %>
|
||||
<% if reservation.errors.any? %>
|
||||
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
|
||||
<h2><%= pluralize(reservation.errors.count, "error") %> prohibited this reservation from being saved:</h2>
|
||||
<%= form_with(model: reservation, class: "contents", data: { controller: "customer-search" }) do |form| %>
|
||||
<% if reservation.errors.any? %>
|
||||
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
|
||||
<h2><%= pluralize(reservation.errors.count, "error") %> prohibited this reservation from being saved:</h2>
|
||||
|
||||
<ul>
|
||||
<% reservation.errors.each do |error| %>
|
||||
<li><%= error.full_message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<ul>
|
||||
<% reservation.errors.each do |error| %>
|
||||
<li><%= error.full_message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :customer %>
|
||||
<%= form.select :customer_id,
|
||||
[], # Start with empty options
|
||||
{ prompt: "Type to search customers..." },
|
||||
{
|
||||
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
|
||||
data: { customer_search_target: "select" }
|
||||
} %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :phone_number %>
|
||||
<%= form.telephone_field :customer_original_phone,
|
||||
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
|
||||
data: { customer_search_target: "phoneField" } %>
|
||||
</div>
|
||||
|
||||
<div data-customer-search-target="newCustomerFields" class="hidden">
|
||||
<div class="my-5">
|
||||
<%= form.label :first_name %>
|
||||
<%= form.text_field :customer_first_name,
|
||||
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
|
||||
data: { customer_search_target: "firstNameField" } %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :surname %>
|
||||
<%= form.text_field :customer_surname,
|
||||
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
|
||||
data: { customer_search_target: "surnameField" } %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :birth_year %>
|
||||
<%= form.number_field :customer_birth_year,
|
||||
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
|
||||
data: { customer_search_target: "birthYearField" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :team_id %>
|
||||
<%= form.collection_select :team_id,
|
||||
@company.teams,
|
||||
:id,
|
||||
:name,
|
||||
{ prompt: "Select a team" },
|
||||
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :start_time %>
|
||||
<%= form.datetime_field :start_time,
|
||||
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
|
||||
id: "start_time_field" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :end_time %>
|
||||
<%= form.datetime_field :end_time,
|
||||
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
|
||||
id: "end_time_field" %>
|
||||
</div>
|
||||
|
||||
<div class="inline">
|
||||
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :customer_id %>
|
||||
<%= form.collection_select :customer_id, @company.customers, :id, :name, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :team_id %>
|
||||
<%= form.collection_select :team_id, @company.teams, :id, :name, prompt: "Select a Team", class:"block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :start_time %>`
|
||||
<%= form.datetime_field :start_time, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :end_time %>
|
||||
<%= form.datetime_field :end_time, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="inline">
|
||||
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
// Only set default times for new records (not when editing)
|
||||
<% unless reservation.persisted? %>
|
||||
// Get current date and time
|
||||
const now = new Date();
|
||||
|
||||
// Get local components
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
|
||||
// Format for datetime-local input (YYYY-MM-DDThh:mm)
|
||||
const localStartTime = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
document.getElementById('start_time_field').value = localStartTime;
|
||||
|
||||
// Add 30 minutes for end time
|
||||
const endDate = new Date(now.getTime() + 30 * 60000);
|
||||
const endHours = String(endDate.getHours()).padStart(2, '0');
|
||||
const endMinutes = String(endDate.getMinutes()).padStart(2, '0');
|
||||
const localEndTime = `${year}-${month}-${day}T${endHours}:${endMinutes}`;
|
||||
document.getElementById('end_time_field').value = localEndTime;
|
||||
|
||||
// For debugging - add this to see actual values
|
||||
console.log("Start time set to: " + localStartTime);
|
||||
console.log("End time set to: " + localEndTime);
|
||||
console.log("Current browser time: " + now.toString());
|
||||
<% end %>
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
<p class="my-5">
|
||||
<strong class="block font-medium mb-1">Customer:</strong>
|
||||
<%= reservation.customer_id %>
|
||||
<%= reservation.customer.try(:full_name) || "N/A" %>
|
||||
(<%= reservation.customer.try(:birthyear) %>)
|
||||
</p>
|
||||
|
||||
<p class="my-5">
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
<div class="w-full" style="height: 100%">
|
||||
<% if notice.present? %>
|
||||
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
|
||||
<% end %>
|
||||
<!-- Separate the page into two distinct parts -->
|
||||
<div class="reservation-page" data-controller="main-calendar" style="display: block; width: 100%; height: 100vh; overflow: hidden;">
|
||||
<!-- Fixed height header -->
|
||||
<header style="height: 80px; padding: 15px; background-color: white; box-shadow: 0 2px 4px rgba(0,0,0,0.1); position: relative; z-index: 100;">
|
||||
<% if notice.present? %>
|
||||
<p class="py-2 px-3 bg-green-50 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
|
||||
<% end %>
|
||||
|
||||
<% content_for :title, "Reservations" %>
|
||||
<% content_for :title, "Reservations" %>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="font-bold text-4xl">Reservations</h1>
|
||||
<%= link_to "New reservation", new_reservation_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
|
||||
<div class="flex justify-between items-center-calendar">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="font-bold text-4xl px-5">Reservations</h1>
|
||||
<div class="flex items-center space-x-2 ml-6" data-main-calendar-target="navigation">
|
||||
<button data-action="main-calendar#prev" class="px-3 py-1 bg-gray-100 rounded-md hover:bg-gray-200">
|
||||
« Prev
|
||||
</button>
|
||||
<button data-action="main-calendar#today" class="px-3 py-1 bg-gray-100 rounded-md hover:bg-gray-200">
|
||||
Today
|
||||
</button>
|
||||
<button data-action="main-calendar#next" class="px-3 py-1 bg-gray-100 rounded-md hover:bg-gray-200">
|
||||
Next »
|
||||
</button>
|
||||
<span data-main-calendar-target="dateDisplay" class="ml-3 font-medium"></span>
|
||||
</div>
|
||||
</div>
|
||||
<%= link_to "New reservation", new_reservation_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium ml-auto" %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Calendar container - with fixed top position and precise height calculation -->
|
||||
<div class="calendar-container" style="height: calc(100vh - 80px); width: 100%; position: relative; top: 0; left: 0;">
|
||||
<div style="height: 100%; width: 100%;">
|
||||
<%= tag.div nil, data: {reservations: @reservations.to_json}, id: "main-calendar", style: "height: 100%; width: 100%;" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-controller="main-calendar" class="min-w-full" style="height: 90vh;" >
|
||||
<%= tag.div nil, data: {reservations: @reservations.to_json}, id: "main-calendar"%>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user