Merge branch '2-napraviti-dodavanje-termina' into 'master'

Added customer composite key

Closes #2

See merge request kbr4/zsterminator!2
This commit is contained in:
2025-03-03 19:04:30 +00:00
committed by Nedim Uka
35 changed files with 1967 additions and 123 deletions

View 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 */
}

View File

@@ -11,14 +11,60 @@
}
*/
body.calendar {
height: 99vw;
overflow: hidden;
body.calendar {
height: 100vh !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
}
toastui-calendar-time {
height: 99% !important;
.toastui-calendar-time {
height: 100% !important;
}
.toastui-calendar-timegrid { height: 99%; }
.toastui-calendar-panel.toastui-calendar-time { overflow-y: inherit; }
.toastui-calendar-timegrid {
height: 100% !important;
}
.toastui-calendar-panel.toastui-calendar-time {
overflow-y: inherit !important;
}
div[data-controller="main-calendar"] {
height: 100vh !important;
margin: 0 !important;
padding: 0 !important;
}
#main-calendar {
height: 100% !important;
width: 100% !important;
}
body.calendar main {
height: 100vh !important;
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
}
.w-full {
margin: 0 !important;
padding: 0 !important;
}
.flex.justify-between.items-center {
padding: 0.5rem 1rem !important;
margin-bottom: 0 !important;
}
.flex.justify-between.items-center-calendar {
padding: 0.5rem 1rem !important;
margin-bottom: 0 !important;
position: relative;
}
.px-5 {
padding-left: 20px !important;
padding-right: 20px !important;
}

View 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;
}

View File

@@ -21,7 +21,7 @@ class CustomersController < ApplicationController
# POST /customers or /customers.json
def create
@customer = Customer.new(customer_params)
@customer.company = @company
@customer.company = Company.first # Set the first company
respond_to do |format|
if @customer.save
@@ -57,15 +57,35 @@ class CustomersController < ApplicationController
end
end
def search
@customers = @company.customers.where(
"LOWER(first_name) LIKE :query OR LOWER(surname) LIKE :query OR original_phone LIKE :query",
query: "%#{params[:q].downcase}%"
).limit(10)
render json: @customers.map { |c|
{
id: "#{c.first_name}_#{c.surname}_#{c.original_phone}",
label: "#{c.full_name} (#{c.original_phone})",
birthyear: c.birthyear
}
}
end
private
# Use callbacks to share common setup or constraints between actions.
def set_customer
@customer = Customer.find(params[:id])
first_name, surname, original_phone = params[:composite_key].split('_')
@customer = Customer.find_by!(
first_name: first_name,
surname: surname,
original_phone: original_phone
)
end
# Only allow a list of trusted parameters through.
def customer_params
params.require(:customer).permit(:name, :phone, :notes, :email, :birthyear)
params.require(:customer).permit(:first_name, :surname, :phone, :notes, :email, :birthyear)
end
end

View File

