1 Commits

Author SHA1 Message Date
Nedim
df93b184da Fixed tailwind css 2025-03-13 07:53:01 +01:00
62 changed files with 267 additions and 1906 deletions

118
README.md
View File

@@ -1,116 +1,24 @@
# ZSTerminator -
# README
This README would normally document whatever steps are necessary to get the
application up and running.
## Setup
Things you may want to cover:
* Ruby version: 3.2.4
* Rails version: 7.1.5+
* Database: SQLite3
* Ruby version
## Getting Started
* System dependencies
1. Install dependencies:
```bash
bundle install
```
* Configuration
2. Setup database:
```bash
rails db:create
rails db:migrate
rails db:seed
```
* Database creation
3. Start the server:
```bash
rails server
```
* Database initialization
4. Visit http://localhost:3000 and login with:
- Username: `admin`
- Password: `password123`
* How to run the test suite
## User Management (Rake Tasks)
* Services (job queues, cache servers, search engines, etc.)
This project includes Rake tasks for managing users via command line:
* Deployment instructions
### Available Commands
```bash
# List all users
rails users:list
# Show user details
rails users:show[username]
# Create a new user
rails users:create[username,email,password,company_id]
# Change user password
rails users:change_password[username,new_password]
# Delete a user
rails users:delete[username]
# Clean up test users (users with 'test' in username/email)
rails users:cleanup_test_users
```
### Usage Examples
```bash
# Create a new user (company_id is optional, uses first company if blank)
rails "users:create[john,john@example.com,securepass123]"
# Change password
rails "users:change_password[john,newpassword456]"
# Show user information
rails users:show[john]
# Delete user (with confirmation)
rails users:delete[john]
# List all users in a formatted table
rails users:list
```
### Automated Testing
**Run complete CRUD test suite:**
```bash
# Single command to test all user operations automatically
rails users:test_crud
```
This automated test will:
1. **Create** test users (dodavanje)
2. **List** created users
3. **Change** password (mijenjanje sifre)
4. **Show** user details
5. **Delete** user (brisanje)
6. **Clean up** all test data
### Manual Testing Workflow
1. **Create test users:**
```bash
rails "users:create[testuser1,test1@example.com,testpass123]"
rails "users:create[testuser2,test2@example.com,testpass456]"
```
2. **Test your application** with the created users
3. **Clean up test data:**
```bash
# This will find and delete all users with 'test' in username or email
rails users:cleanup_test_users
```
### Notes
- All user passwords are encrypted using bcrypt
- Users must belong to a company
- Username and email must be unique
- Minimum password length is 6 characters
- The cleanup task only removes users with 'test' in their username or email for safety
* ...

View File

@@ -5,3 +5,4 @@
//= link controllers/application.js
//= link controllers/index.js
//= link_tree ../../javascript .js
//= link calendar.tailwind.css

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,13 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
/*
@layer components {
.btn-primary {
@apply py-2 px-4 bg-blue-200;
}
}
*/
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
@import "tailwindcss";

View File

@@ -27,126 +27,4 @@
.calendar-time-slot {
min-height: 50px; /* Adjust as needed */
}
/* ToastUI Calendar Event Styling */
.toastui-calendar-events {
margin-right: 8px;
text-wrap: auto;
}
.toastui-calendar-event-time {
position: relative;
overflow: visible !important;
min-height: 24px !important;
cursor: pointer;
transition: all 0.2s ease;
}
.toastui-calendar-event-time:hover {
transform: scale(1.02);
z-index: 1000 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 4px !important;
}
.toastui-calendar-event-time-content {
height: 100% !important;
overflow: visible !important;
padding: 2px 4px !important;
text-wrap: auto;
word-wrap: break-word;
line-height: 1.1 !important;
}
.toastui-calendar-template-time {
font-size: 10px !important;
line-height: 1.1 !important;
text-wrap: auto;
word-wrap: break-word;
white-space: normal !important;
overflow: visible !important;
display: block !important;
}
.toastui-calendar-template-time strong {
font-size: 10px !important;
font-weight: 600;
display: block;
margin-bottom: 1px;
}
.toastui-calendar-template-time span {
font-size: 9px !important;
line-height: 1.0 !important;
text-wrap: auto;
word-wrap: break-word;
white-space: normal !important;
display: block !important;
}
/* Hover tooltip effect */
.toastui-calendar-event-time:hover::after {
content: "Click to edit reservation";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 11px;
white-space: nowrap;
z-index: 2000;
pointer-events: none;
opacity: 0;
animation: tooltipFadeIn 0.3s ease forwards;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.toastui-calendar-event-time:hover::before {
content: '';
position: absolute;
bottom: calc(100% - 6px);
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(0, 0, 0, 0.9);
z-index: 2000;
pointer-events: none;
opacity: 0;
animation: tooltipFadeIn 0.3s ease forwards;
}
@keyframes tooltipFadeIn {
to {
opacity: 1;
}
}
/* Improve visibility for small events */
.toastui-calendar-event-time[style*="height: calc(1%"] .toastui-calendar-template-time strong,
.toastui-calendar-event-time[style*="height: calc(2%"] .toastui-calendar-template-time strong {
display: none; /* Hide time for very small events */
}
.toastui-calendar-event-time[style*="height: calc(1%"] .toastui-calendar-template-time span,
.toastui-calendar-event-time[style*="height: calc(2%"] .toastui-calendar-template-time span {
font-size: 8px !important;
line-height: 1.0 !important;
}
/* Ensure minimum visibility */
.toastui-calendar-event-time {
min-width: 60px !important;
}
/* Better text contrast */
.toastui-calendar-event-time[style*="background-color: rgb(66, 109, 215)"] {
color: white !important;
}
.toastui-calendar-event-time[style*="background-color: rgb(66, 109, 215)"] .toastui-calendar-template-time {
color: white !important;
}
}

View File

@@ -1 +1,4 @@
@import "tailwindcss";
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

View File

@@ -1,45 +1,9 @@
class ApplicationController < ActionController::Base
before_action :set_locale
before_action :require_login
private
def set_locale
I18n.locale = params[:locale] || session[:locale] || I18n.default_locale
session[:locale] = I18n.locale
end
def default_url_options
{ locale: I18n.locale }
end
def current_user
@current_user ||= User.find(session[:user_id]) if session[:user_id]
end
def logged_in?
!!current_user
end
def require_login
return if logged_in?
flash[:alert] = t('sessions.login_required')
redirect_to login_path
end
def set_company
return unless logged_in?
@company = current_user.company
return if @company
redirect_to companies_path, alert: 'No company found. Please create a company first.'
company_id = session.fetch(:company_id, Company.first&.id)
session[:company_id] = company_id
@company = Company.find(session[:company_id])
end
def current_company
@company
end
helper_method :current_user, :logged_in?, :current_company
end

View File

@@ -64,15 +64,10 @@ class CustomersController < ApplicationController
).limit(10)
render json: @customers.map { |c|
label = "#{c.full_name} (#{c.original_phone})"
label += " - #{c.notes}" if c.notes.present?
{
id: "#{c.first_name}_#{c.surname}_#{c.original_phone}",
label: label,
birthyear: c.birthyear,
color: c.color || 'green',
color_hex: c.color_hex,
notes: c.notes
label: "#{c.full_name} (#{c.original_phone})",
birthyear: c.birthyear
}
}
end
@@ -91,6 +86,6 @@ class CustomersController < ApplicationController
# Only allow a list of trusted parameters through.
def customer_params
params.require(:customer).permit(:first_name, :surname, :phone, :notes, :email, :birthyear, :color)
params.require(:customer).permit(:first_name, :surname, :phone, :notes, :email, :birthyear)
end
end

