4 Commits

Author SHA1 Message Date
693f3b587a Merge branch '13-napraviti-podrsku-za-korisnike' into 'master'
Added users support

Closes #13

See merge request kbr4/zsterminator!11
2025-08-19 13:36:25 +00:00
a980ac576f Merge branch '12-napraviti-da-se-za-svakog-klijenta-izabrati-boja-i-moze-napisati-napomena' into 'master'
12 Added notes and colours to customers

Closes #12

See merge request kbr4/zsterminator!10
2025-08-19 13:35:20 +00:00
20b62e7312 Added users support 2025-08-19 07:24:18 +02:00
541b181c87 12 Added notes and colours to customers 2025-07-23 15:35:19 +02:00
29 changed files with 953 additions and 54 deletions

118
README.md
View File

@@ -1,24 +1,116 @@
# README
# ZSTerminator -
This README would normally document whatever steps are necessary to get the
application up and running.
Things you may want to cover:
## Setup
* Ruby version
* Ruby version: 3.2.4
* Rails version: 7.1.5+
* Database: SQLite3
* System dependencies
## Getting Started
* Configuration
1. Install dependencies:
```bash
bundle install
```
* Database creation
2. Setup database:
```bash
rails db:create
rails db:migrate
rails db:seed
```
* Database initialization
3. Start the server:
```bash
rails server
```
* How to run the test suite
4. Visit http://localhost:3000 and login with:
- Username: `admin`
- Password: `password123`
* Services (job queues, cache servers, search engines, etc.)
## User Management (Rake Tasks)
* Deployment instructions
This project includes Rake tasks for managing users via command line:
* ...
### 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

@@ -27,4 +27,126 @@
.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,5 +1,6 @@
class ApplicationController < ActionController::Base
before_action :set_locale
before_action :require_login
private
@@ -8,27 +9,37 @@ class ApplicationController < ActionController::Base
session[:locale] = I18n.locale
end
# Optional: Make locale persist across requests via URL helpers
def default_url_options
{ locale: I18n.locale }
end
def set_company
# 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
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.'
end
def current_company
@company
end
helper_method :current_company
helper_method :current_user, :logged_in?, :current_company
end

View File

@@ -64,10 +64,15 @@ 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: "#{c.full_name} (#{c.original_phone})",
birthyear: c.birthyear
label: label,
birthyear: c.birthyear,
color: c.color || 'green',
color_hex: c.color_hex,
notes: c.notes
}
}
end
@@ -86,6 +91,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)
params.require(:customer).permit(:first_name, :surname, :phone, :notes, :email, :birthyear, :color)
end
end

View File

@@ -41,7 +41,10 @@ class ReservationsController < ApplicationController
# POST /reservations or /reservations.json
def create
@reservation = @company.reservations.new(
reservation_params.except(:customer_id, :customer_birth_year)
reservation_params.except(
:customer_id, :customer_first_name, :customer_surname,
:customer_original_phone, :customer_birth_year, :customer_composite_key
)
)
# Find or create customer based on submitted attributes
@@ -68,7 +71,7 @@ class ReservationsController < ApplicationController
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_original_phone, :customer_birth_year, :customer_composite_key
)
# Find the customer identified by the submitted name/phone
@@ -144,6 +147,7 @@ class ReservationsController < ApplicationController
:customer_surname,
:customer_original_phone,
:customer_birth_year,
:customer_composite_key,
:customer_id # Allow this if select still sends it sometimes
)
end
@@ -215,5 +219,10 @@ class ReservationsController < ApplicationController
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

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

@@ -66,7 +66,8 @@ export default class extends Controller {
return '<div class="no-results">No customers found. Fill in the details below.</div>';
},
option: function(item) {
return `<div>${item.label}</div>`;
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>`;
}
},
@@ -98,6 +99,9 @@ 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();
}
}
@@ -148,4 +152,4 @@ export default class extends Controller {
this.selectInstance.destroy();
}
}
}
}

View File

@@ -89,7 +89,8 @@ export default class extends Controller {
return ''; // Empty location as requested
},
popupDetailAttendees(eventObj) {
return eventObj.attendees[0]; // Show team name
const teamName = eventObj.attendees[0]; // Show team name
return teamName;
},
popupDetailState(eventObj) {
return '';
@@ -148,11 +149,13 @@ export default class extends Controller {
const startTime = new Date(reservation.start_time);
const endTime = new Date(reservation.end_time);
// Create the event
// 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: reservation.customer ? `${reservation.customer.first_name} ${reservation.customer.surname}` : '',
title: customerName,
start: startTime,
end: endTime,
category: 'time',
@@ -276,4 +279,4 @@ export default class extends Controller {
// Update calendar display
window.calendar.render();
}
}
}

View File

@@ -2,4 +2,5 @@ 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,6 +2,8 @@ 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
@@ -21,20 +23,65 @@ 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

23
app/models/user.rb Normal file
View File

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

View File

@@ -19,6 +19,14 @@
<%= 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,6 +31,18 @@
<%= 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

@@ -18,6 +18,15 @@
</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

@@ -11,7 +11,6 @@
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<style>
html, body {
@@ -51,6 +50,114 @@
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>
@@ -60,4 +167,4 @@
</main>
</body>
</html>
want
want

View File

@@ -28,14 +28,13 @@
} %>
</div>
<div data-customer-search-target="newCustomerFields" class="">
<div class="my-5">
<%= form.label :phone_number, t('reservations.form.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.text_field :customer_first_name,
@@ -56,12 +55,14 @@
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.collection_select :team_id,
@company.teams,
@company&.teams || [],
:id,
:name,
{ prompt: t('reservations.form.select_team') },

View File

@@ -28,10 +28,12 @@
<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>
<% 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>
@@ -65,4 +67,4 @@
%>
</div>
</div>
</div>
</div>

View File

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

@@ -33,6 +33,11 @@ bs:
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"
@@ -126,4 +131,17 @@ bs:
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

@@ -77,6 +77,11 @@ en:
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"
@@ -162,3 +167,16 @@ en:
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,4 +1,8 @@
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
@@ -12,7 +16,7 @@ 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
# config/routes.rb
get "/service-worker.js" => "service_worker#service_worker"
get "/manifest.json" => "service_worker#manifest"
# Defines the root path route ("/")

View File

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

View File

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

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

@@ -0,0 +1,6 @@
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_02_18_071800) do
ActiveRecord::Schema[7.1].define(version: 2025_07_31_114403) do
create_table "companies", force: :cascade do |t|
t.string "name"
t.string "id_number"
@@ -36,6 +36,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_18_071800) 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
@@ -65,8 +66,21 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_18_071800) 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,3 +7,34 @@
# ["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})"

271
lib/tasks/users.rake Normal file
View File

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