@@ -14,8 +14,22 @@ class ReservationsController < ApplicationController
# GET /reservations/new
def new
@reservation = Reservation.new
@reservation.team = @company.teams.first
# logger.debug "--- Reservations#new --- Params received: #{params.inspect}"
# Use Time.zone.parse to interpret times within the application's configured timezone
start_time_param = params[:start_time]
end_time_param = params[:end_time]
# logger.debug "--- Reservations#new --- Start Param: #{start_time_param}, End Param: #{end_time_param}"
start_time = start_time_param ? Time.zone.parse(start_time_param) : Time.current
end_time = end_time_param ? Time.zone.parse(end_time_param) : Time.current + 30.minutes
# logger.debug "--- Reservations#new --- Parsed Start: #{start_time}, Parsed End: #{end_time}"
@reservation = Reservation.new(start_time: start_time, end_time: end_time)
# logger.debug "--- Reservations#new --- Reservation object initialized: #{@reservation.attributes.inspect}"
@reservation.team = @company.teams.first # Assign default team
@customers = @company.customers # Preload customers for potential dropdown
end
# GET /reservations/1/edit
@@ -23,31 +37,79 @@ class ReservationsController < ApplicationController
# POST /reservations or /reservations.json
def create
@reservation = Reservation.new(reservation_params)
@reservation.company = @company
@reservation = @company.reservations.new(
reservation_params.except(:customer_id, :customer_birth_year)
)
respond_to do |format|
if @reservation.save
format.html { redirect_to reservation_url(@reservation), notice: t('.reservation_created') }
format.json { render :show, status: :created, location: @reservation }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @reservation.errors, status: :unprocessable_entity }
end
# Find or create customer based on submitted attributes
find_or_create_customer
# Associate the reservation with the found/created customer's primary key attributes
assign_customer_to_reservation
if @reservation.save
redirect_to @reservation, notice: t('.reservation_created')
else
@customers = @company.customers
render :new, status: :unprocessable_entity
end
rescue ActiveRecord::RecordInvalid => e
# If customer creation/validation fails
@reservation.errors.add(:base, "Failed to save customer: #{e.message}")
@customers = @company.customers
render :new, status: :unprocessable_entity
end
# PATCH/PUT /reservations/1 or /reservations/1.json
def update
# Separate reservation attributes from customer attributes
customer_attrs = build_customer_attributes # Use existing helper
reservation_attrs = reservation_params.except(
:customer_id, :customer_first_name, :customer_surname,
:customer_original_phone, :customer_birth_year
)
# Find the customer identified by the submitted name/phone
@customer = Customer.find_by(
first_name: customer_attrs[:first_name],
surname: customer_attrs[:surname],
original_phone: customer_attrs[:original_phone],
company_id: @company.id
)
# Update customer phone/birthyear if found (identifiers assumed immutable here)
customer_updated = @customer ? @customer.update(customer_attrs.slice(:phone, :birthyear)) : false
# Assign the correct customer foreign keys to the reservation attributes
if @customer
reservation_attrs[:customer_first_name] = @customer.first_name
reservation_attrs[:customer_surname] = @customer.surname
reservation_attrs[:customer_original_phone] = @customer.original_phone
else
# Fall back to original keys if form customer wasn't found (e.g., identifiers changed)
# Consider adding an error or different handling if customer *must* be found.
reservation_attrs[:customer_first_name] = @reservation.customer_first_name
reservation_attrs[:customer_surname] = @reservation.customer_surname
reservation_attrs[:customer_original_phone] = @reservation.customer_original_phone
end
reservation_updated = @reservation.update(reservation_attrs)
respond_to do |format|
if @reservation.update(reservation_params)
# Check if reservation update was successful
# (We might ignore customer_updated status for now, or add more complex checks)
if reservation_updated
format.html { redirect_to reservation_url(@reservation), notice: t('.reservation_updated') }
format.json { render :show, status: :ok, location: @reservation }
else
@customers = @company.customers # Reload for form
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @reservation.errors, status: :unprocessable_entity }
end
end
rescue ActiveRecord::RecordInvalid => e # Catch potential customer update errors
@reservation.errors.add(:base, "Failed to save customer: #{e.message}") if @customer&.invalid?
@customers = @company.customers
render :edit, status: :unprocessable_entity
end
# DELETE /reservations/1 or /reservations/1.json
@@ -69,11 +131,74 @@ class ReservationsController < ApplicationController
# Only allow a list of trusted parameters through.
def reservation_params
params.require(:reservation).permit(:company_id, :customer_id, :team_id, :title, :description, :start_time,
:end_time)
# Permit composite key if form uses it, otherwise permit individual fields
# params.require(:reservation).permit(:customer_composite_key, ...)
params.require(:reservation).permit(
:team_id,
:start_time,
:end_time,
:customer_first_name,
:customer_surname,
:customer_original_phone,
:customer_birth_year,
:customer_id # Allow this if select still sends it sometimes
)
end
def determine_layout
action_name == 'index' ? 'calendar' : 'application'
end
# Finds or creates customer based on submitted fields
def find_or_create_customer
customer_params = build_customer_attributes
@customer = if new_customer?
Customer.create!(customer_params)
else
find_or_initialize_customer(customer_params)
end
end
# Extracts customer attributes from reservation form parameters
def build_customer_attributes
{
first_name: params[:reservation][:customer_first_name],
surname: params[:reservation][:customer_surname],
original_phone: params[:reservation][:customer_original_phone],
phone: params[:reservation][:customer_original_phone], # Assuming phone is same as original_phone for now
birthyear: params[:reservation][:customer_birth_year],
company_id: @company.id
}
end
# Checks if the submitted customer ID indicates a new customer
def new_customer?
# Check based on the specific value format used by TomSelect create function
params[:reservation][:customer_composite_key]&.end_with?('__new') ||
params[:reservation][:customer_id]&.end_with?('__new') # Fallback if old key is used
end
# Finds customer by composite key or creates them
def find_or_initialize_customer(attributes)
# Find using the composite key fields
Customer.find_or_create_by!(
first_name: attributes[:first_name],
surname: attributes[:surname],
original_phone: attributes[:original_phone]
# company_id: attributes[:company_id] # Scope to company if needed
) do |customer|
# Assign other attributes only if creating
customer.assign_attributes(attributes.slice(:phone, :birthyear, :company_id))
end
end
# Sets the foreign key fields on the reservation based on the found/created customer
def assign_customer_to_reservation
return unless @customer # Guard clause
@reservation.customer_first_name = @customer.first_name
@reservation.customer_surname = @customer.surname
@reservation.customer_original_phone = @customer.original_phone
end
end