View File

@@ -6,10 +6,7 @@ class ReservationsController < ApplicationController
# GET /reservations or /reservations.json
def index
@reservations = Reservation.includes(:team, :customer).where(company: @company)
@reservations = ActiveModelSerializers::SerializableResource.new(
@reservations,
each_serializer: ReservationSerializer
).as_json
@reservations = ActiveModelSerializers::SerializableResource.new(@reservations).as_json
end
# GET /reservations/1 or /reservations/1.json
@@ -17,22 +14,9 @@ class ReservationsController < ApplicationController
# GET /reservations/new
def new
# 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
@reservation = Reservation.new
@reservation.team = @company.teams.first
@customers = @company.customers
end
# GET /reservations/1/edit
@@ -41,25 +25,20 @@ class ReservationsController < ApplicationController
# POST /reservations or /reservations.json
def create
@reservation = @company.reservations.new(
reservation_params.except(
:customer_id, :customer_first_name, :customer_surname,
:customer_original_phone, :customer_birth_year, :customer_composite_key
)
reservation_params.except(:customer_id, :customer_birth_year)
)
# Find or create customer based on submitted attributes
# Find or create customer
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 reservations_url, notice: t('.reservation_created')
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
@@ -67,55 +46,15 @@ class ReservationsController < ApplicationController
# 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, :customer_composite_key
)
# 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|
# 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 reservations_url, notice: t('.reservation_updated') }
if @reservation.update(reservation_params)
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
@@ -137,18 +76,17 @@ class ReservationsController < ApplicationController
# Only allow a list of trusted parameters through.
def reservation_params
# Permit composite key if form uses it, otherwise permit individual fields
# params.require(:reservation).permit(:customer_composite_key, ...)
params.require(:reservation).permit(
:customer_id,
:team_id,
:start_time,
:end_time,
:title,
:description,
:customer_first_name,
:customer_surname,
:customer_original_phone,
:customer_birth_year,
:customer_composite_key,
:customer_id # Allow this if select still sends it sometimes
:customer_birth_year # Keep this in permitted params
)
end
@@ -156,7 +94,6 @@ class ReservationsController < ApplicationController
action_name == 'index' ? 'calendar' : 'application'
end
# Finds or creates customer based on submitted fields
def find_or_create_customer
customer_params = build_customer_attributes
@@ -167,62 +104,35 @@ class ReservationsController < ApplicationController
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
phone: params[:reservation][:customer_original_phone],
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
params[:reservation][:customer_id].present? &&
params[:reservation][:customer_id].end_with?('__new')
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))
customer.assign_attributes(attributes)
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
# 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
unless @company
redirect_to companies_path, alert: 'No company found. Please create a company first.'
return
end
end
end

View File

@@ -1,7 +0,0 @@
class ServiceWorkerController < ApplicationController
protect_from_forgery except: :service_worker
def service_worker
end
def manifest
end
end

View File

@@ -1,25 +0,0 @@
class SessionsController < ApplicationController
skip_before_action :require_login, only: %i[new create]
def new
end
def create
user = User.find_by_login(params[:login])
if user&.authenticate(params[:password])
session[:user_id] = user.id
session[:company_id] = user.company_id
redirect_to root_path, notice: t('sessions.login_successful')
else
flash.now[:alert] = t('sessions.invalid_credentials')
render :new, status: :unprocessable_entity
end
end
def destroy
session[:user_id] = nil
session[:company_id] = nil
redirect_to login_path, notice: t('sessions.logout_successful')
end
end

View File

@@ -1,73 +0,0 @@
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

@@ -1,3 +1,2 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "controllers"
import "custom/companion"

View File

@@ -2,20 +2,8 @@ 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',
@@ -32,11 +20,10 @@ export default class extends Controller {
};
},
options: initialOptions,
items: initialValue ? [initialValue] : [],
options: [],
load: async (query, callback) => {
if (!query.length && !initialValue) return callback();
if (!query.length) return callback();
try {
const response = await fetch(`/customers/search?q=${encodeURIComponent(query)}`, {
@@ -46,11 +33,7 @@ export default class extends Controller {
}
});
const data = await response.json();
const existingOptionId = this.hasExistingIdValue ? this.existingIdValue : null;
const filteredData = data.filter(item => item.id !== existingOptionId);
callback(filteredData);
callback(data);
} catch (error) {
console.error('Error loading customers:', error);
callback();
@@ -66,23 +49,21 @@ export default class extends Controller {
return '<div class="no-results">No customers found. Fill in the details below.</div>';
},
option: function(item) {
const colorStyle = item.color_hex ? `background-color: ${item.color_hex}20; border-left: 4px solid ${item.color_hex};` : '';
return `<div style="${colorStyle} padding: 8px;">${item.label}</div>`;
return `<div>${item.label}</div>`;
}
},
// Events
onLoad: (data) => {
if (!this.selectInstance.getValue() && (!data || data.length === 0)) {
if (!data || data.length === 0) {
this.showNewCustomerFields();
}
},
onChange: (value) => {
if (value === null || value === '') {
if (value === null) {
this.showNewCustomerFields();
} else if (!value.endsWith('__new')) {
this.newCustomerFieldsTarget.classList.add('hidden');
}
}
},
onItemAdd: (value) => {
@@ -95,14 +76,6 @@ export default class extends Controller {
}
}
});
if (initialValue) {
this.newCustomerFieldsTarget.classList.add('hidden');
this.customerSelected(initialValue);
} else {
// Show new customer fields if no existing customer
this.showNewCustomerFields();
}
}
customerSelected(value) {
@@ -124,15 +97,9 @@ export default class extends Controller {
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();
}
}
@@ -152,4 +119,4 @@ export default class extends Controller {
this.selectInstance.destroy();
}
}
}
}

View File

