Merge branch '8-podrska-za-boje-na-kalendaru' into 'master'

Added colours for teams

Closes #8

See merge request kbr4/zsterminator!6
This commit is contained in:
2025-04-24 05:20:14 +00:00
committed by Nedim Uka
11 changed files with 313 additions and 135 deletions

View File

@@ -14,8 +14,21 @@ class ApplicationController < ActionController::Base
end
def set_company
company_id = session.fetch(:company_id, Company.first&.id)
session[:company_id] = company_id
@company = Company.find(session[:company_id])
# This should be handled by your authentication system
# But for now, we'll use a placeholder
company_id = session[:company_id]
unless company_id && Company.exists?(company_id)
# If no company in session or it doesn't exist, use the first company
company_id = Company.first&.id
session[:company_id] = company_id
end
@company = Company.find(company_id) if company_id
end
def current_company
@company
end
helper_method :current_company
end

View File

@@ -6,7 +6,10 @@ class ReservationsController < ApplicationController
# GET /reservations or /reservations.json
def index
@reservations = Reservation.includes(:team, :customer).where(company: @company)
@reservations = ActiveModelSerializers::SerializableResource.new(@reservations).as_json
@reservations = ActiveModelSerializers::SerializableResource.new(
@reservations,
each_serializer: ReservationSerializer
).as_json
end
# GET /reservations/1 or /reservations/1.json
@@ -201,4 +204,16 @@ class ReservationsController < ApplicationController
@reservation.customer_surname = @customer.surname
@reservation.customer_original_phone = @customer.original_phone
end
# Override the application controller method to include teams
def set_company
company_id = session[:company_id]
unless company_id && Company.exists?(company_id)
company_id = Company.first&.id
session[:company_id] = company_id
end
@company = Company.includes(:teams).find(company_id) if company_id
end
end

View File

@@ -0,0 +1,73 @@
module ColorHelper
# Generates a consistent, visually pleasing color based on an ID
# Uses the golden ratio to ensure good color distribution
def team_color(team_id)
# Use the golden ratio to create a well-distributed sequence of hues
golden_ratio_conjugate = 0.618033988749895
# Use the team_id as a seed for the hue
h = (team_id.to_i * golden_ratio_conjugate) % 1
# Convert to HSL color with fixed saturation and lightness for good UI colors
# Saturation: 65% - vibrant but not too intense
# Lightness: 55% - visible on both light and dark backgrounds
hsl_to_hex(h, 0.65, 0.55)
end
# Returns a color object with various formats for a team
# This allows for more flexibility in how the color is used
def team_color_object(team_id)
h = (team_id.to_i * 0.618033988749895) % 1
s = 0.65
l = 0.55
# Calculate RGB values
rgb = hsl_to_rgb(h, s, l)
hex = hsl_to_hex(h, s, l)
{
hex: hex, # #RRGGBB
rgb: "rgb(#{rgb[0]}, #{rgb[1]}, #{rgb[2]})", # rgb(r,g,b)
hsl: "hsl(#{(h*360).round}, #{(s*100).round}%, #{(l*100).round}%)", # hsl(h,s%,l%)
light_bg: hsl_to_hex(h, s, 0.9), # Lighter version for backgrounds
dark_bg: hsl_to_hex(h, s, 0.2), # Darker version for backgrounds
border: hsl_to_hex(h, s, 0.4) # Border color
}
end
private
# Convert HSL to Hex color
def hsl_to_hex(h, s, l)
r, g, b = hsl_to_rgb(h, s, l)
"##{r.to_s(16).rjust(2, '0')}#{g.to_s(16).rjust(2, '0')}#{b.to_s(16).rjust(2, '0')}"
end
# Convert HSL to RGB values
def hsl_to_rgb(h, s, l)
# Convert HSL to RGB using standard algorithm
if s == 0
r = g = b = (l * 255).round
else
q = l < 0.5 ? l * (1 + s) : l + s - l * s
p = 2 * l - q
r = (hue_to_rgb(p, q, h + 1/3.0) * 255).round
g = (hue_to_rgb(p, q, h) * 255).round
b = (hue_to_rgb(p, q, h - 1/3.0) * 255).round
end
[r, g, b]
end
# Helper function for HSL to RGB conversion
def hue_to_rgb(p, q, t)
t += 1 if t < 0
t -= 1 if t > 1
return p + (q - p) * 6 * t if t < 1/6.0
return q if t < 1/2.0
return p + (q - p) * (2/3.0 - t) * 6 if t < 2/3.0
return p
end
end

View File