View 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();
}
}

View File

@@ -0,0 +1,151 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["select", "phoneField", "birthYearField", "firstNameField", "surnameField", "newCustomerFields"]
static values = {
existingId: String,
existingLabel: String
}
connect() {
let initialOptions = [];
let initialValue = null;
if (this.hasExistingIdValue && this.hasExistingLabelValue && this.existingIdValue.length > 0) {
initialOptions = [{ id: this.existingIdValue, label: this.existingLabelValue }];
initialValue = this.existingIdValue;
}
this.selectInstance = new TomSelect(this.selectTarget, {
valueField: 'id',
labelField: 'label',
searchField: ['label'],
maxItems: 1,
create: true,
createOnBlur: true,
placeholder: 'Type to search customers...',
create: function(input) {
return {
id: `${input}__new`,
label: `${input} (New Customer)`
};
},
options: initialOptions,
items: initialValue ? [initialValue] : [],
load: async (query, callback) => {
if (!query.length && !initialValue) return callback();
try {
const response = await fetch(`/customers/search?q=${encodeURIComponent(query)}`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
const existingOptionId = this.hasExistingIdValue ? this.existingIdValue : null;
const filteredData = data.filter(item => item.id !== existingOptionId);
callback(filteredData);
} catch (error) {
console.error('Error loading customers:', error);
callback();
}
},
shouldLoad: function(query) {
return query.length >= 2;
},
render: {
no_results: (data, escape) => {
return '<div class="no-results">No customers found. Fill in the details below.</div>';
},
option: function(item) {
return `<div>${item.label}</div>`;
}
},
onLoad: (data) => {
if (!this.selectInstance.getValue() && (!data || data.length === 0)) {
this.showNewCustomerFields();
}
},
onChange: (value) => {
if (value === null || value === '') {
this.showNewCustomerFields();
} else if (!value.endsWith('__new')) {
this.newCustomerFieldsTarget.classList.add('hidden');
}
},
onItemAdd: (value) => {
this.customerSelected(value);
},
onDropdownClose: (dropdown) => {
if (!this.selectInstance.getValue()) {
this.showNewCustomerFields();
}
}
});
if (initialValue) {
this.newCustomerFieldsTarget.classList.add('hidden');
this.customerSelected(initialValue);
}
}
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;
} else {
this.birthYearFieldTarget.value = '';
}
this.newCustomerFieldsTarget.classList.add('hidden');
} else {
console.warn("Selected customer value format unexpected:", value);
this.clearFields();
this.showNewCustomerFields();
}
}
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();
}
}
}

View File