@@ -2,63 +2,14 @@ import {Controller} from "@hotwired/stimulus"
// Connects to data-controller="main-calendar"
export default class extends Controller {
static targets = ["dateDisplay", "navigation", "teamFilter"]
static targets = ["dateDisplay", "navigation"]
connect() {
// Set height to full viewport
document.getElementById('main-calendar').style.height = '100vh';
document.getElementById('main-calendar').style.width = '100vw';
// Get current locale from html lang attribute
const currentLocale = document.documentElement.lang || 'bs';
// Get translations from data attribute
const translations = JSON.parse(document.getElementById('main-calendar').dataset.translations || '{}');
// Translation helper
const t = (key) => translations[key] || key;
// 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'), {
const calendar = new tui.Calendar(document.getElementById('main-calendar'), {
defaultView: 'week',
usageStatistics: false,
week: {
@@ -69,117 +20,67 @@ export default class extends Controller {
hourStart: 4,
hourEnd: 21,
},
timezone: {
zones: [
{
timezoneName: 'UTC',
displayLabel: 'UTC' // Optional: Label for the timezone
}
]
},
// This is important - set the height to 100%
height: '100%',
// Make sure it takes full width
width: '100%',
template: {
timegridDisplayPrimaryTime({time}) {
return `${time.getHours()} ${t('hours')}`;
return `${time.getHours()} sati`;
},
popupDetailLocation(eventObj) {
return ''; // Empty location as requested
},
popupDetailAttendees(eventObj) {
const teamName = eventObj.attendees[0]; // Show team name
return teamName;
return eventObj.attendees[0]; // Show team name
},
popupDetailState(eventObj) {
return '';
},
popupDetailBody(eventObj) {
return '';
},
popupEdit() {
return t('edit');
},
popupDelete() {
return t('delete');
}
},
calendars: teamCalendars.length > 0 ? teamCalendars : [
calendars: [
{
id: 'default',
name: 'Default',
id: 'cal1',
name: 'Work',
backgroundColor: '#00a9ff',
}
]
},
],
// Enable the built-in popup
useDetailPopup: true,
});
window.calendar = calendar;
this.getCalendardata();
// Create calendar events
this.createCalendarEvents(reservations);
calendar.render();
// Set up initial date display
// Update the date display after rendering
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 with customer name only
const customerName = reservation.customer ? `${reservation.customer.first_name} ${reservation.customer.surname}` : '';
const event = {
id: `reservation-${reservation.id}`,
calendarId: calendarId,
title: customerName,
start: startTime,
end: endTime,
category: 'time',
isReadOnly: false,
reservation: reservation,
attendees: [reservation.team.name]
};
// Add event to calendar
window.calendar.createEvents([event]);
});
}
getCalendardata() {
// This method is now replaced by initialization in connect()
// and createCalendarEvents method
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
}
])
});
}
// 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();
@@ -239,44 +140,4 @@ 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

@@ -1,11 +0,0 @@
if (navigator.serviceWorker) {
navigator.serviceWorker
.register("/service-worker.js", { scope: "/" })
.then(() => navigator.serviceWorker.ready)
.then((registration) => {
if ("SyncManager" in window) {
registration.sync.register("sync-forms");
}
})
.then(() => console.log("[Companion]", "Service worker registered!"));
}

View File

@@ -2,5 +2,4 @@ class Company < ApplicationRecord
has_many :customers, dependent: :destroy
has_many :reservations, dependent: :destroy
has_many :teams, dependent: :destroy
has_many :users, dependent: :destroy
end

View File

@@ -2,8 +2,6 @@ class Customer < ApplicationRecord
# Use Rails 7.1's native composite primary key
self.primary_key = %i[first_name surname original_phone]
attribute :color, :string, default: 'green'
belongs_to :company
validates :first_name, presence: true
@@ -23,65 +21,20 @@ class Customer < ApplicationRecord
less_than_or_equal_to: -> { Time.current.year }
}, allow_nil: true
validates :color, inclusion: { in: %w[green yellow red] }, allow_nil: true
before_validation :set_original_phone, on: :create
before_validation :set_default_color, on: :create
def full_name
[first_name, surname].compact_blank.join(' ')
end
def green?
color == 'green'
end
def yellow?
color == 'yellow'
end
def red?
color == 'red'
end
# Add method for URL generation
def to_param
[first_name, surname, original_phone].join('_')
end
def color_hex
case color || 'green'
when 'green'
'#22c55e'
when 'yellow'
'#eab308'
when 'red'
'#ef4444'
else
'#22c55e'
end
end
def color_emoji
case color || 'green'
when 'green'
'🟩'
when 'yellow'
'🟨'
when 'red'
'🟥'
else
'🟩'
end
end
private
def set_original_phone
self.original_phone = phone if original_phone.blank?
end
def set_default_color
self.color = 'green' if color.blank?
end
end

View File

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

View File

@@ -1,23 +0,0 @@
class User < ApplicationRecord
has_secure_password
belongs_to :company
validates :username, presence: true, uniqueness: { case_sensitive: false }
validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 6 }, if: -> { new_record? || password.present? }
validates :company_id, presence: true
before_save :downcase_username_and_email
def self.find_by_login(login)
find_by(username: login.downcase) || find_by(email: login.downcase)
end
private
def downcase_username_and_email
self.username = username.downcase.strip if username.present?
self.email = email.downcase.strip if email.present?
end
end

View File

@@ -1,10 +0,0 @@
class ReservationSerializer < ActiveModel::Serializer
attributes :id, :start_time, :end_time, :customer_color_emoji
belongs_to :customer
belongs_to :team, serializer: TeamSerializer
def customer_color_emoji
object.customer&.color_emoji || ''
end
end

View File

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

View File

@@ -1,51 +1,47 @@
<div id="<%= dom_id company %>" class="bg-white rounded-xl shadow-md overflow-hidden p-6">
<div class="flex justify-between items-start mb-4">
<h2 class="text-2xl font-bold text-gray-800"><%= company.name %></h2>
<div class="flex-shrink-0 bg-blue-100 text-blue-800 rounded-full p-3">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z" />
</svg>
</div>
</div>
<div id="<%= dom_id company %>">
<p class="my-5">
<strong class="block font-medium mb-1">Name:</strong>
<%= company.name %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Id number:</strong>
<%= company.id_number %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Vat number:</strong>
<%= company.vat_number %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Address line one:</strong>
<%= company.address_line_one %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Address line two:</strong>
<%= company.address_line_two %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Postal code:</strong>
<%= company.postal_code %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">City:</strong>
<%= company.city %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Entity:</strong>
<%= company.entity %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Country:</strong>
<%= company.country %>
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm">
<span class="font-medium text-gray-500"><%= t('companies.company.id_number') %></span>
<%= company.id_number %>
</p>
<p class="text-sm mt-2">
<span class="font-medium text-gray-500"><%= t('companies.company.vat_number') %></span>
<%= company.vat_number %>
</p>
<p class="text-sm mt-2">
<span class="font-medium text-gray-500"><%= t('companies.company.entity') %></span>
<%= company.entity %>
</p>
<p class="text-sm mt-2">
<span class="font-medium text-gray-500"><%= t('companies.company.country') %></span>
<%= company.country %>
</p>
</div>
<div>
<p class="text-sm">
<span class="font-medium text-gray-500"><%= t('companies.company.address_line_one') %></span>
<%= company.address_line_one %>
</p>
<% if company.address_line_two.present? %>
<p class="text-sm mt-2">
<span class="font-medium text-gray-500"><%= t('companies.company.address_line_two') %></span>
<%= company.address_line_two %>
</p>
<% end %>
<p class="text-sm mt-2">
<span class="font-medium text-gray-500"><%= t('companies.company.postal_code') %></span>
<%= company.postal_code %>
</p>
<p class="text-sm mt-2">
<span class="font-medium text-gray-500"><%= t('companies.company.city') %></span>
<%= company.city %>
</p>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<%= form_with(model: company, class: "contents") do |form| %>
<% if company.errors.any? %>
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= t('companies.form.errors.header', count: company.errors.count) %></h2>
<h2><%= pluralize(company.errors.count, "error") %> prohibited this company from being saved:</h2>
<ul>
<% company.errors.each do |error| %>
@@ -12,55 +12,51 @@
<% end %>
<div class="my-5">
<%= form.label :name, t('companies.form.name') %>
<%= 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" %>
</div>
<div class="my-5">
<%= form.label :id_number, t('companies.form.id_number') %>
<%= form.label :id_number %>
<%= form.text_field :id_number, 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 :vat_number, t('companies.form.vat_number') %>
<%= form.label :vat_number %>
<%= form.text_field :vat_number, 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 :address_line_one, t('companies.form.address_line_one') %>
<%= form.label :address_line_one %>
<%= form.text_field :address_line_one, 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 :address_line_two, t('companies.form.address_line_two') %>
<%= form.label :address_line_two %>
<%= form.text_field :address_line_two, 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 :postal_code, t('companies.form.postal_code') %>
<%= form.label :postal_code %>
<%= form.text_field :postal_code, 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 :city, t('companies.form.city') %>
<%= form.label :city %>
<%= form.text_field :city, 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 :entity, t('companies.form.entity') %>
<%= form.label :entity %>
<%= form.text_field :entity, 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 :country, t('companies.form.country') %>
<%= form.label :country %>
<%= form.text_field :country, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="inline">
<% if company.new_record? %>
<%= form.submit t('companies.form.create'), class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
<% else %>
<%= form.submit t('companies.form.update'), class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
<% end %>
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>