@@ -2,7 +2,7 @@ import {Controller} from "@hotwired/stimulus"
// Connects to data-controller="main-calendar"
export default class extends Controller {
static targets = ["dateDisplay", "navigation"]
static targets = ["dateDisplay", "navigation", "teamFilter"]
connect() {
// Set height to full viewport
@@ -18,7 +18,47 @@ export default class extends Controller {
// Translation helper
const t = (key) => translations[key] || key;
const calendar = new tui.Calendar(document.getElementById('main-calendar'), {
// Pre-process reservations to set up team calendars
const reservations = JSON.parse(document.querySelector("#main-calendar").dataset.reservations);
window.reservations = reservations;
// Debug: Log the first reservation to inspect color format
if (reservations && reservations.length > 0) {
console.log("First reservation team color:", reservations[0].team.color);
}
// Extract unique teams and create calendar configurations
const teamCalendars = [];
const teamMap = {};
// Create calendar configs for each team
reservations.forEach(reservation => {
const teamId = reservation.team.id;
if (!teamMap[teamId]) {
const calendarId = `team-${teamId}`;
teamMap[teamId] = calendarId;
// Use color directly - it should be a hex string from the TeamSerializer
const teamColor = reservation.team.color || '#00a9ff';
teamCalendars.push({
id: calendarId,
name: reservation.team.name,
backgroundColor: teamColor,
borderColor: teamColor
});
}
});
// Store team calendars for filtering
this.teamCalendars = teamCalendars;
this.allCalendars = [...teamCalendars];
// Store all reservations for filtering
this.allReservations = reservations;
// Initialize calendar with all team calendars
window.calendar = new tui.Calendar(document.getElementById('main-calendar'), {
defaultView: 'week',
usageStatistics: false,
week: {
@@ -64,141 +104,71 @@ export default class extends Controller {
return t('delete');
}
},
calendars: [
calendars: teamCalendars.length > 0 ? teamCalendars : [
{
id: 'cal1',
name: 'Work',
id: 'default',
name: 'Default',
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(t('delete_error'));
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(t('delete_success'));
} else {
console.error(`Failed to delete reservation ${reservationId}. Status: ${response.status}`);
let errorMessage = t('delete_error');
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(t('network_error'));
}
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();
// Create calendar events
this.createCalendarEvents(reservations);
// Update the date display after rendering
// Set up initial date display
this.updateDateDisplay();
// Handle calendar navigation
window.calendar.on('beforeUpdateDay', (date) => {
this.updateDateDisplay();
});
// Handle edit and delete actions
window.calendar.on('clickEvent', (event) => {
const reservation = event.event.reservation;
// Redirect to edit page
window.location.href = `/reservations/${reservation.id}/edit`;
});
// Render the calendar
window.calendar.render();
}
// Create events for all reservations
createCalendarEvents(reservations) {
if (!window.calendar) return;
// Process each reservation into a calendar event
reservations.forEach(reservation => {
const teamId = reservation.team.id;
const calendarId = `team-${teamId}`;
const startTime = new Date(reservation.start_time);
const endTime = new Date(reservation.end_time);
// Create the event
const event = {
id: `reservation-${reservation.id}`,
calendarId: calendarId,
title: reservation.customer ? `${reservation.customer.first_name} ${reservation.customer.surname}` : '',
start: startTime,
end: endTime,
category: 'time',
isReadOnly: false,
reservation: reservation,
attendees: [reservation.team.name]
};
// Add event to calendar
window.calendar.createEvents([event]);
});
}
getCalendardata() {
var reservations = JSON.parse(document.querySelector("#main-calendar").dataset.reservations);
window.reservations = reservations;
reservations.forEach(reservation => {
window.calendar.createEvents([
{
id: reservation.id,
calendarId: 'cal1',
title: reservation.customer.first_name + ' ' + reservation.customer.surname + ' (' + reservation.customer.phone + ')',
category: 'time',
dueDateClass: reservation.dueDateClass,
location: '', // Empty location as requested
attendees: [reservation.team.name], // Team name as attendee
start: reservation.start_time,
end: reservation.end_time
}
])
});
// This method is now replaced by initialization in connect()
// and createCalendarEvents method
}
// Helper function to get CSRF token from meta tag
@@ -266,4 +236,44 @@ export default class extends Controller {
day: 'numeric'
});
}
// Method for team filtering
filterByTeam(event) {
const selectedTeamId = event.target.value;
// Store last value for test verification
window.lastTeamFilterValue = selectedTeamId;
console.log(`Filtering by team: ${selectedTeamId}`);
if (!window.calendar) return;
// Clear existing events
window.calendar.clear();
// Process reservations based on filter
let filteredReservations = this.allReservations;
// If not "all", filter by team
if (selectedTeamId !== 'all') {
// Extract numeric ID if the value is in "team-{id}" format
const teamId = selectedTeamId.toString().startsWith('team-')
? selectedTeamId.toString().replace('team-', '')
: selectedTeamId.toString();
console.log(`Using team ID for filtering: ${teamId}`);
filteredReservations = this.allReservations.filter(reservation =>
reservation.team.id.toString() === teamId
);
console.log(`Found ${filteredReservations.length} reservations for team ${teamId}`);
}
// Create and add calendar events based on filtered reservations
this.createCalendarEvents(filteredReservations);
// Update calendar display
window.calendar.render();
}
}

View File

@@ -25,6 +25,7 @@ class Reservation < ApplicationRecord
end
end
class ReservationSerializer < ActiveModel::Serializer
attributes :id, :company, :customer, :team, :start_time, :end_time
end
# Moved to app/serializers/reservation_serializer.rb
# class ReservationSerializer < ActiveModel::Serializer
# attributes :id, :company, :customer, :team, :start_time, :end_time
# end

View File

@@ -0,0 +1,6 @@
class ReservationSerializer < ActiveModel::Serializer
attributes :id, :start_time, :end_time
belongs_to :customer
belongs_to :team, serializer: TeamSerializer
end

View File

@@ -0,0 +1,9 @@
class TeamSerializer < ActiveModel::Serializer
include ColorHelper
attributes :id, :name, :color
def color
team_color(object.id)
end
end

View File

@@ -22,6 +22,19 @@
</button>
<span data-main-calendar-target="dateDisplay" class="ml-3 font-medium"></span>
</div>
<!-- Team Filter Dropdown -->
<div class="ml-6">
<label for="team-filter" class="mr-2 font-medium"><%= t('.filter_by_team') %>:</label>
<select id="team-filter" data-main-calendar-target="teamFilter" data-action="change->main-calendar#filterByTeam" class="rounded-md border-gray-300 shadow-sm px-3 py-1 bg-white">
<option value="all"><%= t('.all_teams') %></option>
<% @company.teams.each do |team| %>
<option value="<%= team.id %>" style="background-color: <%= team_color(team.id) %>; color: #000000; padding-left: 10px;">
<%= team.name %>
</option>
<% end %>
</select>
</div>
</div>
<%= link_to t('.new_reservation'), new_reservation_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium ml-auto" %>
</div>
@@ -38,7 +51,8 @@
delete_error: t('reservations.calendar.delete_error'),
network_error: t('reservations.calendar.network_error'),
edit: t('reservations.calendar.edit'),
delete: t('reservations.calendar.delete')
delete: t('reservations.calendar.delete'),
all_teams: t('reservations.calendar.all_teams')
}.to_json
%>
<%= tag.div nil,

View File

@@ -7,6 +7,8 @@ bs:
today: "Danas"
next: "Sljedeća »"
new_reservation: "Nova rezervacija" # TODO: Translate to Bosnian
filter_by_team: "Filtriraj po timu"
all_teams: "Svi timovi"
calendar:
hours: "sati"
delete_confirm: "Jeste li sigurni da želite izbrisati ovu rezervaciju?"
@@ -15,6 +17,7 @@ bs:
network_error: "Greška prilikom brisanja rezervacije zbog problema s mrežom ili skriptom."
edit: "Uredi"
delete: "Obriši"
all_teams: "Svi timovi"
# Keys for form and edit/new pages
edit:
title: "Uređivanje rezervacije"

View File

@@ -52,6 +52,8 @@ en:
today: "Today"
next: "Next »"
new_reservation: "New reservation"
filter_by_team: "Filter by team"
all_teams: "All teams"
calendar:
hours: "hours"
delete_confirm: "Are you sure you want to delete this reservation?"
@@ -60,6 +62,7 @@ en:
network_error: "Error deleting reservation due to a network or script issue."
edit: "Edit"
delete: "Delete"
all_teams: "All teams"
edit:
title: "Editing reservation"
show: "Show this reservation"

View File

@@ -5,6 +5,8 @@ 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'
require 'capybara/rails'
require 'capybara/rspec'
# Add additional requires below this line. Rails is not loaded until this point!
# Requires supporting ruby files with custom matchers and macros, etc, in
@@ -61,4 +63,33 @@ RSpec.configure do |config|
config.filter_rails_from_backtrace!
# arbitrary gems may also be filtered via:
# config.filter_gems_from_backtrace("gem name")
# Include FactoryBot methods
config.include FactoryBot::Syntax::Methods
# Configure Capybara for JavaScript tests
Capybara.javascript_driver = :selenium_chrome_headless
# Use DatabaseCleaner for feature specs
config.use_transactional_fixtures = false
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do
DatabaseCleaner.strategy = :transaction
end
config.before(:each, js: true) do
DatabaseCleaner.strategy = :truncation
end
config.before(:each) do
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end