@@ -2,7 +2,13 @@ import {Controller} from "@hotwired/stimulus"
// Connects to data-controller="main-calendar"
export default class extends Controller {
static targets = ["dateDisplay", "navigation"]
connect() {
// Set height to full viewport
document.getElementById('main-calendar').style.height = '100vh';
document.getElementById('main-calendar').style.width = '100vw';
const calendar = new tui.Calendar(document.getElementById('main-calendar'), {
defaultView: 'week',
usageStatistics: false,
@@ -10,12 +16,40 @@ export default class extends Controller {
taskView: false,
scheduleView: false,
eventView: ['time'],
startDayOfWeek: 1, // Start week on Monday
hourStart: 4,
hourEnd: 21,
},
timezone: {
zones: [
{
timezoneName: 'UTC',
displayLabel: 'UTC' // Optional: Label for the timezone
}
]
// You might need `primaryTimezone: 'UTC'` here as well,
// depending on the exact library version and desired behavior.
// Let's start with just zones.
},
// This is important - set the height to 100%
height: '100%',
// Make sure it takes full width
width: '100%',
template: {
timegridDisplayPrimaryTime({time}) {
return `${time.getHours()} sati`;
},
popupDetailLocation(eventObj) {
return ''; // Empty location as requested
},
popupDetailAttendees(eventObj) {
return eventObj.attendees[0]; // Show team name
},
popupDetailState(eventObj) {
return '';
},
popupDetailBody(eventObj) {
return '';
}
},
calendars: [
@@ -25,11 +59,114 @@ export default class extends Controller {
backgroundColor: '#00a9ff',
},
],
// Enable the built-in popup
useDetailPopup: true,
});
// Listener for clicks on empty time slots
calendar.on('selectDateTime', (eventData) => {
const startTime = new Date(eventData.start);
const endTime = new Date(startTime.getTime() + 30 * 60000); // Add 30 minutes
const formatForUrl = (date) => {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}:00`;
};
const startTimeParam = formatForUrl(startTime);
const endTimeParam = formatForUrl(endTime);
const newReservationUrl = `/reservations/new?start_time=${encodeURIComponent(startTimeParam)}&end_time=${encodeURIComponent(endTimeParam)}`;
if (window.Turbo) {
Turbo.visit(newReservationUrl);
} else {
window.location.href = newReservationUrl; // Fallback: Full page reload
}
// Prevent TUI Calendar from creating its default event/guide element
return false;
});
// Listener for delete button in popup
calendar.on('beforeDeleteEvent', async (eventObj) => {
const reservationId = eventObj.id;
const calendarId = eventObj.calendarId;
if (!reservationId) {
console.error("Reservation ID not found in event object.");
return false;
}
const csrfToken = this.getCsrfToken();
if (!csrfToken) {
console.error("CSRF token not found.");
alert("Error: Could not verify request security token.");
return false;
}
try {
const response = await fetch(`/reservations/${reservationId}`, {
method: 'DELETE',
headers: {
'X-CSRF-Token': csrfToken,
'Accept': 'application/json'
}
});
if (response.ok) {
calendar.deleteEvent(reservationId, calendarId);
alert('Reservation deleted.');
} else {
console.error(`Failed to delete reservation ${reservationId}. Status: ${response.status}`);
let errorMessage = 'Error deleting reservation.';
try {
const errorData = await response.json();
errorMessage += ` Server says: ${errorData.error || JSON.stringify(errorData)}`;
} catch (e) { /* Ignore */ }
alert(errorMessage);
}
} catch (error) {
console.error("Network error or exception during delete:", error);
alert("Error deleting reservation due to a network or script issue.");
}
return false; // Prevent TUI default delete handling
});
// Listener for edit button in popup (or drag/resize completion)
calendar.on('beforeUpdateEvent', (eventInfo) => {
const eventId = eventInfo.event?.id;
if (!eventId) {
console.error("Cannot edit: Event ID not found.");
return false;
}
// Navigate to edit page for both edit clicks and drag/resize events
const editUrl = `/reservations/${eventId}/edit`;
if (window.Turbo) {
Turbo.visit(editUrl);
} else {
window.location.href = editUrl;
}
// Prevent TUI's default update action
return false;
});
window.calendar = calendar;
this.getCalendardata();
calendar.render();
// Update the date display after rendering
this.updateDateDisplay();
}
getCalendardata() {
@@ -40,14 +177,81 @@ export default class extends Controller {
{
id: reservation.id,
calendarId: 'cal1',
title: reservation.customer.name,
title: reservation.customer.first_name + ' ' + reservation.customer.surname + ' (' + reservation.customer.phone + ')',
category: 'time',
dueDateClass: reservation.dueDateClass,
location: reservation.team.name,
location: '', // Empty location as requested
attendees: [reservation.team.name], // Team name as attendee
start: reservation.start_time,
end: reservation.end_time
}
])
});
}
}
// Helper function to get CSRF token from meta tag
getCsrfToken() {
const token = document.querySelector('meta[name="csrf-token"]')?.content;
return token;
}
// Navigation methods - using the global window.calendar
prev() {
window.calendar.prev();
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'
});
}
}

View File

@@ -1,7 +1,40 @@
class Customer < ApplicationRecord
# Use Rails 7.1's native composite primary key
self.primary_key = %i[first_name surname original_phone]
belongs_to :company
validates :name, presence: true, uniqueness: { scope: :company_id }
validates :first_name, presence: true
validates :surname, presence: true
validates :phone, presence: true
validates :original_phone, presence: true
validates :company_id, presence: true
validates :first_name, uniqueness: {
scope: %i[surname original_phone company_id],
message: -> { I18n.t('customers.customer.already_exists') }
}
validates :birthyear, numericality: {
only_integer: true,
greater_than: 1900,
less_than_or_equal_to: -> { Time.current.year }
}, allow_nil: true
before_validation :set_original_phone, on: :create
def full_name
[first_name, surname].compact_blank.join(' ')
end
# Add method for URL generation
def to_param
[first_name, surname, original_phone].join('_')
end
private
def set_original_phone
self.original_phone = phone if original_phone.blank?
end
end

View File

@@ -1,11 +1,28 @@
class Reservation < ApplicationRecord
belongs_to :company
belongs_to :customer
belongs_to :customer,
primary_key: %i[first_name surname original_phone],
query_constraints: %i[customer_first_name customer_surname customer_original_phone]
belongs_to :team
validates :company_id, presence: true
validates :customer_id, presence: true
validates :team_id, presence: true
# Remove customer_id validation since we're using composite key
validates :customer_first_name, :customer_surname, :customer_original_phone, presence: true
# Add validation to ensure customer exists
validate :customer_must_exist
private
def customer_must_exist
unless Customer.exists?(first_name: customer_first_name,
surname: customer_surname,
original_phone: customer_original_phone)
errors.add(:base, "Selected customer does not exist")
end
end
end
class ReservationSerializer < ActiveModel::Serializer

View File

@@ -1,7 +1,12 @@
<div id="<%= dom_id customer %>">
<p class="my-5">
<strong class="block font-medium mb-1">Name:</strong>
<%= customer.name %>
<strong class="block font-medium mb-1">First Name:</strong>
<%= customer.first_name %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Surname:</strong>
<%= customer.surname %>
</p>
<p class="my-5">

View File

@@ -12,8 +12,13 @@
<% end %>
<div class="my-5">
<%= form.label :name %>
<%= form.text_field :name, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
<%= form.label :first_name %>
<%= form.text_field :first_name, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.label :surname %>
<%= form.text_field :surname, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">

View File

@@ -14,7 +14,7 @@
<% @customers.each do |customer| %>
<%= render customer %>
<p>
<%= link_to "Show this customer", customer, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Show this customer", customer_path(customer), class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</p>
<% end %>
</div>

View File

@@ -9,9 +9,11 @@
<script src="https://uicdn.toast.com/calendar/latest/toastui-calendar.min.js"></script>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
</head>
<body>

View File

@@ -9,14 +9,54 @@
<script src="https://uicdn.toast.com/calendar/latest/toastui-calendar.min.js"></script>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "calendar.tailwind", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<style>
html, body {
margin: 0;
padding: 0;
height: 100vh;
width: 100vw;
overflow: hidden;
}
body.calendar {
height: 100vh;
width: 100vw;
overflow: hidden;
}
body.calendar main {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
}
.w-full, .min-w-full, div[data-controller="main-calendar"] {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
}
#main-calendar {
height: 100vh;
width: 100vw;
}
.flex.justify-between.items-center {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
background: rgba(255,255,255,0.8);
padding: 5px;
border-radius: 5px;
}
</style>
</head>
<body class="calendar">
<main style="height: 100vw;">
<main>
<%= yield %>
</main>
</body>
</html>
want

View File

@@ -1,38 +1,88 @@
<%= form_with(model: reservation, class: "contents") do |form| %>
<% if reservation.errors.any? %>
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= pluralize(reservation.errors.count, "error") %> prohibited this reservation from being saved:</h2>
<%= form_with(model: reservation, class: "contents",
data: {
controller: "customer-search",
customer_search_existing_id_value: (reservation.persisted? && reservation.customer ? reservation.customer.to_param : nil),
customer_search_existing_label_value: (reservation.persisted? && reservation.customer ? "#{reservation.customer.full_name} (#{reservation.customer.original_phone})" : nil)
}) do |form| %>
<% if reservation.errors.any? %>
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= pluralize(reservation.errors.count, "error") %> prohibited this reservation from being saved:</h2>
<ul>
<% reservation.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
<ul>
<% reservation.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="my-5">
<%= form.label :customer %>
<%= form.select :customer_composite_key,
[], # Start with empty options
{ prompt: "Type to search customers..." },
{
selected: (reservation.persisted? && reservation.customer ? reservation.customer.to_param : nil),
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
data: { customer_search_target: "select" }
} %>
</div>
<div class="my-5">
<%= form.label :phone_number %>
<%= form.telephone_field :customer_original_phone,
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
data: { customer_search_target: "phoneField" } %>
</div>
<div data-customer-search-target="newCustomerFields" class="hidden">
<div class="my-5">
<%= form.label :first_name %>
<%= form.text_field :customer_first_name,
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
data: { customer_search_target: "firstNameField" } %>
</div>
<div class="my-5">
<%= form.label :surname %>
<%= form.text_field :customer_surname,
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
data: { customer_search_target: "surnameField" } %>
</div>
<div class="my-5">
<%= form.label :birth_year %>
<%= form.number_field :customer_birth_year,
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
data: { customer_search_target: "birthYearField" } %>
</div>
</div>
<div class="my-5">
<%= form.label :team_id %>
<%= form.collection_select :team_id,
@company.teams,
:id,
:name,
{ prompt: "Select a team" },
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.label :start_time %>
<%= form.datetime_field :start_time,
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
id: "start_time_field" %>
</div>
<div class="my-5">
<%= form.label :end_time %>
<%= form.datetime_field :end_time,
class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full",
id: "end_time_field" %>
</div>
<div class="inline">
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
<div class="my-5">
<%= form.label :customer_id %>
<%= form.collection_select :customer_id, @company.customers, :id, :name, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.label :team_id %>
<%= form.collection_select :team_id, @company.teams, :id, :name, prompt: "Select a Team", class:"block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.label :start_time %>`
<%= form.datetime_field :start_time, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.label :end_time %>
<%= form.datetime_field :end_time, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="inline">
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>

View File

@@ -6,7 +6,8 @@
<p class="my-5">
<strong class="block font-medium mb-1">Customer:</strong>
<%= reservation.customer_id %>
<%= reservation.customer.try(:full_name) || "N/A" %>
(<%= reservation.customer.try(:birthyear) %>)
</p>
<p class="my-5">
@@ -16,12 +17,12 @@
<p class="my-5">
<strong class="block font-medium mb-1">Start time:</strong>
<%= reservation.start_time %>
<%= l reservation.start_time if reservation.start_time %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">End time:</strong>
<%= reservation.end_time %>
<%= l reservation.end_time if reservation.end_time %>
</p>
</div>

View File

@@ -1,16 +1,37 @@
<div class="w-full" style="height: 100%">
<% if notice.present? %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<!-- Separate the page into two distinct parts -->
<div class="reservation-page" data-controller="main-calendar" style="display: block; width: 100%; height: 100vh; overflow: hidden;">
<!-- Fixed height header -->
<header style="height: 80px; padding: 15px; background-color: white; box-shadow: 0 2px 4px rgba(0,0,0,0.1); position: relative; z-index: 100;">
<% if notice.present? %>
<p class="py-2 px-3 bg-green-50 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<% content_for :title, "Reservations" %>
<% content_for :title, "Reservations" %>
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Reservations</h1>
<%= link_to "New reservation", new_reservation_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
<div class="flex justify-between items-center-calendar">
<div class="flex items-center space-x-4">
<h1 class="font-bold text-4xl px-5">Reservations</h1>
<div class="flex items-center space-x-2 ml-6" data-main-calendar-target="navigation">
<button data-action="main-calendar#prev" class="px-3 py-1 bg-gray-100 rounded-md hover:bg-gray-200">
&laquo; 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 &raquo;
</button>
<span data-main-calendar-target="dateDisplay" class="ml-3 font-medium"></span>
</div>
</div>
<%= link_to "New reservation", new_reservation_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium ml-auto" %>
</div>
</header>
<!-- Calendar container - with fixed top position and precise height calculation -->
<div class="calendar-container" style="height: calc(100vh - 80px); width: 100%; position: relative; top: 0; left: 0;">
<div style="height: 100%; width: 100%;">
<%= tag.div nil, data: {reservations: @reservations.to_json}, id: "main-calendar", style: "height: 100%; width: 100%;" %>
</div>
</div>
<div data-controller="main-calendar" class="min-w-full" style="height: 90vh;" >
<%= tag.div nil, data: {reservations: @reservations.to_json}, id: "main-calendar"%>
</div>
</div>
</div>