View File

@@ -1,8 +1,8 @@
<div class="mx-auto md:w-2/3 w-full">
<h1 class="font-bold text-4xl"><%= t('.title') %></h1>
<h1 class="font-bold text-4xl">Editing company</h1>
<%= render "form", company: @company %>
<%= link_to t('.show'), @company, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to t('.back'), companies_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Show this company", @company, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Back to companies", companies_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

View File

@@ -1,27 +1,21 @@
<div class="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="w-full">
<% if notice.present? %>
<div class="mb-8 mt-4">
<p class="py-3 px-4 bg-green-50 text-green-700 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
</div>
<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 %>
<% content_for :title, t('.title') %>
<% content_for :title, "Companies" %>
<div class="flex justify-between items-center mb-12">
<h1 class="font-bold text-4xl text-gray-900"><%= t('.title') %></h1>
<%= link_to new_company_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white font-medium hover:bg-blue-700 transition-colors duration-200 flex items-center" do %>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<%= t('.new_company') %>
<% end %>
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Companies</h1>
<%= link_to "New company", new_company_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
</div>
<div id="companies" class="grid gap-6 mb-8">
<div id="companies" class="min-w-full">
<% @companies.each do |company| %>
<%= link_to company, class: "block transition-all duration-200 hover:shadow-lg hover:-translate-y-1" do %>
<%= render company %>
<% end %>
<%= render company %>
<p>
<%= link_to "Show this company", company, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</p>
<% end %>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<div class="mx-auto md:w-2/3 w-full">
<h1 class="font-bold text-4xl"><%= t('.title') %></h1>
<h1 class="font-bold text-4xl">New company</h1>
<%= render "form", company: @company %>
<%= link_to t('.back'), companies_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Back to companies", companies_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

View File

@@ -6,10 +6,10 @@
<%= render @company %>
<%= link_to t('.edit'), edit_company_path(@company), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to t('.back'), companies_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Edit this company", edit_company_path(@company), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Back to companies", companies_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<div class="inline-block ml-2">
<%= button_to t('.destroy'), @company, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
<%= button_to "Destroy this company", @company, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
</div>
</div>
</div>

View File

@@ -19,14 +19,6 @@
<%= customer.notes %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Color:</strong>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
style="background-color: <%= customer.color_hex %>20; color: <%= customer.color_hex %>; border: 1px solid <%= customer.color_hex %>;">
<%= customer.color_emoji %> <%= (customer.color || 'green').humanize %>
</span>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Email:</strong>
<%= customer.email %>

View File

