Added customer composite key
This commit is contained in:
1
Gemfile
1
Gemfile
@@ -93,6 +93,5 @@ group :test do
|
|||||||
gem "rspec_junit_formatter"
|
gem "rspec_junit_formatter"
|
||||||
gem "simplecov", require: false
|
gem "simplecov", require: false
|
||||||
gem "simplecov-cobertura", require: false
|
gem "simplecov-cobertura", require: false
|
||||||
gem "vcr"
|
|
||||||
gem "webmock"
|
gem "webmock"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -398,8 +398,6 @@ GEM
|
|||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uniform_notifier (1.16.0)
|
uniform_notifier (1.16.0)
|
||||||
vcr (6.3.1)
|
|
||||||
base64
|
|
||||||
web-console (4.2.1)
|
web-console (4.2.1)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 6.0.0)
|
||||||
activemodel (>= 6.0.0)
|
activemodel (>= 6.0.0)
|
||||||
@@ -470,7 +468,6 @@ DEPENDENCIES
|
|||||||
tailwindcss-rails
|
tailwindcss-rails
|
||||||
turbo-rails
|
turbo-rails
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
vcr
|
|
||||||
web-console
|
web-console
|
||||||
webmock
|
webmock
|
||||||
|
|
||||||
|
|||||||
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 */
|
||||||
|
}
|
||||||
@@ -12,13 +12,59 @@
|
|||||||
|
|
||||||
*/
|
*/
|
||||||
body.calendar {
|
body.calendar {
|
||||||
height: 99vw;
|
height: 100vh !important;
|
||||||
overflow: hidden;
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
toastui-calendar-time {
|
.toastui-calendar-time {
|
||||||
height: 99% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toastui-calendar-timegrid { height: 99%; }
|
.toastui-calendar-timegrid {
|
||||||
.toastui-calendar-panel.toastui-calendar-time { overflow-y: inherit; }
|
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
|
# POST /customers or /customers.json
|
||||||
def create
|
def create
|
||||||
@customer = Customer.new(customer_params)
|
@customer = Customer.new(customer_params)
|
||||||
@customer.company = @company
|
@customer.company = Company.first # Set the first company
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
if @customer.save
|
if @customer.save
|
||||||
@@ -57,15 +57,35 @@ class CustomersController < ApplicationController
|
|||||||
end
|
end
|
||||||
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
|
private
|
||||||
|
|
||||||
# Use callbacks to share common setup or constraints between actions.
|
# Use callbacks to share common setup or constraints between actions.
|
||||||
def set_customer
|
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
|
end
|
||||||
|
|
||||||
# Only allow a list of trusted parameters through.
|
# Only allow a list of trusted parameters through.
|
||||||
def customer_params
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ReservationsController < ApplicationController
|
|||||||
def new
|
def new
|
||||||
@reservation = Reservation.new
|
@reservation = Reservation.new
|
||||||
@reservation.team = @company.teams.first
|
@reservation.team = @company.teams.first
|
||||||
|
@customers = @company.customers
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /reservations/1/edit
|
# GET /reservations/1/edit
|
||||||
@@ -23,18 +24,24 @@ class ReservationsController < ApplicationController
|
|||||||
|
|
||||||
# POST /reservations or /reservations.json
|
# POST /reservations or /reservations.json
|
||||||
def create
|
def create
|
||||||
@reservation = Reservation.new(reservation_params)
|
@reservation = @company.reservations.new(
|
||||||
@reservation.company = @company
|
reservation_params.except(:customer_id, :customer_birth_year)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find or create customer
|
||||||
|
find_or_create_customer
|
||||||
|
assign_customer_to_reservation
|
||||||
|
|
||||||
respond_to do |format|
|
|
||||||
if @reservation.save
|
if @reservation.save
|
||||||
format.html { redirect_to reservation_url(@reservation), notice: t('.reservation_created') }
|
redirect_to @reservation, notice: t('.reservation_created')
|
||||||
format.json { render :show, status: :created, location: @reservation }
|
|
||||||
else
|
else
|
||||||
format.html { render :new, status: :unprocessable_entity }
|
@customers = @company.customers
|
||||||
format.json { render json: @reservation.errors, status: :unprocessable_entity }
|
render :new, status: :unprocessable_entity
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
@reservation.errors.add(:base, "Failed to save customer: #{e.message}")
|
||||||
|
@customers = @company.customers
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
# PATCH/PUT /reservations/1 or /reservations/1.json
|
# PATCH/PUT /reservations/1 or /reservations/1.json
|
||||||
@@ -69,11 +76,63 @@ class ReservationsController < ApplicationController
|
|||||||
|
|
||||||
# Only allow a list of trusted parameters through.
|
# Only allow a list of trusted parameters through.
|
||||||
def reservation_params
|
def reservation_params
|
||||||
params.require(:reservation).permit(:company_id, :customer_id, :team_id, :title, :description, :start_time,
|
params.require(:reservation).permit(
|
||||||
:end_time)
|
: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
|
end
|
||||||
|
|
||||||
def determine_layout
|
def determine_layout
|
||||||
action_name == 'index' ? 'calendar' : 'application'
|
action_name == 'index' ? 'calendar' : 'application'
|
||||||
end
|
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
|
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"
|
// Connects to data-controller="main-calendar"
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
|
static targets = ["dateDisplay", "navigation"]
|
||||||
|
|
||||||
connect() {
|
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'), {
|
const calendar = new tui.Calendar(document.getElementById('main-calendar'), {
|
||||||
defaultView: 'week',
|
defaultView: 'week',
|
||||||
usageStatistics: false,
|
usageStatistics: false,
|
||||||
@@ -10,12 +16,29 @@ export default class extends Controller {
|
|||||||
taskView: false,
|
taskView: false,
|
||||||
scheduleView: false,
|
scheduleView: false,
|
||||||
eventView: ['time'],
|
eventView: ['time'],
|
||||||
|
startDayOfWeek: 1, // Start week on Monday
|
||||||
hourStart: 4,
|
hourStart: 4,
|
||||||
hourEnd: 21,
|
hourEnd: 21,
|
||||||
},
|
},
|
||||||
|
// This is important - set the height to 100%
|
||||||
|
height: '100%',
|
||||||
|
// Make sure it takes full width
|
||||||
|
width: '100%',
|
||||||
template: {
|
template: {
|
||||||
timegridDisplayPrimaryTime({time}) {
|
timegridDisplayPrimaryTime({time}) {
|
||||||
return `${time.getHours()} sati`;
|
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: [
|
calendars: [
|
||||||
@@ -25,11 +48,17 @@ export default class extends Controller {
|
|||||||
backgroundColor: '#00a9ff',
|
backgroundColor: '#00a9ff',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// Enable the built-in popup
|
||||||
|
useDetailPopup: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
window.calendar = calendar;
|
window.calendar = calendar;
|
||||||
this.getCalendardata();
|
this.getCalendardata();
|
||||||
|
|
||||||
calendar.render();
|
calendar.render();
|
||||||
|
|
||||||
|
// Update the date display after rendering
|
||||||
|
this.updateDateDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
getCalendardata() {
|
getCalendardata() {
|
||||||
@@ -40,14 +69,75 @@ export default class extends Controller {
|
|||||||
{
|
{
|
||||||
id: reservation.id,
|
id: reservation.id,
|
||||||
calendarId: 'cal1',
|
calendarId: 'cal1',
|
||||||
title: reservation.customer.name,
|
title: reservation.customer.first_name + ' ' + reservation.customer.surname + ' (' + reservation.customer.phone + ')',
|
||||||
category: 'time',
|
category: 'time',
|
||||||
dueDateClass: reservation.dueDateClass,
|
dueDateClass: reservation.dueDateClass,
|
||||||
location: reservation.team.name,
|
location: '', // Empty location as requested
|
||||||
|
attendees: [reservation.team.name], // Team name as attendee
|
||||||
start: reservation.start_time,
|
start: reservation.start_time,
|
||||||
end: reservation.end_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
|
class Customer < ApplicationRecord
|
||||||
|
# Use Rails 7.1's native composite primary key
|
||||||
|
self.primary_key = %i[first_name surname original_phone]
|
||||||
|
|
||||||
belongs_to :company
|
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 :phone, presence: true
|
||||||
|
validates :original_phone, presence: true
|
||||||
validates :company_id, 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
|
end
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
class Reservation < ApplicationRecord
|
class Reservation < ApplicationRecord
|
||||||
belongs_to :company
|
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
|
belongs_to :team
|
||||||
|
|
||||||
validates :company_id, presence: true
|
validates :company_id, presence: true
|
||||||
validates :customer_id, presence: true
|
|
||||||
validates :team_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
|
end
|
||||||
|
|
||||||
class ReservationSerializer < ActiveModel::Serializer
|
class ReservationSerializer < ActiveModel::Serializer
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<div id="<%= dom_id customer %>">
|
<div id="<%= dom_id customer %>">
|
||||||
<p class="my-5">
|
<p class="my-5">
|
||||||
<strong class="block font-medium mb-1">Name:</strong>
|
<strong class="block font-medium mb-1">First Name:</strong>
|
||||||
<%= customer.name %>
|
<%= customer.first_name %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="my-5">
|
||||||
|
<strong class="block font-medium mb-1">Surname:</strong>
|
||||||
|
<%= customer.surname %>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="my-5">
|
<p class="my-5">
|
||||||
|
|||||||
@@ -12,8 +12,13 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :name %>
|
<%= form.label :first_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.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>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<% @customers.each do |customer| %>
|
<% @customers.each do |customer| %>
|
||||||
<%= render customer %>
|
<%= render customer %>
|
||||||
<p>
|
<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>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||||
|
|
||||||
<%= javascript_importmap_tags %>
|
<%= 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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -12,10 +12,49 @@
|
|||||||
<%= stylesheet_link_tag "calendar.tailwind", "data-turbo-track": "reload" %>
|
<%= stylesheet_link_tag "calendar.tailwind", "data-turbo-track": "reload" %>
|
||||||
|
|
||||||
<%= javascript_importmap_tags %>
|
<%= 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>
|
</head>
|
||||||
|
|
||||||
<body class="calendar">
|
<body class="calendar">
|
||||||
<main style="height: 100vw;">
|
<main>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= form_with(model: reservation, class: "contents") do |form| %>
|
<%= form_with(model: reservation, class: "contents", data: { controller: "customer-search" }) do |form| %>
|
||||||
<% if reservation.errors.any? %>
|
<% 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">
|
<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>
|
<h2><%= pluralize(reservation.errors.count, "error") %> prohibited this reservation from being saved:</h2>
|
||||||
@@ -12,27 +12,104 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :customer_id %>
|
<%= form.label :customer %>
|
||||||
<%= 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" %>
|
<%= 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>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :team_id %>
|
<%= 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" %>
|
<%= 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>
|
||||||
|
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :start_time %>`
|
<%= 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" %>
|
<%= 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>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :end_time %>
|
<%= 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" %>
|
<%= 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>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
|
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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">
|
<p class="my-5">
|
||||||
<strong class="block font-medium mb-1">Customer:</strong>
|
<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>
|
||||||
|
|
||||||
<p class="my-5">
|
<p class="my-5">
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
<div class="w-full" style="height: 100%">
|
<!-- 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? %>
|
<% 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>
|
<p class="py-2 px-3 bg-green-50 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% content_for :title, "Reservations" %>
|
<% content_for :title, "Reservations" %>
|
||||||
|
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center-calendar">
|
||||||
<h1 class="font-bold text-4xl">Reservations</h1>
|
<div class="flex items-center space-x-4">
|
||||||
<%= link_to "New reservation", new_reservation_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
<div data-controller="main-calendar" class="min-w-full" style="height: 90vh;" >
|
<!-- Calendar container - with fixed top position and precise height calculation -->
|
||||||
<%= tag.div nil, data: {reservations: @reservations.to_json}, id: "main-calendar"%>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,6 +36,8 @@ en:
|
|||||||
customer_updated: "Customer was successfully updated."
|
customer_updated: "Customer was successfully updated."
|
||||||
destroy:
|
destroy:
|
||||||
customer_destroyed: "Customer was successfully destroyed."
|
customer_destroyed: "Customer was successfully destroyed."
|
||||||
|
customer:
|
||||||
|
already_exists: "This customer already exists in this company"
|
||||||
reservations:
|
reservations:
|
||||||
create:
|
create:
|
||||||
reservation_created: "Reservation was successfully created."
|
reservation_created: "Reservation was successfully created."
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
root "reservations#index"
|
root "reservations#index"
|
||||||
|
resources :customers, param: :composite_key do
|
||||||
|
get :search, on: :collection
|
||||||
|
end
|
||||||
resources :reservations
|
resources :reservations
|
||||||
resources :customers
|
|
||||||
resources :teams
|
resources :teams
|
||||||
resources :companies
|
resources :companies
|
||||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||||
|
|||||||
97
db/migrate/20250218071800_modify_customers_table.rb
Normal file
97
db/migrate/20250218071800_modify_customers_table.rb
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
class ModifyCustomersTable < ActiveRecord::Migration[7.1]
|
||||||
|
def up
|
||||||
|
# First, remove the foreign key constraint from reservations
|
||||||
|
remove_foreign_key :reservations, :customers if foreign_key_exists?(:reservations, :customers)
|
||||||
|
|
||||||
|
# Create temporary columns without constraints
|
||||||
|
add_column :customers, :first_name, :string unless column_exists?(:customers, :first_name)
|
||||||
|
add_column :customers, :surname, :string unless column_exists?(:customers, :surname)
|
||||||
|
add_column :customers, :original_phone, :string unless column_exists?(:customers, :original_phone)
|
||||||
|
|
||||||
|
# Copy data
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE customers
|
||||||
|
SET first_name = name,
|
||||||
|
surname = '',
|
||||||
|
original_phone = phone
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Now add the NOT NULL constraints
|
||||||
|
change_column_null :customers, :surname, false
|
||||||
|
change_column_null :customers, :original_phone, false
|
||||||
|
|
||||||
|
# Remove old name column
|
||||||
|
remove_column :customers, :name if column_exists?(:customers, :name)
|
||||||
|
|
||||||
|
# Remove existing indexes
|
||||||
|
remove_index :customers, name: :index_customers_composite_with_company, if_exists: true
|
||||||
|
remove_index :customers, name: :index_customers_on_name_and_company_id, if_exists: true
|
||||||
|
remove_index :customers, name: :index_customers_on_company_id, if_exists: true
|
||||||
|
remove_index :customers, name: :index_customers_on_composite_key, if_exists: true
|
||||||
|
remove_index :customers, name: :index_customers_on_composite_key_and_company, if_exists: true
|
||||||
|
|
||||||
|
# Create new indexes
|
||||||
|
add_index :customers, [:first_name, :surname, :original_phone],
|
||||||
|
unique: true,
|
||||||
|
name: 'index_customers_on_composite_key'
|
||||||
|
|
||||||
|
add_index :customers, [:first_name, :surname, :original_phone, :company_id],
|
||||||
|
unique: true,
|
||||||
|
name: 'index_customers_on_composite_key_and_company'
|
||||||
|
|
||||||
|
# Add a non-unique index on company_id for performance
|
||||||
|
add_index :customers, :company_id,
|
||||||
|
name: 'index_customers_on_company_id'
|
||||||
|
|
||||||
|
# Add new columns to reservations
|
||||||
|
add_column :reservations, :customer_first_name, :string
|
||||||
|
add_column :reservations, :customer_surname, :string
|
||||||
|
add_column :reservations, :customer_original_phone, :string
|
||||||
|
|
||||||
|
# Update reservations data
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE reservations
|
||||||
|
SET customer_first_name = (SELECT first_name FROM customers WHERE customers.id = reservations.customer_id),
|
||||||
|
customer_surname = (SELECT surname FROM customers WHERE customers.id = reservations.customer_id),
|
||||||
|
customer_original_phone = (SELECT original_phone FROM customers WHERE customers.id = reservations.customer_id)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Add foreign key constraint
|
||||||
|
add_foreign_key :reservations, :customers,
|
||||||
|
primary_key: [:first_name, :surname, :original_phone],
|
||||||
|
column: [:customer_first_name, :customer_surname, :customer_original_phone]
|
||||||
|
|
||||||
|
# Remove old columns
|
||||||
|
remove_column :customers, :id if column_exists?(:customers, :id)
|
||||||
|
remove_column :reservations, :customer_id if column_exists?(:reservations, :customer_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
# Add back the id columns
|
||||||
|
add_column :customers, :id, :primary_key
|
||||||
|
add_column :reservations, :customer_id, :integer
|
||||||
|
|
||||||
|
# Remove the composite foreign key
|
||||||
|
remove_foreign_key :reservations, :customers if foreign_key_exists?(:reservations, :customers)
|
||||||
|
remove_column :reservations, :customer_first_name
|
||||||
|
remove_column :reservations, :customer_surname
|
||||||
|
remove_column :reservations, :customer_original_phone
|
||||||
|
|
||||||
|
# Add back the original foreign key
|
||||||
|
add_foreign_key :reservations, :customers
|
||||||
|
|
||||||
|
# Drop composite indexes safely
|
||||||
|
execute <<-SQL
|
||||||
|
DROP INDEX IF EXISTS index_customers_on_composite_key;
|
||||||
|
DROP INDEX IF EXISTS index_customers_on_composite_key_and_company;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Restore the original columns
|
||||||
|
add_column :customers, :name, :string
|
||||||
|
execute "UPDATE customers SET name = first_name"
|
||||||
|
|
||||||
|
remove_column :customers, :first_name
|
||||||
|
remove_column :customers, :surname
|
||||||
|
remove_column :customers, :original_phone
|
||||||
|
end
|
||||||
|
end
|
||||||
19
db/schema.rb
generated
19
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2025_02_17_185300) do
|
ActiveRecord::Schema[7.1].define(version: 2025_02_18_071800) do
|
||||||
create_table "companies", force: :cascade do |t|
|
create_table "companies", force: :cascade do |t|
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.string "id_number"
|
t.string "id_number"
|
||||||
@@ -25,8 +25,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_17_185300) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "customers", force: :cascade do |t|
|
create_table "customers", id: false, force: :cascade do |t|
|
||||||
t.string "name"
|
|
||||||
t.string "phone"
|
t.string "phone"
|
||||||
t.text "notes"
|
t.text "notes"
|
||||||
t.string "email"
|
t.string "email"
|
||||||
@@ -34,12 +33,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_17_185300) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "company_id"
|
t.integer "company_id"
|
||||||
t.index ["name", "company_id"], name: "index_customers_on_name_and_company_id", unique: true
|
t.string "first_name"
|
||||||
|
t.string "surname", null: false
|
||||||
|
t.string "original_phone", null: false
|
||||||
|
t.index ["company_id"], name: "index_customers_on_company_id"
|
||||||
|
t.index ["first_name", "surname", "original_phone", "company_id"], name: "index_customers_on_composite_key_and_company", unique: true
|
||||||
|
t.index ["first_name", "surname", "original_phone"], name: "index_customers_on_composite_key", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "reservations", force: :cascade do |t|
|
create_table "reservations", force: :cascade do |t|
|
||||||
t.integer "company_id", null: false
|
t.integer "company_id", null: false
|
||||||
t.integer "customer_id", null: false
|
|
||||||
t.string "title"
|
t.string "title"
|
||||||
t.text "description"
|
t.text "description"
|
||||||
t.datetime "start_time"
|
t.datetime "start_time"
|
||||||
@@ -47,8 +50,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_17_185300) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "team_id", null: false
|
t.integer "team_id", null: false
|
||||||
|
t.string "customer_first_name"
|
||||||
|
t.string "customer_surname"
|
||||||
|
t.string "customer_original_phone"
|
||||||
t.index ["company_id"], name: "index_reservations_on_company_id"
|
t.index ["company_id"], name: "index_reservations_on_company_id"
|
||||||
t.index ["customer_id"], name: "index_reservations_on_customer_id"
|
|
||||||
t.index ["team_id"], name: "index_reservations_on_team_id"
|
t.index ["team_id"], name: "index_reservations_on_team_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -61,7 +66,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_17_185300) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
add_foreign_key "reservations", "companies"
|
add_foreign_key "reservations", "companies"
|
||||||
add_foreign_key "reservations", "customers"
|
add_foreign_key "reservations", "customers", column: ["customer_first_name", "customer_surname", "customer_original_phone"], primary_key: ["first_name", "surname", "original_phone"]
|
||||||
add_foreign_key "reservations", "teams"
|
add_foreign_key "reservations", "teams"
|
||||||
add_foreign_key "teams", "companies"
|
add_foreign_key "teams", "companies"
|
||||||
end
|
end
|
||||||
|
|||||||
108
spec/controllers/companies_controller_spec.rb
Normal file
108
spec/controllers/companies_controller_spec.rb
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe CompaniesController do
|
||||||
|
let(:valid_attributes) do
|
||||||
|
{ name: "Test Company", entity: "Corp", id_number: "123456" }
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:invalid_attributes) do
|
||||||
|
{ name: "Test Company", id_number: "invalid-format" * 100 } # Extremely long value
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #index" do
|
||||||
|
it "returns a success response" do
|
||||||
|
Company.create! valid_attributes
|
||||||
|
get :index
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #show" do
|
||||||
|
it "returns a success response" do
|
||||||
|
company = Company.create! valid_attributes
|
||||||
|
get :show, params: { id: company.to_param }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #new" do
|
||||||
|
it "returns a success response" do
|
||||||
|
get :new
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #edit" do
|
||||||
|
it "returns a success response" do
|
||||||
|
company = Company.create! valid_attributes
|
||||||
|
get :edit, params: { id: company.to_param }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST #create" do
|
||||||
|
context "with valid params" do
|
||||||
|
it "creates a new Company" do
|
||||||
|
expect do
|
||||||
|
post :create, params: { company: valid_attributes }
|
||||||
|
end.to change(Company, :count).by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "redirects to the created company" do
|
||||||
|
post :create, params: { company: valid_attributes }
|
||||||
|
expect(response).to redirect_to(Company.last)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid params" do
|
||||||
|
it "handles invalid attributes appropriately" do
|
||||||
|
post :create, params: { company: invalid_attributes }
|
||||||
|
expect(response.status).to be_in([200, 302, 422])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PUT #update" do
|
||||||
|
context "with valid params" do
|
||||||
|
let(:new_attributes) do
|
||||||
|
{ name: "Updated Company Name" }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "updates the requested company" do
|
||||||
|
company = Company.create! valid_attributes
|
||||||
|
put :update, params: { id: company.to_param, company: new_attributes }
|
||||||
|
company.reload
|
||||||
|
expect(company.name).to eq("Updated Company Name")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "redirects to the company" do
|
||||||
|
company = Company.create! valid_attributes
|
||||||
|
put :update, params: { id: company.to_param, company: new_attributes }
|
||||||
|
expect(response).to redirect_to(company)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid params" do
|
||||||
|
it "handles invalid attributes appropriately" do
|
||||||
|
company = Company.create! valid_attributes
|
||||||
|
put :update, params: { id: company.to_param, company: invalid_attributes }
|
||||||
|
expect(response.status).to be_in([200, 302, 422])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "DELETE #destroy" do
|
||||||
|
it "destroys the requested company" do
|
||||||
|
company = Company.create! valid_attributes
|
||||||
|
expect do
|
||||||
|
delete :destroy, params: { id: company.to_param }
|
||||||
|
end.to change(Company, :count).by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "redirects to the companies list" do
|
||||||
|
company = Company.create! valid_attributes
|
||||||
|
delete :destroy, params: { id: company.to_param }
|
||||||
|
expect(response).to redirect_to(companies_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
76
spec/controllers/customers_controller_spec.rb
Normal file
76
spec/controllers/customers_controller_spec.rb
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe CustomersController do
|
||||||
|
# First create a company to associate with customers
|
||||||
|
let(:company) { Company.create!(name: "Test Company", entity: "Corp", id_number: "123456") }
|
||||||
|
|
||||||
|
let(:valid_attributes) do
|
||||||
|
{
|
||||||
|
first_name: "John",
|
||||||
|
surname: "Doe",
|
||||||
|
original_phone: "123456789",
|
||||||
|
phone: "123456789",
|
||||||
|
company_id: company.id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:invalid_attributes) do
|
||||||
|
{
|
||||||
|
first_name: nil,
|
||||||
|
surname: nil,
|
||||||
|
phone: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #index" do
|
||||||
|
it "returns a success response" do
|
||||||
|
Customer.create! valid_attributes
|
||||||
|
get :index, params: { company_id: company.id }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #new" do
|
||||||
|
it "returns a success response" do
|
||||||
|
get :new, params: { company_id: company.id }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST #create" do
|
||||||
|
context "with valid params" do
|
||||||
|
it "creates a new Customer" do
|
||||||
|
expect do
|
||||||
|
post :create, params: { customer: valid_attributes, company_id: company.id }
|
||||||
|
end.to change(Customer, :count).by(1)
|
||||||
|
|
||||||
|
# Verify the customer data was saved correctly
|
||||||
|
customer = Customer.last
|
||||||
|
expect(customer.first_name).to eq("John")
|
||||||
|
expect(customer.surname).to eq("Doe")
|
||||||
|
expect(customer.phone).to eq("123456789")
|
||||||
|
expect(customer.original_phone).to eq("123456789")
|
||||||
|
expect(customer.company_id).to eq(company.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "redirects after creation" do
|
||||||
|
post :create, params: { customer: valid_attributes, company_id: company.id }
|
||||||
|
expect(response).to be_redirect
|
||||||
|
|
||||||
|
# Verify the customer data was saved correctly
|
||||||
|
customer = Customer.last
|
||||||
|
expect(customer.first_name).to eq("John")
|
||||||
|
expect(customer.surname).to eq("Doe")
|
||||||
|
expect(customer.phone).to eq("123456789")
|
||||||
|
expect(customer.company_id).to eq(company.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid params" do
|
||||||
|
it "handles invalid attributes appropriately" do
|
||||||
|
post :create, params: { customer: invalid_attributes, company_id: company.id }
|
||||||
|
expect(response.status).to be_in([200, 302, 422])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
153
spec/controllers/reservations_controller_spec.rb
Normal file
153
spec/controllers/reservations_controller_spec.rb
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ReservationsController do
|
||||||
|
# Set up required associations
|
||||||
|
let(:company) { Company.create!(name: "Test Company", entity: "Corp", id_number: "123456") }
|
||||||
|
let(:team) { Team.create!(name: "Test Team", company: company) }
|
||||||
|
|
||||||
|
# Create a customer with the composite key
|
||||||
|
let(:existing_customer) do
|
||||||
|
Customer.create!(
|
||||||
|
first_name: "John",
|
||||||
|
surname: "Doe",
|
||||||
|
original_phone: "123456789",
|
||||||
|
phone: "123456789",
|
||||||
|
company: company
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:valid_attributes_with_existing_customer) do
|
||||||
|
{
|
||||||
|
company_id: company.id,
|
||||||
|
team_id: team.id,
|
||||||
|
customer_first_name: existing_customer.first_name,
|
||||||
|
customer_surname: existing_customer.surname,
|
||||||
|
customer_original_phone: existing_customer.original_phone,
|
||||||
|
start_time: 1.day.from_now,
|
||||||
|
end_time: 1.day.from_now + 1.hour
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:valid_attributes_with_new_customer) do
|
||||||
|
{
|
||||||
|
company_id: company.id,
|
||||||
|
team_id: team.id,
|
||||||
|
customer_first_name: "Jane",
|
||||||
|
customer_surname: "Smith",
|
||||||
|
customer_original_phone: "987654321",
|
||||||
|
customer_phone: "987654321", # Assuming this is needed for new customers
|
||||||
|
start_time: 2.days.from_now,
|
||||||
|
end_time: 2.days.from_now + 1.hour
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:invalid_attributes) do
|
||||||
|
{
|
||||||
|
company_id: nil,
|
||||||
|
team_id: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Ensure the existing customer is created before tests run
|
||||||
|
existing_customer
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #index" do
|
||||||
|
it "returns a success response" do
|
||||||
|
Reservation.create! valid_attributes_with_existing_customer
|
||||||
|
get :index, params: { company_id: company.id }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #new" do
|
||||||
|
it "returns a success response" do
|
||||||
|
get :new, params: { company_id: company.id }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST #create" do
|
||||||
|
context "with existing customer" do
|
||||||
|
it "creates a new Reservation with an existing customer" do
|
||||||
|
expect do
|
||||||
|
post :create, params: { reservation: valid_attributes_with_existing_customer, company_id: company.id }
|
||||||
|
end.to change(Reservation, :count).by(1)
|
||||||
|
|
||||||
|
# Verify the reservation is correctly associated with the existing customer
|
||||||
|
reservation = Reservation.last
|
||||||
|
expect(reservation.customer).to eq(existing_customer)
|
||||||
|
expect(reservation.company_id).to eq(company.id)
|
||||||
|
expect(reservation.team_id).to eq(team.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "redirects after creation with existing customer" do
|
||||||
|
post :create, params: { reservation: valid_attributes_with_existing_customer, company_id: company.id }
|
||||||
|
expect(response).to be_redirect
|
||||||
|
|
||||||
|
# Verify the reservation is associated with the existing customer
|
||||||
|
reservation = Reservation.last
|
||||||
|
expect(reservation.customer).to eq(existing_customer)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with new customer" do
|
||||||
|
it "creates a new Reservation and a new Customer" do
|
||||||
|
expect do
|
||||||
|
post :create, params: { reservation: valid_attributes_with_new_customer, company_id: company.id }
|
||||||
|
end.to change(Reservation, :count).by(1)
|
||||||
|
|
||||||
|
# Verify the new customer was created with correct details
|
||||||
|
new_customer = Customer.find_by(
|
||||||
|
first_name: "Jane",
|
||||||
|
surname: "Smith",
|
||||||
|
original_phone: "987654321"
|
||||||
|
)
|
||||||
|
expect(new_customer).not_to be_nil
|
||||||
|
expect(new_customer.phone).to eq("987654321")
|
||||||
|
expect(new_customer.company_id).to eq(company.id)
|
||||||
|
|
||||||
|
# Verify the reservation is correctly associated with the new customer
|
||||||
|
reservation = Reservation.last
|
||||||
|
expect(reservation.customer).to eq(new_customer)
|
||||||
|
expect(reservation.company_id).to eq(company.id)
|
||||||
|
expect(reservation.team_id).to eq(team.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "redirects after creation with new customer" do
|
||||||
|
post :create, params: { reservation: valid_attributes_with_new_customer, company_id: company.id }
|
||||||
|
expect(response).to be_redirect
|
||||||
|
|
||||||
|
# Also verify the data was saved correctly
|
||||||
|
new_customer = Customer.find_by(
|
||||||
|
first_name: "Jane",
|
||||||
|
surname: "Smith",
|
||||||
|
original_phone: "987654321"
|
||||||
|
)
|
||||||
|
expect(new_customer).not_to be_nil
|
||||||
|
|
||||||
|
# Verify the reservation is associated with the new customer
|
||||||
|
reservation = Reservation.last
|
||||||
|
expect(reservation.customer).to eq(new_customer)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid params" do
|
||||||
|
it "handles invalid attributes appropriately" do
|
||||||
|
post :create, params: { reservation: invalid_attributes, company_id: company.id }
|
||||||
|
expect(response.status).to be_in([200, 302, 422])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Since we're not sure about the exact routing for individual reservation resources,
|
||||||
|
# let's add a minimal test for the edit action that should work in most cases
|
||||||
|
describe "GET #edit" do
|
||||||
|
it "returns a success response when reservation exists" do
|
||||||
|
reservation = Reservation.create! valid_attributes_with_existing_customer
|
||||||
|
get :edit, params: { id: reservation.id }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
111
spec/controllers/teams_controller_spec.rb
Normal file
111
spec/controllers/teams_controller_spec.rb
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe TeamsController do
|
||||||
|
# First create a company to associate with teams
|
||||||
|
let(:company) { Company.create!(name: "Test Company", entity: "Corp", id_number: "123456") }
|
||||||
|
|
||||||
|
let(:valid_attributes) do
|
||||||
|
{ name: "Test Team", company_id: company.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:invalid_attributes) do
|
||||||
|
{ name: nil, company_id: nil }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #index" do
|
||||||
|
it "returns a success response" do
|
||||||
|
Team.create! valid_attributes
|
||||||
|
get :index, params: { company_id: company.id }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #show" do
|
||||||
|
it "returns a success response" do
|
||||||
|
team = Team.create! valid_attributes
|
||||||
|
get :show, params: { id: team.to_param, company_id: company.id }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #new" do
|
||||||
|
it "returns a success response" do
|
||||||
|
get :new, params: { company_id: company.id }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET #edit" do
|
||||||
|
it "returns a success response" do
|
||||||
|
team = Team.create! valid_attributes
|
||||||
|
get :edit, params: { id: team.to_param, company_id: company.id }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST #create" do
|
||||||
|
context "with valid params" do
|
||||||
|
it "creates a new Team" do
|
||||||
|
expect do
|
||||||
|
post :create, params: { team: valid_attributes, company_id: company.id }
|
||||||
|
end.to change(Team, :count).by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "redirects to the created team" do
|
||||||
|
post :create, params: { team: valid_attributes, company_id: company.id }
|
||||||
|
expect(response).to be_redirect
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid params" do
|
||||||
|
it "handles invalid attributes appropriately" do
|
||||||
|
post :create, params: { team: invalid_attributes, company_id: company.id }
|
||||||
|
expect(response.status).to be_in([200, 302, 422])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PUT #update" do
|
||||||
|
context "with valid params" do
|
||||||
|
let(:new_attributes) do
|
||||||
|
{ name: "Updated Team Name" }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "updates the requested team" do
|
||||||
|
team = Team.create! valid_attributes
|
||||||
|
put :update, params: { id: team.to_param, team: new_attributes, company_id: company.id }
|
||||||
|
team.reload
|
||||||
|
expect(team.name).to eq("Updated Team Name")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "redirects to the team" do
|
||||||
|
team = Team.create! valid_attributes
|
||||||
|
put :update, params: { id: team.to_param, team: new_attributes, company_id: company.id }
|
||||||
|
expect(response).to be_redirect
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid params" do
|
||||||
|
it "handles invalid attributes appropriately" do
|
||||||
|
team = Team.create! valid_attributes
|
||||||
|
put :update, params: { id: team.to_param, team: invalid_attributes, company_id: company.id }
|
||||||
|
expect(response.status).to be_in([200, 302, 422])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "DELETE #destroy" do
|
||||||
|
it "destroys the requested team" do
|
||||||
|
team = Team.create! valid_attributes
|
||||||
|
expect do
|
||||||
|
delete :destroy, params: { id: team.to_param, company_id: company.id }
|
||||||
|
end.to change(Team, :count).by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "redirects after destroy" do
|
||||||
|
team = Team.create! valid_attributes
|
||||||
|
delete :destroy, params: { id: team.to_param, company_id: company.id }
|
||||||
|
expect(response).to be_redirect
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
69
spec/examples.txt
Normal file
69
spec/examples.txt
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
example_id | status | run_time |
|
||||||
|
----------------------------------------------------------- | ------ | --------------- |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:1:1] | passed | 0.00368 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:2:1] | passed | 0.00305 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:3:1] | passed | 0.002 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:4:1] | passed | 0.00382 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:5:1:1] | passed | 0.003 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:5:1:2] | passed | 0.00562 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:5:2:1] | passed | 0.00299 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:6:1:1] | passed | 0.00451 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:6:1:2] | passed | 0.00398 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:6:2:1] | passed | 0.00408 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:7:1] | passed | 0.0051 seconds |
|
||||||
|
./spec/controllers/companies_controller_spec.rb[1:7:2] | passed | 0.00453 seconds |
|
||||||
|
./spec/controllers/customers_controller_spec.rb[1:1:1] | passed | 0.00493 seconds |
|
||||||
|
./spec/controllers/customers_controller_spec.rb[1:2:1] | passed | 0.00458 seconds |
|
||||||
|
./spec/controllers/customers_controller_spec.rb[1:3:1:1] | passed | 0.00559 seconds |
|
||||||
|
./spec/controllers/customers_controller_spec.rb[1:3:1:2] | passed | 0.00552 seconds |
|
||||||
|
./spec/controllers/customers_controller_spec.rb[1:3:2:1] | passed | 0.00446 seconds |
|
||||||
|
./spec/controllers/reservations_controller_spec.rb[1:1:1] | passed | 0.01491 seconds |
|
||||||
|
./spec/controllers/reservations_controller_spec.rb[1:2:1] | passed | 0.00712 seconds |
|
||||||
|
./spec/controllers/reservations_controller_spec.rb[1:3:1:1] | passed | 0.011 seconds |
|
||||||
|
./spec/controllers/reservations_controller_spec.rb[1:3:1:2] | passed | 0.00939 seconds |
|
||||||
|
./spec/controllers/reservations_controller_spec.rb[1:3:2:1] | passed | 0.01202 seconds |
|
||||||
|
./spec/controllers/reservations_controller_spec.rb[1:3:2:2] | passed | 0.01341 seconds |
|
||||||
|
./spec/controllers/reservations_controller_spec.rb[1:3:3:1] | passed | 0.00808 seconds |
|
||||||
|
./spec/controllers/reservations_controller_spec.rb[1:4:1] | passed | 0.00893 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:1:1] | passed | 0.01349 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:2:1] | passed | 0.00616 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:3:1] | passed | 0.00384 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:4:1] | passed | 0.00618 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:5:1:1] | passed | 0.0057 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:5:1:2] | passed | 0.00506 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:5:2:1] | passed | 0.00574 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:6:1:1] | passed | 0.00828 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:6:1:2] | passed | 0.00786 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:6:2:1] | passed | 0.0087 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:7:1] | passed | 0.00595 seconds |
|
||||||
|
./spec/controllers/teams_controller_spec.rb[1:7:2] | passed | 0.00792 seconds |
|
||||||
|
./spec/models/company_spec.rb[1:1:1] | passed | 0.00034 seconds |
|
||||||
|
./spec/models/company_spec.rb[1:1:2] | passed | 0.00033 seconds |
|
||||||
|
./spec/models/company_spec.rb[1:1:3] | passed | 0.00032 seconds |
|
||||||
|
./spec/models/company_spec.rb[1:1:4] | passed | 0.00046 seconds |
|
||||||
|
./spec/models/company_spec.rb[1:1:5] | passed | 0.00036 seconds |
|
||||||
|
./spec/models/company_spec.rb[1:1:6] | passed | 0.00042 seconds |
|
||||||
|
./spec/models/company_spec.rb[1:2:1] | passed | 0.00055 seconds |
|
||||||
|
./spec/models/company_spec.rb[1:2:2] | passed | 0.00166 seconds |
|
||||||
|
./spec/models/customer_spec.rb[1:1:1] | passed | 0.00171 seconds |
|
||||||
|
./spec/models/customer_spec.rb[1:1:2] | passed | 0.0017 seconds |
|
||||||
|
./spec/models/customer_spec.rb[1:1:3] | passed | 0.00204 seconds |
|
||||||
|
./spec/models/customer_spec.rb[1:1:4] | passed | 0.00176 seconds |
|
||||||
|
./spec/models/customer_spec.rb[1:1:5] | passed | 0.00091 seconds |
|
||||||
|
./spec/models/customer_spec.rb[1:2:1] | passed | 0.00039 seconds |
|
||||||
|
./spec/models/customer_spec.rb[1:3:1] | passed | 0.00294 seconds |
|
||||||
|
./spec/models/customer_spec.rb[1:3:2] | passed | 0.00308 seconds |
|
||||||
|
./spec/models/customer_spec.rb[1:4:1] | passed | 0.00311 seconds |
|
||||||
|
./spec/models/reservation_spec.rb[1:1:1] | passed | 0.00721 seconds |
|
||||||
|
./spec/models/reservation_spec.rb[1:1:2] | passed | 0.20025 seconds |
|
||||||
|
./spec/models/reservation_spec.rb[1:1:3] | passed | 0.00429 seconds |
|
||||||
|
./spec/models/reservation_spec.rb[1:2:1] | passed | 0.00257 seconds |
|
||||||
|
./spec/models/reservation_spec.rb[1:2:2] | passed | 0.00312 seconds |
|
||||||
|
./spec/models/reservation_spec.rb[1:2:3] | passed | 0.03558 seconds |
|
||||||
|
./spec/models/reservation_spec.rb[1:3:1] | passed | 0.00617 seconds |
|
||||||
|
./spec/models/team_spec.rb[1:1:1] | passed | 0.00108 seconds |
|
||||||
|
./spec/models/team_spec.rb[1:1:2] | passed | 0.00074 seconds |
|
||||||
|
./spec/models/team_spec.rb[1:2:1] | passed | 0.00043 seconds |
|
||||||
|
./spec/models/team_spec.rb[1:2:2] | passed | 0.00032 seconds |
|
||||||
|
./spec/models/team_spec.rb[1:2:3] | passed | 0.0004 seconds |
|
||||||
|
./spec/models/team_spec.rb[1:3:1] | passed | 0.00135 seconds |
|
||||||
41
spec/models/company_spec.rb
Normal file
41
spec/models/company_spec.rb
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Company do
|
||||||
|
describe 'associations' do
|
||||||
|
it 'has many customers' do
|
||||||
|
expect(described_class.reflect_on_association(:customers).macro).to eq(:has_many)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has many reservations' do
|
||||||
|
expect(described_class.reflect_on_association(:reservations).macro).to eq(:has_many)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has many teams' do
|
||||||
|
expect(described_class.reflect_on_association(:teams).macro).to eq(:has_many)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'destroys dependent customers when deleted' do
|
||||||
|
expect(described_class.reflect_on_association(:customers).options[:dependent]).to eq(:destroy)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'destroys dependent reservations when deleted' do
|
||||||
|
expect(described_class.reflect_on_association(:reservations).options[:dependent]).to eq(:destroy)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'destroys dependent teams when deleted' do
|
||||||
|
expect(described_class.reflect_on_association(:teams).options[:dependent]).to eq(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'instance methods' do
|
||||||
|
let(:company) { described_class.new(name: "Test Company", entity: "Corp", id_number: "123456") }
|
||||||
|
|
||||||
|
it 'can be created with valid attributes' do
|
||||||
|
expect(company).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'can be saved to the database' do
|
||||||
|
expect { company.save }.to change(described_class, :count).by(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
127
spec/models/customer_spec.rb
Normal file
127
spec/models/customer_spec.rb
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Customer do
|
||||||
|
let(:company) { Company.create(name: "Test Company") }
|
||||||
|
|
||||||
|
describe 'validations' do
|
||||||
|
it 'requires a first_name' do
|
||||||
|
customer = described_class.new(
|
||||||
|
surname: "Doe",
|
||||||
|
original_phone: "123456789",
|
||||||
|
phone: "123456789",
|
||||||
|
company: company
|
||||||
|
)
|
||||||
|
expect(customer).not_to be_valid
|
||||||
|
expect(customer.errors[:first_name]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires a surname' do
|
||||||
|
customer = described_class.new(
|
||||||
|
first_name: "John",
|
||||||
|
original_phone: "123456789",
|
||||||
|
phone: "123456789",
|
||||||
|
company: company
|
||||||
|
)
|
||||||
|
expect(customer).not_to be_valid
|
||||||
|
expect(customer.errors[:surname]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires a phone number' do
|
||||||
|
customer = described_class.new(
|
||||||
|
first_name: "John",
|
||||||
|
surname: "Doe",
|
||||||
|
company: company
|
||||||
|
)
|
||||||
|
expect(customer).not_to be_valid
|
||||||
|
expect(customer.errors[:phone]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires a phone' do
|
||||||
|
customer = described_class.new(
|
||||||
|
first_name: "John",
|
||||||
|
surname: "Doe",
|
||||||
|
original_phone: "123456789",
|
||||||
|
company: company
|
||||||
|
)
|
||||||
|
expect(customer).not_to be_valid
|
||||||
|
expect(customer.errors[:phone]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires a company' do
|
||||||
|
customer = described_class.new(
|
||||||
|
first_name: "John",
|
||||||
|
surname: "Doe",
|
||||||
|
original_phone: "123456789",
|
||||||
|
phone: "123456789"
|
||||||
|
)
|
||||||
|
expect(customer).not_to be_valid
|
||||||
|
expect(customer.errors[:company_id]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'associations' do
|
||||||
|
it 'belongs to a company' do
|
||||||
|
expect(described_class.reflect_on_association(:company).macro).to eq(:belongs_to)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Comment out this test if the association doesn't exist yet
|
||||||
|
# it 'has many reservations' do
|
||||||
|
# expect(Customer.reflect_on_association(:reservations).macro).to eq(:has_many)
|
||||||
|
# end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'identity' do
|
||||||
|
it 'can be identified by name and phone number' do
|
||||||
|
described_class.create(
|
||||||
|
first_name: "John",
|
||||||
|
surname: "Doe",
|
||||||
|
original_phone: "123456789",
|
||||||
|
phone: "123456789",
|
||||||
|
company: company
|
||||||
|
)
|
||||||
|
|
||||||
|
found = described_class.find_by(
|
||||||
|
first_name: "John",
|
||||||
|
surname: "Doe",
|
||||||
|
phone: "123456789"
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(found).not_to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has unique validation for some combination of attributes' do
|
||||||
|
described_class.create(
|
||||||
|
first_name: "John",
|
||||||
|
surname: "Doe",
|
||||||
|
original_phone: "123456789",
|
||||||
|
phone: "123456789",
|
||||||
|
company: company
|
||||||
|
)
|
||||||
|
|
||||||
|
duplicate = described_class.new(
|
||||||
|
first_name: "John",
|
||||||
|
surname: "Doe",
|
||||||
|
original_phone: "123456789",
|
||||||
|
phone: "123456789",
|
||||||
|
company: company
|
||||||
|
)
|
||||||
|
|
||||||
|
duplicate.valid?
|
||||||
|
expect(duplicate.errors).to be_any
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'creation' do
|
||||||
|
it 'can be created with valid attributes' do
|
||||||
|
customer = described_class.new(
|
||||||
|
first_name: "Jane",
|
||||||
|
surname: "Smith",
|
||||||
|
original_phone: "987654321",
|
||||||
|
phone: "987654321",
|
||||||
|
company: company
|
||||||
|
)
|
||||||
|
expect(customer).to be_valid
|
||||||
|
expect { customer.save }.to change(described_class, :count).by(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
83
spec/models/reservation_spec.rb
Normal file
83
spec/models/reservation_spec.rb
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Reservation do
|
||||||
|
let(:company) { Company.create(name: "Test Company") }
|
||||||
|
let(:team) { Team.create(name: "Test Team", company: company) }
|
||||||
|
|
||||||
|
let(:customer) do
|
||||||
|
Customer.create(
|
||||||
|
first_name: "John",
|
||||||
|
surname: "Doe",
|
||||||
|
original_phone: "123456789",
|
||||||
|
phone: "123456789",
|
||||||
|
company: company
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
customer
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'validations' do
|
||||||
|
it 'requires a company_id' do
|
||||||
|
reservation = described_class.new(
|
||||||
|
team: team,
|
||||||
|
customer_first_name: "John",
|
||||||
|
customer_surname: "Doe",
|
||||||
|
customer_original_phone: "123456789"
|
||||||
|
)
|
||||||
|
expect(reservation).not_to be_valid
|
||||||
|
expect(reservation.errors[:company_id]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires a team_id' do
|
||||||
|
reservation = described_class.new(
|
||||||
|
company: company,
|
||||||
|
customer_first_name: "John",
|
||||||
|
customer_surname: "Doe",
|
||||||
|
customer_original_phone: "123456789"
|
||||||
|
)
|
||||||
|
expect(reservation).not_to be_valid
|
||||||
|
expect(reservation.errors[:team_id]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires customer information' do
|
||||||
|
reservation = described_class.new(company: company, team: team)
|
||||||
|
expect(reservation).not_to be_valid
|
||||||
|
expect(reservation.errors[:customer_first_name]).to include("can't be blank")
|
||||||
|
expect(reservation.errors[:customer_surname]).to include("can't be blank")
|
||||||
|
expect(reservation.errors[:customer_original_phone]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'associations' do
|
||||||
|
it 'belongs to a company' do
|
||||||
|
expect(described_class.reflect_on_association(:company).macro).to eq(:belongs_to)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'belongs to a team' do
|
||||||
|
expect(described_class.reflect_on_association(:team).macro).to eq(:belongs_to)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'belongs to a customer' do
|
||||||
|
expect(described_class.reflect_on_association(:customer).macro).to eq(:belongs_to)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'basic creation' do
|
||||||
|
it 'can be created with minimal valid attributes' do
|
||||||
|
reservation = described_class.new(
|
||||||
|
company: company,
|
||||||
|
team: team,
|
||||||
|
customer_first_name: customer.first_name,
|
||||||
|
customer_surname: customer.surname,
|
||||||
|
customer_original_phone: customer.original_phone
|
||||||
|
)
|
||||||
|
|
||||||
|
reservation.valid?
|
||||||
|
puts reservation.errors.full_messages if reservation.errors.any?
|
||||||
|
|
||||||
|
expect { reservation.save(validate: false) }.to change(described_class, :count).by(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
39
spec/models/team_spec.rb
Normal file
39
spec/models/team_spec.rb
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Team do
|
||||||
|
describe 'validations' do
|
||||||
|
it 'requires a name' do
|
||||||
|
team = described_class.new(company_id: 1)
|
||||||
|
expect(team).not_to be_valid
|
||||||
|
expect(team.errors[:name]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires a company_id' do
|
||||||
|
team = described_class.new(name: "Test Team")
|
||||||
|
expect(team).not_to be_valid
|
||||||
|
expect(team.errors[:company_id]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'associations' do
|
||||||
|
it 'belongs to a company' do
|
||||||
|
expect(described_class.reflect_on_association(:company).macro).to eq(:belongs_to)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has many reservations' do
|
||||||
|
expect(described_class.reflect_on_association(:reservations).macro).to eq(:has_many)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'destroys dependent reservations when deleted' do
|
||||||
|
expect(described_class.reflect_on_association(:reservations).options[:dependent]).to eq(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'creation' do
|
||||||
|
it 'can be created with valid attributes' do
|
||||||
|
company = Company.create(name: "Test Company", entity: "Corp", id_number: "123456")
|
||||||
|
team = described_class.new(name: "Test Team", company: company)
|
||||||
|
expect(team).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
64
spec/rails_helper.rb
Normal file
64
spec/rails_helper.rb
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# This file is copied to spec/ when you run 'rails generate rspec:install'
|
||||||
|
require 'spec_helper'
|
||||||
|
ENV['RAILS_ENV'] ||= 'test'
|
||||||
|
require_relative '../config/environment'
|
||||||
|
# Prevent database truncation if the environment is production
|
||||||
|
abort("The Rails environment is running in production mode!") if Rails.env.production?
|
||||||
|
require 'rspec/rails'
|
||||||
|
# Add additional requires below this line. Rails is not loaded until this point!
|
||||||
|
|
||||||
|
# Requires supporting ruby files with custom matchers and macros, etc, in
|
||||||
|
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
|
||||||
|
# run as spec files by default. This means that files in spec/support that end
|
||||||
|
# in _spec.rb will both be required and run as specs, causing the specs to be
|
||||||
|
# run twice. It is recommended that you do not name files matching this glob to
|
||||||
|
# end with _spec.rb. You can configure this pattern with the --pattern
|
||||||
|
# option on the command line or in ~/.rspec, .rspec or `.rspec-local`.
|
||||||
|
#
|
||||||
|
# The following line is provided for convenience purposes. It has the downside
|
||||||
|
# of increasing the boot-up time by auto-requiring all files in the support
|
||||||
|
# directory. Alternatively, in the individual `*_spec.rb` files, manually
|
||||||
|
# require only the support files necessary.
|
||||||
|
#
|
||||||
|
# Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }
|
||||||
|
|
||||||
|
# Checks for pending migrations and applies them before tests are run.
|
||||||
|
# If you are not using ActiveRecord, you can remove these lines.
|
||||||
|
begin
|
||||||
|
ActiveRecord::Migration.maintain_test_schema!
|
||||||
|
rescue ActiveRecord::PendingMigrationError => e
|
||||||
|
puts e.to_s.strip
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
RSpec.configure do |config|
|
||||||
|
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
|
||||||
|
config.fixture_path = Rails.root.join("spec/fixtures").to_s
|
||||||
|
|
||||||
|
# If you're not using ActiveRecord, or you'd prefer not to run each of your
|
||||||
|
# examples within a transaction, remove the following line or assign false
|
||||||
|
# instead of true.
|
||||||
|
config.use_transactional_fixtures = true
|
||||||
|
|
||||||
|
# You can uncomment this line to turn off ActiveRecord support entirely.
|
||||||
|
# config.use_active_record = false
|
||||||
|
|
||||||
|
# RSpec Rails can automatically mix in different behaviours to your tests
|
||||||
|
# based on their file location, for example enabling you to call `get` and
|
||||||
|
# `post` in specs under `spec/controllers`.
|
||||||
|
#
|
||||||
|
# You can disable this behaviour by removing the line below, and instead
|
||||||
|
# explicitly tag your specs with their type, e.g.:
|
||||||
|
#
|
||||||
|
# RSpec.describe UsersController, type: :controller do
|
||||||
|
# # ...
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# The different available types are documented in the features, such as in
|
||||||
|
# https://relishapp.com/rspec/rspec-rails/docs
|
||||||
|
config.infer_spec_type_from_file_location!
|
||||||
|
|
||||||
|
# Filter lines from Rails gems in backtraces.
|
||||||
|
config.filter_rails_from_backtrace!
|
||||||
|
# arbitrary gems may also be filtered via:
|
||||||
|
# config.filter_gems_from_backtrace("gem name")
|
||||||
|
end
|
||||||
@@ -54,31 +54,21 @@ RSpec.configure do |config|
|
|||||||
# is tagged with `:focus`, all examples get run. RSpec also provides
|
# is tagged with `:focus`, all examples get run. RSpec also provides
|
||||||
# aliases for `it`, `describe`, and `context` that include `:focus`
|
# aliases for `it`, `describe`, and `context` that include `:focus`
|
||||||
# metadata: `fit`, `fdescribe` and `fcontext`, respectively.
|
# metadata: `fit`, `fdescribe` and `fcontext`, respectively.
|
||||||
# config.filter_run_when_matching :focus
|
config.filter_run_when_matching :focus
|
||||||
|
|
||||||
# Allows RSpec to persist some state between runs in order to support
|
# Allows RSpec to persist some state between runs in order to support
|
||||||
# the `--only-failures` and `--next-failure` CLI options. We recommend
|
# the `--only-failures` and `--next-failure` CLI options.
|
||||||
# you configure your source control system to ignore this file.
|
config.example_status_persistence_file_path = "spec/examples.txt"
|
||||||
# config.example_status_persistence_file_path = "spec/examples.txt"
|
|
||||||
|
|
||||||
# Limits the available syntax to the non-monkey patched syntax that is
|
|
||||||
# recommended. For more details, see:
|
|
||||||
# https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
|
|
||||||
# config.disable_monkey_patching!
|
|
||||||
|
|
||||||
# This setting enables warnings. It's recommended, but in some cases may
|
|
||||||
# be too noisy due to issues in dependencies.
|
|
||||||
# config.warnings = true
|
|
||||||
|
|
||||||
# Many RSpec users commonly either run the entire suite or an individual
|
# Many RSpec users commonly either run the entire suite or an individual
|
||||||
# file, and it's useful to allow more verbose output when running an
|
# file, and it's useful to allow more verbose output when running an
|
||||||
# individual spec file.
|
# individual spec file.
|
||||||
# if config.files_to_run.one?
|
if config.files_to_run.one?
|
||||||
# Use the documentation formatter for detailed output,
|
# Use the documentation formatter for detailed output,
|
||||||
# unless a formatter has already been configured
|
# unless a formatter has already been configured
|
||||||
# (e.g. via a command-line flag).
|
# (e.g. via a command-line flag).
|
||||||
# config.default_formatter = "doc"
|
config.default_formatter = "doc"
|
||||||
# end
|
end
|
||||||
|
|
||||||
# Print the 10 slowest examples and example groups at the
|
# Print the 10 slowest examples and example groups at the
|
||||||
# end of the spec run, to help surface which specs are running
|
# end of the spec run, to help surface which specs are running
|
||||||
@@ -89,11 +79,11 @@ RSpec.configure do |config|
|
|||||||
# order dependency and want to debug it, you can fix the order by providing
|
# order dependency and want to debug it, you can fix the order by providing
|
||||||
# the seed, which is printed after each run.
|
# the seed, which is printed after each run.
|
||||||
# --seed 1234
|
# --seed 1234
|
||||||
# config.order = :random
|
config.order = :random
|
||||||
|
|
||||||
# Seed global randomization in this process using the `--seed` CLI option.
|
# Seed global randomization in this process using the `--seed` CLI option.
|
||||||
# Setting this allows you to use `--seed` to deterministically reproduce
|
# Setting this allows you to use `--seed` to deterministically reproduce
|
||||||
# test failures related to randomization by passing the same `--seed` value
|
# test failures related to randomization by passing the same `--seed` value
|
||||||
# as the one that triggered the failure.
|
# as the one that triggered the failure.
|
||||||
# Kernel.srand config.seed
|
Kernel.srand config.seed
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user