@@ -31,18 +31,6 @@
<%= form.text_area :notes, rows: 4, 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 :color %>
<%= form.select :color,
options_for_select([
['Green', 'green'],
['Yellow', 'yellow'],
['Red', 'red']
], customer.color),
{ prompt: 'Select color' },
{ 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 :email %>
<%= form.text_field :email, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>

View File

@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
<html>
<head>
<title><%= content_for?(:title) ? yield(:title) : "Terminator" %></title>
<link rel="manifest" crossorigin="use-credentials" href="/manifest.json" />
<title>Terminator</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
@@ -11,22 +10,12 @@
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<%= stylesheet_link_tag "tailwind", "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>
<% if logged_in? %>
<div class="fixed top-4 right-4 text-sm text-gray-600 bg-white px-3 py-1 rounded-md shadow-sm border">
<span class="lowercase"><%= current_user.username %></span>
<span class="mx-2">|</span>
<%= link_to t('sessions.logout_button'), logout_path, method: :delete,
class: "text-blue-600 hover:text-blue-800" %>
</div>
<% end %>
<main class="container mx-auto mt-28 px-5 flex">
<%= yield %>
</main>

View File

@@ -2,7 +2,6 @@
<html>
<head>
<title>Terminator</title>
<link rel="manifest" crossorigin="use-credentials" href="/manifest.json" />
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
@@ -10,6 +9,7 @@
<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>
@@ -50,114 +50,6 @@
padding: 5px;
border-radius: 5px;
}
/* ToastUI Calendar Event Styling */
.toastui-calendar-events {
margin-right: 8px;
text-wrap: auto;
}
.toastui-calendar-event-time {
cursor: pointer;
transition: all 0.2s ease;
min-width: 50px !important;
}
.toastui-calendar-event-time:hover {
z-index: 1000 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
border-radius: 3px !important;
}
.toastui-calendar-event-time-content {
padding: 2px 4px !important;
text-wrap: auto;
word-wrap: break-word;
line-height: 1.1 !important;
overflow: hidden;
}
.toastui-calendar-template-time {
font-size: 10px !important;
line-height: 1.1 !important;
}
.toastui-calendar-template-time strong {
font-size: 10px !important;
font-weight: 600;
display: block;
margin-bottom: 1px;
}
.toastui-calendar-template-time span {
font-size: 9px !important;
line-height: 1.0 !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
/* Hover tooltip effect */
.toastui-calendar-event-time:hover::after {
content: "Click to edit reservation";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 11px;
white-space: nowrap;
z-index: 2000;
pointer-events: none;
opacity: 0;
animation: tooltipFadeIn 0.3s ease forwards;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.toastui-calendar-event-time:hover::before {
content: '';
position: absolute;
bottom: calc(100% - 6px);
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(0, 0, 0, 0.9);
z-index: 2000;
pointer-events: none;
opacity: 0;
animation: tooltipFadeIn 0.3s ease forwards;
}
@keyframes tooltipFadeIn {
to {
opacity: 1;
}
}
/* Improve visibility for small events */
.toastui-calendar-event-time[style*="height: calc(1%"] .toastui-calendar-template-time strong,
.toastui-calendar-event-time[style*="height: calc(2%"] .toastui-calendar-template-time strong {
display: none; /* Hide time for very small events */
}
.toastui-calendar-event-time[style*="height: calc(1%"] .toastui-calendar-template-time span,
.toastui-calendar-event-time[style*="height: calc(2%"] .toastui-calendar-template-time span {
font-size: 8px !important;
line-height: 1.0 !important;
}
/* Better text contrast */
.toastui-calendar-event-time[style*="background-color: rgb(66, 109, 215)"] {
color: white !important;
}
.toastui-calendar-event-time[style*="background-color: rgb(66, 109, 215)"] .toastui-calendar-template-time {
color: white !important;
}
</style>
</head>
@@ -167,4 +59,3 @@
</main>
</body>
</html>
want

View File

@@ -1,12 +1,7 @@
<%= 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| %>
<%= form_with(model: reservation, class: "contents", data: { controller: "customer-search" }) do |form| %>
<% if reservation.errors.any? %>
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= t('reservations.form.errors.header', count: reservation.errors.count) %></h2>
<h2><%= pluralize(reservation.errors.count, "error") %> prohibited this reservation from being saved:</h2>
<ul>
<% reservation.errors.each do |error| %>
@@ -17,77 +12,104 @@
<% end %>
<div class="my-5">
<%= form.label :customer, t('reservations.form.customer') %>
<%= form.select :customer_composite_key,
<%= form.label :customer %>
<%= form.select :customer_id,
[], # Start with empty options
{ prompt: t('reservations.form.search_prompt') },
{
selected: (reservation.persisted? && reservation.customer ? reservation.customer.to_param : nil),
{ 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 data-customer-search-target="newCustomerFields" class="">
<div class="my-5">
<%= form.label :phone_number, t('reservations.form.phone_number') %>
<%= 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, t('reservations.form.first_name') %>
<%= 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, t('reservations.form.surname') %>
<%= 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, t('reservations.form.birth_year') %>
<%= 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, t('reservations.form.team') %>
<%= form.label :team_id %>
<%= form.collection_select :team_id,
@company&.teams || [],
@company.teams,
:id,
:name,
{ prompt: t('reservations.form.select_team') },
{ 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, t('reservations.form.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",
id: "start_time_field" %>
</div>
<div class="my-5">
<%= form.label :end_time, t('reservations.form.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",
id: "end_time_field" %>
</div>
<div class="inline">
<% if reservation.new_record? %>
<%= form.submit t('reservations.form.create'), class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
<% else %>
<%= form.submit t('reservations.form.update'), class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
<% end %>
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
<script>
window.onload = function() {
// Only set default times for new records (not when editing)
<% unless reservation.persisted? %>
// Get current date and time
const now = new Date();
// Get local components
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
// Format for datetime-local input (YYYY-MM-DDThh:mm)
const localStartTime = `${year}-${month}-${day}T${hours}:${minutes}`;
document.getElementById('start_time_field').value = localStartTime;
// Add 30 minutes for end time
const endDate = new Date(now.getTime() + 30 * 60000);
const endHours = String(endDate.getHours()).padStart(2, '0');
const endMinutes = String(endDate.getMinutes()).padStart(2, '0');
const localEndTime = `${year}-${month}-${day}T${endHours}:${endMinutes}`;
document.getElementById('end_time_field').value = localEndTime;
// For debugging - add this to see actual values
console.log("Start time set to: " + localStartTime);
console.log("End time set to: " + localEndTime);
console.log("Current browser time: " + now.toString());
<% end %>
};
</script>

View File

@@ -17,12 +17,12 @@
<p class="my-5">
<strong class="block font-medium mb-1">Start time:</strong>
<%= l reservation.start_time if reservation.start_time %>
<%= reservation.start_time %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">End time:</strong>
<%= l reservation.end_time if reservation.end_time %>
<%= reservation.end_time %>
</p>
</div>

View File

@@ -1,8 +1,8 @@
<div class="mx-auto md:w-2/3 w-full">
<h1 class="font-bold text-4xl"><%= t('.title') %></h1>
<h1 class="font-bold text-4xl">Editing reservation</h1>
<%= render "form", reservation: @reservation %>
<%= link_to t('.show'), @reservation, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to t('.back'), reservations_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Show this reservation", @reservation, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Back to reservations", reservations_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

View File

@@ -2,69 +2,36 @@
<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;">
<% content_for :title, t('.title') %>
<% 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" %>
<div class="flex justify-between items-center-calendar">
<div class="flex items-center space-x-4">
<h1 class="font-bold text-4xl px-5"><%= t('.title') %></h1>
<% if notice.present? %>
<p class="py-1 px-3 ml-4 bg-green-100 text-green-700 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<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">
<%= t('.prev') %>
&laquo; Prev
</button>
<button data-action="main-calendar#today" class="px-3 py-1 bg-gray-100 rounded-md hover:bg-gray-200">
<%= t('.today') %>
Today
</button>
<button data-action="main-calendar#next" class="px-3 py-1 bg-gray-100 rounded-md hover:bg-gray-200">
<%= t('.next') %>
Next &raquo;
</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>
<% if @company&.teams %>
<% @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 %>
<% 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" %>
<%= 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%;">
<%
calendar_translations = {
hours: t('reservations.calendar.hours'),
delete_confirm: t('reservations.calendar.delete_confirm'),
delete_success: t('reservations.calendar.delete_success'),
delete_error: t('reservations.calendar.delete_error'),
network_error: t('reservations.calendar.network_error'),
edit: t('reservations.calendar.edit'),
delete: t('reservations.calendar.delete'),
all_teams: t('reservations.calendar.all_teams')
}.to_json
%>
<%= tag.div nil,
data: {
reservations: @reservations.to_json,
translations: calendar_translations
},
id: "main-calendar",
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>

View File

@@ -1,7 +1,7 @@
<div class="mx-auto md:w-2/3 w-full">
<h1 class="font-bold text-4xl"><%= t('.title') %></h1>
<h1 class="font-bold text-4xl">New reservation</h1>
<%= render "form", reservation: @reservation %>
<%= link_to t('.back'), reservations_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Back to reservations", reservations_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

View File

@@ -1,21 +0,0 @@
{
"short_name": "Zdravo Stopalo",
"name": "Zdravo Stopalo",
"icons": [
{
"src": "<%= image_path('icon-192.png')%>",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "<%= image_path('icon-512.png')%>",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "<%= root_path %>",
"background_color": "#FFFFFF",
"display": "standalone",
"scope": "<%= root_path %>",
"theme_color": "#8e2731"
}

View File

@@ -1,14 +0,0 @@
function onInstall(event) {
console.log("[Serviceworker]", "Installing!", event);
}
function onActivate(event) {
console.log("[Serviceworker]", "Activating!", event);
}
function onFetch(event) {
console.log("[Serviceworker]", "Fetching!", event);
}
self.addEventListener("install", onInstall);
self.addEventListener("activate", onActivate);
self.addEventListener("fetch", onFetch);

View File

@@ -1,24 +0,0 @@
<div class="max-w-md mx-auto mt-8 bg-white p-6 rounded-lg shadow-md">
<h2 class="text-2xl font-bold text-center mb-6"><%= t('sessions.login') %></h2>
<%= form_with url: login_path, method: :post, local: true, class: "space-y-4" do |form| %>
<div>
<%= form.label :login, t('sessions.username_or_email'), class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= form.text_field :login, required: true,
class: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
placeholder: t('sessions.username_or_email_placeholder') %>
</div>
<div>
<%= form.label :password, t('sessions.password'), class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= form.password_field :password, required: true,
class: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
placeholder: t('sessions.password_placeholder') %>
</div>
<div>
<%= form.submit t('sessions.login_button'),
class: "w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-200" %>
</div>
<% end %>
</div>

View File

@@ -1,7 +1,7 @@
<%= form_with(model: team, class: "contents") do |form| %>
<% if team.errors.any? %>
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= t('teams.form.errors.header', count: team.errors.count) %></h2>
<h2><%= pluralize(team.errors.count, "error") %> prohibited this team from being saved:</h2>
<ul>
<% team.errors.each do |error| %>
<li><%= error.full_message %></li>
@@ -11,15 +11,11 @@
<% end %>
<div class="my-5">
<%= form.label :name, t('teams.form.name') %>
<%= 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" %>
</div>
<div class="inline">
<% if team.new_record? %>
<%= form.submit t('teams.form.create'), class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
<% else %>
<%= form.submit t('teams.form.update'), class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
<% end %>
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>

View File

@@ -1,12 +1,2 @@
<div id="<%= dom_id team %>" class="bg-white rounded-xl shadow-md overflow-hidden mb-6 hover:shadow-lg transition-shadow duration-300">
<div class="p-6 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-800"><%= team.name %></h2>
</div>
<div class="flex-shrink-0 bg-blue-100 text-blue-800 rounded-full p-3">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
</svg>
</div>
</div>
<div id="<%= dom_id team %>">
</div>

View File

@@ -1,8 +1,8 @@
<div class="mx-auto md:w-2/3 w-full">
<h1 class="font-bold text-4xl"><%= t('.title') %></h1>
<h1 class="font-bold text-4xl">Editing team</h1>
<%= render "form", team: @team %>
<%= link_to t('.show'), @team, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to t('.back'), teams_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Show this team", @team, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Back to teams", teams_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

View File

@@ -1,27 +1,21 @@
<div class="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="w-full">
<% if notice.present? %>
<div class="mb-8 mt-4">
<p class="py-3 px-4 bg-green-50 text-green-700 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
</div>
<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 %>
<% content_for :title, t('.title') %>
<% content_for :title, "Teams" %>
<div class="flex justify-between items-center mb-12">
<h1 class="font-bold text-4xl text-gray-900"><%= t('.title') %></h1>
<%= link_to new_team_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white font-medium hover:bg-blue-700 transition-colors duration-200 flex items-center" do %>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<%= t('.new_team') %>
<% end %>
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Teams</h1>
<%= link_to "New team", new_team_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
</div>
<div id="teams" class="grid gap-6 mb-8">
<div id="teams" class="min-w-full">
<% @teams.each do |team| %>
<%= link_to team, class: "block transition-all duration-200 hover:shadow-lg hover:-translate-y-1" do %>
<%= render team %>
<% end %>
<%= render team %>
<p>
<%= link_to "Show this team", team, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</p>
<% end %>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<div class="mx-auto md:w-2/3 w-full">
<h1 class="font-bold text-4xl"><%= t('.title') %></h1>
<h1 class="font-bold text-4xl">New team</h1>
<%= render "form", team: @team %>
<%= link_to t('.back'), teams_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Back to teams", teams_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

View File

@@ -6,10 +6,10 @@
<%= render @team %>
<%= link_to t('.edit'), edit_team_path(@team), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to t('.back'), teams_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Edit this team", edit_team_path(@team), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Back to teams", teams_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<div class="inline-block ml-2">
<%= button_to t('.destroy'), @team, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
<%= button_to "Destroy this team", @team, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
</div>
</div>
</div>

View File

@@ -16,11 +16,6 @@ module Terminator
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w(assets tasks))
# Set available locales and default locale
config.i18n.available_locales = [:en, :bs]
config.i18n.default_locale = :bs
config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}')]
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files

View File

@@ -63,6 +63,8 @@ Rails.application.configure do
# Suppress logger output for asset requests.
config.assets.quiet = true
config.assets.compile = true
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true

View File

@@ -4,4 +4,3 @@ pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "application"
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/custom", under: "custom"

View File

@@ -10,3 +10,5 @@ Rails.application.config.assets.version = "1.0"
# application.js, application.css, and all non-JS/CSS in the app/assets
# folder are already added.
# Rails.application.config.assets.precompile += %w( admin.js admin.css )
Rails.application.config.assets.precompile += %w( application.tailwind.css )

View File

@@ -1,147 +0,0 @@
bs:
# Add Bosnian translations here
reservations:
index:
title: "Rezervacije"
prev: "« Prethodna"
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?"
delete_success: "Rezervacija je izbrisana."
delete_error: "Greška prilikom brisanja rezervacije."
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"
show: "Prikaži ovu rezervaciju"
back: "Nazad na rezervacije"
new:
title: "Nova rezervacija"
back: "Nazad na rezervacije"
form:
customer: "Klijent"
search_prompt: "Unesite za pretragu klijenata..."
phone_number: "Broj telefona"
first_name: "Ime"
surname: "Prezime"
birth_year: "Godina rođenja"
customer_color: "Boja klijenta"
select_color: "Odaberite boju klijenta"
green: "Zelena"
yellow: "Žuta"
red: "Crvena"
team: "Tim"
select_team: "Odaberite tim"
start_time: "Vrijeme početka"
end_time: "Vrijeme završetka"
submit: "Pošalji"
update: "Ažuriraj rezervaciju"
create: "Kreiraj rezervaciju"
errors:
header: "%{count} greška/e spriječila/e je da se ova rezervacija sačuva:"
create:
reservation_created: "Rezervacija je uspješno kreirana."
update:
reservation_updated: "Rezervacija je uspješno ažurirana."
destroy:
reservation_destroyed: "Rezervacija je uspješno izbrisana."
teams:
create:
team_created: "Tim je uspješno kreiran."
update:
team_updated: "Tim je uspješno ažuriran."
destroy:
team_destroyed: "Tim je uspješno izbrisan."
index:
title: "Timovi"
new_team: "Novi tim"
show_team: "Prikaži ovaj tim"
new:
title: "Novi tim"
back: "Nazad na timove"
edit:
title: "Uređivanje tima"
show: "Prikaži ovaj tim"
back: "Nazad na timove"
show:
edit: "Uredi ovaj tim"
back: "Nazad na timove"
destroy: "Izbriši ovaj tim"
form:
name: "Naziv"
submit: "Pošalji"
update: "Ažuriraj tim"
create: "Kreiraj tim"
errors:
header: "%{count} greška/e spriječila/e je da se ovaj tim sačuva:"
companies:
create:
company_created: "Kompanija je uspješno kreirana."
update:
company_updated: "Kompanija je uspješno ažurirana."
destroy:
company_destroyed: "Kompanija je uspješno izbrisana."
index:
title: "Kompanije"
new_company: "Nova kompanija"
show_company: "Prikaži ovu kompaniju"
new:
title: "Nova kompanija"
back: "Nazad na kompanije"
edit:
title: "Uređivanje kompanije"
show: "Prikaži ovu kompaniju"
back: "Nazad na kompanije"
show:
edit: "Uredi ovu kompaniju"
back: "Nazad na kompanije"
destroy: "Izbriši ovu kompaniju"
form:
name: "Naziv"
id_number: "ID broj"
vat_number: "PDV broj"
address_line_one: "Adresa (prva linija)"
address_line_two: "Adresa (druga linija)"
postal_code: "Poštanski broj"
city: "Grad"
entity: "Entitet"
country: "Država"
submit: "Pošalji"
update: "Ažuriraj kompaniju"
create: "Kreiraj kompaniju"
errors:
header: "%{count} greška/e spriječila/e je da se ova kompanija sačuva:"
company:
name: "Naziv:"
id_number: "ID broj:"
vat_number: "PDV broj:"
address_line_one: "Adresa (prva linija):"
address_line_two: "Adresa (druga linija):"
postal_code: "Poštanski broj:"
city: "Grad:"
entity: "Entitet:"
country: "Država:"
sessions:
login: "Prijava"
username_or_email: "Korisničko ime ili email"
username_or_email_placeholder: "Unesite korisničko ime ili email"
password: "Lozinka"
password_placeholder: "Unesite lozinku"
login_button: "Prijaviť se"
logout_button: "Odjavi se"
login_successful: "Uspješno ste se prijavili!"
logout_successful: "Uspješno ste se odjavili!"
invalid_credentials: "Neispravno korisničko ime/email ili lozinka"
login_required: "Molimo prijavite se da biste pristupili ovoj stranici"

View File

@@ -28,7 +28,6 @@
# enabled: "ON"
en:
# Add English translations here
hello: "Hello world"
customers:
create:
@@ -46,51 +45,6 @@ en:
reservation_updated: "Reservation was successfully updated."
destroy:
reservation_destroyed: "Reservation was successfully destroyed."
index:
title: "Reservations"
prev: "« Prev"
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?"
delete_success: "Reservation deleted."
delete_error: "Error deleting reservation."
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"
back: "Back to reservations"
new:
title: "New reservation"
back: "Back to reservations"
form:
customer: "Customer"
search_prompt: "Type to search customers..."
phone_number: "Phone number"
first_name: "First name"
surname: "Surname"
birth_year: "Birth year"
customer_color: "Customer color"
select_color: "Select customer color"
green: "Green"
yellow: "Yellow"
red: "Red"
team: "Team"
select_team: "Select a team"
start_time: "Start time"
end_time: "End time"
submit: "Submit"
update: "Update Reservation"
create: "Create Reservation"
errors:
header: "%{count} prohibited this reservation from being saved:"
teams:
create:
team_created: "Team was successfully created."
@@ -98,85 +52,3 @@ en:
team_updated: "Team was successfully updated."
destroy:
team_destroyed: "Team was successfully destroyed."
index:
title: "Teams"
new_team: "New team"
show_team: "Show this team"
new:
title: "New team"
back: "Back to teams"
edit:
title: "Editing team"
show: "Show this team"
back: "Back to teams"
show:
edit: "Edit this team"
back: "Back to teams"
destroy: "Destroy this team"
form:
name: "Name"
submit: "Submit"
update: "Update Team"
create: "Create Team"
errors:
header: "%{count} prohibited this team from being saved:"
companies:
create:
company_created: "Company was successfully created."
update:
company_updated: "Company was successfully updated."
destroy:
company_destroyed: "Company was successfully destroyed."
index:
title: "Companies"
new_company: "New company"
show_company: "Show this company"
new:
title: "New company"
back: "Back to companies"
edit:
title: "Editing company"
show: "Show this company"
back: "Back to companies"
show:
edit: "Edit this company"
back: "Back to companies"
destroy: "Destroy this company"
form:
name: "Name"
id_number: "ID number"
vat_number: "VAT number"
address_line_one: "Address line one"
address_line_two: "Address line two"
postal_code: "Postal code"
city: "City"
entity: "Entity"
country: "Country"
submit: "Submit"
update: "Update Company"
create: "Create Company"
errors:
header: "%{count} prohibited this company from being saved:"
company:
name: "Name:"
id_number: "ID number:"
vat_number: "VAT number:"
address_line_one: "Address line one:"
address_line_two: "Address line two:"
postal_code: "Postal code:"
city: "City:"
entity: "Entity:"
country: "Country:"
sessions:
login: "Login"
username_or_email: "Username or Email"
username_or_email_placeholder: "Enter username or email"
password: "Password"
password_placeholder: "Enter password"
login_button: "Log In"
logout_button: "Log Out"
login_successful: "Successfully logged in!"
logout_successful: "Successfully logged out!"
invalid_credentials: "Invalid username/email or password"
login_required: "Please log in to access this page"

View File

@@ -1,8 +1,4 @@
Rails.application.routes.draw do
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
delete '/logout', to: 'sessions#destroy'
root "reservations#index"
resources :customers, param: :composite_key do
get :search, on: :collection
@@ -16,9 +12,6 @@ Rails.application.routes.draw do
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
# config/routes.rb
get "/service-worker.js" => "service_worker#service_worker"
get "/manifest.json" => "service_worker#manifest"
# Defines the root path route ("/")
# root "posts#index"
end

View File

@@ -1,5 +0,0 @@
class AddColorToCustomers < ActiveRecord::Migration[7.1]
def change
add_column :customers, :color, :string
end
end

View File

@@ -1,9 +0,0 @@
class UpdateExistingCustomersColor < ActiveRecord::Migration[7.1]
def up
Customer.where(color: nil).update_all(color: 'green')
end
def down
# No rollback needed - we don't want to set colors back to nil
end
end

View File

@@ -1,14 +0,0 @@
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :username
t.string :email
t.string :password_digest
t.references :company, null: false, foreign_key: true
t.timestamps
end
add_index :users, :username, unique: true
add_index :users, :email, unique: true
end
end

View File

@@ -1,6 +0,0 @@
class AddEmailToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :email, :string
add_index :users, :email, unique: true
end
end

16
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_07_31_114403) do
ActiveRecord::Schema[7.1].define(version: 2025_02_18_071800) do
create_table "companies", force: :cascade do |t|
t.string "name"
t.string "id_number"
@@ -36,7 +36,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_31_114403) do
t.string "first_name"
t.string "surname", null: false
t.string "original_phone", null: false
t.string "color"
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
@@ -66,21 +65,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_31_114403) do
t.index ["company_id"], name: "index_teams_on_company_id"
end
create_table "users", force: :cascade do |t|
t.string "username"
t.string "password_digest"
t.integer "company_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "email"
t.index ["company_id"], name: "index_users_on_company_id"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["username"], name: "index_users_on_username", unique: true
end
add_foreign_key "reservations", "companies"
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 "teams", "companies"
add_foreign_key "users", "companies"
end

View File

@@ -7,34 +7,3 @@
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
# MovieGenre.find_or_create_by!(name: genre_name)
# end
# Create a default company if none exists
default_company = Company.find_or_create_by!(name: 'Default Company') do |company|
company.id_number = 'COMP001'
company.vat_number = 'VAT001'
company.address_line_one = '123 Main Street'
company.city = 'Default City'
company.country = 'Default Country'
end
# Create default teams for the company
teams_data = [
{ name: 'Team Alpha' },
{ name: 'Team Beta' },
{ name: 'Team Gamma' }
]
teams_data.each do |team_attrs|
Team.find_or_create_by!(name: team_attrs[:name], company: default_company)
end
puts "Seeded default company: #{default_company.name}"
puts "Seeded #{default_company.teams.count} teams"
# Create default user for the company
default_user = User.find_or_create_by!(username: 'admin', company: default_company) do |user|
user.email = 'admin@company.ba'
user.password = 'password123'
user.password_confirmation = 'password123'
end
puts "Seeded default user: #{default_user.username} (#{default_user.email})"

View File

@@ -1,271 +0,0 @@
namespace :users do
desc "Create a new user"
task :create, %i[username email password company_id] => :environment do |_t, args|
username = args[:username] || ask("Username: ")
email = args[:email] || ask("Email: ")
password = args[:password] || ask("Password: ") { |q| q.echo = "*" }
company_id = find_or_validate_company(args[:company_id])
user = User.new(
username: username,
email: email,
password: password,
password_confirmation: password,
company_id: company_id
)
if user.save
puts "User created successfully!"
puts " Username: #{user.username}"
puts " Email: #{user.email}"
puts " Company: #{user.company.name}"
else
puts "Failed to create user:"
user.errors.full_messages.each { |msg| puts " - #{msg}" }
exit 1
end
end
desc "Change user password"
task :change_password, %i[username new_password] => :environment do |_t, args|
username = args[:username] || ask("Username: ")
new_password = args[:new_password] || ask("New password: ") { |q| q.echo = "*" }
user = User.find_by(username: username)
unless user
puts "User '#{username}' not found."
exit 1
end
user.password = new_password
user.password_confirmation = new_password
if user.save
puts "Password changed successfully for user '#{username}'"
else
puts "Failed to change password:"
user.errors.full_messages.each { |msg| puts " - #{msg}" }
exit 1
end
end
desc "Delete a user"
task :delete, %i[username] => :environment do |_t, args|
username = args[:username] || ask("Username to delete: ")
user = User.find_by(username: username)
unless user
puts "User '#{username}' not found."
exit 1
end
puts "User details:"
puts " Username: #{user.username}"
puts " Email: #{user.email}"
puts " Company: #{user.company.name}"
confirm = ask("Are you sure you want to delete this user? (yes/no): ")
unless confirm.downcase == 'yes'
puts "User deletion cancelled."
exit 0
end
if user.destroy
puts "User '#{username}' deleted successfully."
else
puts "Failed to delete user:"
user.errors.full_messages.each { |msg| puts " - #{msg}" }
exit 1
end
end
desc "List all users"
task list: :environment do
users = User.includes(:company).order(:username)
if users.empty?
puts "No users found."
exit 0
end
puts "Users List:"
puts "=" * 80
printf "%-20s %-30s %-20s %s\n", "USERNAME", "EMAIL", "COMPANY", "CREATED"
puts "-" * 80
users.each do |user|
printf "%-20s %-30s %-20s %s\n",
user.username,
user.email,
user.company.name,
user.created_at.strftime("%Y-%m-%d")
end
puts "-" * 80
puts "Total: #{users.count} users"
end
desc "Run automated CRUD tests for user management"
task test_crud: :environment do
puts "=" * 60
puts "AUTOMATED USER CRUD TEST"
puts "=" * 60
# Test 1: Create users
puts "\n1. TESTING USER CREATION (dodavanje):"
puts "-" * 40
test_users = [
{ username: 'testuser1', email: 'test1@example.com', password: 'testpass123' },
{ username: 'testuser2', email: 'test2@example.com', password: 'testpass456' }
]
test_users.each do |user_data|
company_id = find_or_validate_company(nil)
user = User.new(
username: user_data[:username],
email: user_data[:email],
password: user_data[:password],
password_confirmation: user_data[:password],
company_id: company_id
)
if user.save
puts "✓ Created user: #{user.username} (#{user.email})"
else
puts "✗ Failed to create #{user_data[:username]}: #{user.errors.full_messages.join(', ')}"
end
end
# Test 2: List users
puts "\n2. TESTING USER LISTING:"
puts "-" * 40
users = User.where("username LIKE ?", "%test%").order(:username)
users.each do |user|
puts "✓ Found user: #{user.username} (#{user.email}) - Company: #{user.company.name}"
end
# Test 3: Change password (mijenjanje sifre)
puts "\n3. TESTING PASSWORD CHANGE (mijenjanje sifre):"
puts "-" * 40
test_user = User.find_by(username: 'testuser1')
if test_user
test_user.password = 'newpassword789'
test_user.password_confirmation = 'newpassword789'
if test_user.save
puts "✓ Password changed for: #{test_user.username}"
else
puts "✗ Failed to change password: #{test_user.errors.full_messages.join(', ')}"
end
end
# Test 4: Show user details
puts "\n4. TESTING USER DETAILS:"
puts "-" * 40
if test_user
puts "✓ User: #{test_user.username}"
puts " Email: #{test_user.email}"
puts " Company: #{test_user.company.name}"
puts " Created: #{test_user.created_at.strftime('%Y-%m-%d %H:%M:%S')}"
end
# Test 5: Delete user (brisanje)
puts "\n5. TESTING USER DELETION (brisanje):"
puts "-" * 40
delete_user = User.find_by(username: 'testuser2')
if delete_user
username = delete_user.username
if delete_user.destroy
puts "✓ Deleted user: #{username}"
else
puts "✗ Failed to delete user: #{delete_user.errors.full_messages.join(', ')}"
end
end
# Test 6: Final cleanup
puts "\n6. CLEANUP:"
puts "-" * 40
remaining_test_users = User.where("username LIKE ? OR email LIKE ?", "%test%", "%test%")
deleted_count = remaining_test_users.count
remaining_test_users.destroy_all
puts "✓ Cleaned up #{deleted_count} remaining test users"
puts "\n" + ("=" * 60)
puts "CRUD TEST COMPLETED SUCCESSFULLY!"
puts "All operations (dodavanje, mijenjanje sifre, brisanje) tested."
puts "=" * 60
end
desc "Clean up test users (users with 'test' in username or email)"
task cleanup_test_users: :environment do
test_users = User.where("username LIKE ? OR email LIKE ?", "%test%", "%test%")
if test_users.empty?
puts "No test users found to clean up."
return
end
puts "Found #{test_users.count} test users to delete:"
test_users.each do |user|
puts " - #{user.username} (#{user.email})"
end
confirm = ask("Delete all test users? (yes/no): ")
if confirm.downcase == 'yes'
deleted_count = test_users.count
test_users.destroy_all
puts "Deleted #{deleted_count} test users successfully."
else
puts "Cleanup cancelled."
end
end
desc "Show user details"
task :show, %i[username] => :environment do |_t, args|
username = args[:username] || ask("Username: ")
user = User.find_by(username: username)
unless user
puts "User '#{username}' not found."
exit 1
end
puts "User Details:"
puts " Username: #{user.username}"
puts " Email: #{user.email}"
puts " Company: #{user.company.name} (ID: #{user.company.id})"
puts " Created: #{user.created_at.strftime('%Y-%m-%d %H:%M:%S')}"
puts " Updated: #{user.updated_at.strftime('%Y-%m-%d %H:%M:%S')}"
end
private
def find_or_validate_company(company_id)
if company_id.blank?
company = Company.first
unless company
puts "No companies found. Please create a company first."
exit 1
end
puts "Using company: #{company.name} (ID: #{company.id})"
company.id
else
company = Company.find_by(id: company_id)
unless company
puts "Company with ID #{company_id} not found."
exit 1
end
company_id
end
end
def ask(prompt, &_block)
require 'io/console'
print prompt
if block_given?
yield.call
else
$stdin.gets.chomp
end
end
end

View File

@@ -5,8 +5,6 @@ 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
@@ -63,33 +61,4 @@ 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