hr_lite 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -1
- data/README.md +4 -1
- data/app/assets/javascripts/hr_lite/print.js +5 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +72 -0
- data/app/controllers/hr_lite/admin/documents_controller.rb +63 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/holidays_controller.rb +7 -0
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +23 -0
- data/app/controllers/hr_lite/admin/loans_controller.rb +59 -0
- data/app/controllers/hr_lite/admin/policies_controller.rb +29 -0
- data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +75 -0
- data/app/controllers/hr_lite/documents_controller.rb +69 -0
- data/app/controllers/hr_lite/hr_requests_controller.rb +2 -0
- data/app/controllers/hr_lite/id_cards_controller.rb +25 -0
- data/app/controllers/hr_lite/loans_controller.rb +11 -0
- data/app/controllers/hr_lite/tax_declarations_controller.rb +100 -0
- data/app/helpers/hr_lite/application_helper.rb +18 -3
- data/app/models/hr_lite/approval_flow.rb +1 -1
- data/app/models/hr_lite/employee_profile.rb +21 -3
- data/app/models/hr_lite/hr_request.rb +12 -3
- data/app/models/hr_lite/leave_request.rb +15 -4
- data/app/models/hr_lite/policy.rb +15 -0
- data/app/models/hr_lite/setting.rb +1 -1
- data/app/models/hr_lite/tax_declaration.rb +4 -1
- data/app/views/hr_lite/admin/documents/index.html.erb +89 -0
- data/app/views/hr_lite/admin/documents/show.html.erb +42 -0
- data/app/views/hr_lite/admin/employees/_form.html.erb +5 -0
- data/app/views/hr_lite/admin/employees/show.html.erb +1 -0
- data/app/views/hr_lite/admin/hr_requests/show.html.erb +3 -0
- data/app/views/hr_lite/admin/leave_requests/index.html.erb +3 -0
- data/app/views/hr_lite/admin/leave_requests/new.html.erb +35 -0
- data/app/views/hr_lite/admin/loans/index.html.erb +43 -0
- data/app/views/hr_lite/admin/loans/new.html.erb +39 -0
- data/app/views/hr_lite/admin/loans/show.html.erb +52 -0
- data/app/views/hr_lite/admin/policies/index.html.erb +27 -0
- data/app/views/hr_lite/admin/policies/new.html.erb +22 -0
- data/app/views/hr_lite/admin/tax_declarations/index.html.erb +37 -0
- data/app/views/hr_lite/admin/tax_declarations/show.html.erb +54 -0
- data/app/views/hr_lite/documents/index.html.erb +84 -0
- data/app/views/hr_lite/id_cards/show.html.erb +86 -0
- data/app/views/hr_lite/loans/index.html.erb +52 -0
- data/app/views/hr_lite/shared/_bell.html.erb +3 -0
- data/app/views/hr_lite/tax_declarations/show.html.erb +68 -0
- data/app/views/layouts/hr_lite/application.html.erb +2 -0
- data/config/routes.rb +21 -1
- data/db/migrate/20260927100145_add_id_card_fields_to_hr_lite_employee_profiles.rb +6 -0
- data/db/migrate/20260927101031_add_created_by_id_to_hr_lite_leave_requests.rb +5 -0
- data/lib/hr_lite/configuration.rb +4 -1
- data/lib/hr_lite/notifications.rb +4 -3
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +1 -0
- metadata +41 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The employee's own declaration for a financial year. Replaces an admin
|
|
3
|
+
# typing one lump sum into the profile: the whole year's TDS is projected
|
|
4
|
+
# from this, so the person whose money it is fills it in.
|
|
5
|
+
class TaxDeclarationsController < ApplicationController
|
|
6
|
+
def show
|
|
7
|
+
@declaration = current_declaration || build_declaration
|
|
8
|
+
@regime = @declaration.regime
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def update
|
|
12
|
+
# A bare record, not the form's scaffold: `build_declaration` fills in
|
|
13
|
+
# one blank line per section so the form is a list rather than an empty
|
|
14
|
+
# box, and saving those blanks fails a NOT NULL amount.
|
|
15
|
+
@declaration = current_declaration ||
|
|
16
|
+
TaxDeclaration.new(user_id: hr_current_user.id,
|
|
17
|
+
financial_year: financial_year, regime: employee_regime)
|
|
18
|
+
# A verified declaration is what payroll is deducting against; changing
|
|
19
|
+
# it would silently re-open a decision HR has already made.
|
|
20
|
+
if @declaration.verified?
|
|
21
|
+
return redirect_to tax_declaration_path,
|
|
22
|
+
alert: "This year's declaration is verified. Ask HR to reopen it."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if @declaration.update(declaration_params)
|
|
26
|
+
redirect_to tax_declaration_path, notice: "Saved."
|
|
27
|
+
else
|
|
28
|
+
# Re-render needs the full set of lines back, or the form loses the
|
|
29
|
+
# sections the person had not filled in yet.
|
|
30
|
+
fill_missing_sections(@declaration)
|
|
31
|
+
render :show, status: :unprocessable_entity
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def submit
|
|
36
|
+
@declaration = current_declaration
|
|
37
|
+
return redirect_to tax_declaration_path, alert: "Nothing to submit yet." if @declaration.nil?
|
|
38
|
+
|
|
39
|
+
@declaration.submit!(actor: hr_current_user)
|
|
40
|
+
redirect_to tax_declaration_path, notice: "Submitted — HR will check the proof."
|
|
41
|
+
rescue ActiveRecord::RecordInvalid
|
|
42
|
+
redirect_to tax_declaration_path, alert: "Only a draft can be submitted."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def financial_year
|
|
48
|
+
FinancialYear.start_for(params[:financial_year].present? ? Date.parse(params[:financial_year]) : Date.current)
|
|
49
|
+
rescue Date::Error
|
|
50
|
+
FinancialYear.start_for(Date.current)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def current_declaration
|
|
54
|
+
TaxDeclaration.find_by(user_id: hr_current_user.id, financial_year: financial_year)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_declaration
|
|
58
|
+
declaration = TaxDeclaration.new(user_id: hr_current_user.id, financial_year: financial_year,
|
|
59
|
+
regime: employee_regime)
|
|
60
|
+
# One blank line per section, so the form is a list of the things a
|
|
61
|
+
# person can actually claim rather than an empty box and a plus button.
|
|
62
|
+
fill_missing_sections(declaration)
|
|
63
|
+
declaration
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Every section gets a line, whether or not it has been claimed yet.
|
|
67
|
+
def fill_missing_sections(declaration)
|
|
68
|
+
present = declaration.tax_declaration_items.map(&:section)
|
|
69
|
+
(TaxDeclarationItem::SECTIONS - present).each do |section|
|
|
70
|
+
declaration.tax_declaration_items.build(section: section)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def employee_regime
|
|
75
|
+
EmployeeProfile.find_by(user_id: hr_current_user.id)&.tax_regime.presence || "new"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The form posts a line for every section, because that is what a list of
|
|
79
|
+
# claimable things looks like. Most of them are blank — a section with no
|
|
80
|
+
# amount is not a claim, and saving it violates a NOT NULL amount.
|
|
81
|
+
# An EXISTING line blanked out is a claim being withdrawn, so it is
|
|
82
|
+
# destroyed rather than skipped.
|
|
83
|
+
def declaration_params
|
|
84
|
+
permitted = params.require(:tax_declaration).permit(
|
|
85
|
+
:regime,
|
|
86
|
+
tax_declaration_items_attributes: %i[id section label declared_amount _destroy]
|
|
87
|
+
)
|
|
88
|
+
items = permitted[:tax_declaration_items_attributes]
|
|
89
|
+
return permitted if items.blank?
|
|
90
|
+
|
|
91
|
+
permitted[:tax_declaration_items_attributes] = items.to_h.filter_map { |key, row|
|
|
92
|
+
next [ key, row.merge("_destroy" => "1") ] if row["declared_amount"].blank? && row["id"].present?
|
|
93
|
+
next if row["declared_amount"].blank?
|
|
94
|
+
|
|
95
|
+
[ key, row ]
|
|
96
|
+
}.to_h
|
|
97
|
+
permitted
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -19,7 +19,11 @@ module HrLite
|
|
|
19
19
|
{ label: "Org", path: :org_path, match: [ "/org" ] },
|
|
20
20
|
{ label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
|
|
21
21
|
{ label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
|
|
22
|
-
{ label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] }
|
|
22
|
+
{ label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] },
|
|
23
|
+
{ label: "Documents", path: :documents_path, match: [ "/documents" ] },
|
|
24
|
+
{ label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
|
|
25
|
+
{ label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
|
|
26
|
+
{ label: "Loans", path: :loans_path, match: [ "/loans" ] }
|
|
23
27
|
].freeze
|
|
24
28
|
|
|
25
29
|
ADMIN_NAV_ITEMS = [
|
|
@@ -36,7 +40,9 @@ module HrLite
|
|
|
36
40
|
LEADERSHIP_NAV_ITEMS = [
|
|
37
41
|
{ label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
|
|
38
42
|
{ label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
|
|
39
|
-
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] }
|
|
43
|
+
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
|
|
44
|
+
{ label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
|
|
45
|
+
{ label: "Documents", path: :admin_documents_path, match: [ "/admin/documents" ] }
|
|
40
46
|
].freeze
|
|
41
47
|
|
|
42
48
|
# Money screens, plus role management: granting somebody payroll is the
|
|
@@ -45,9 +51,18 @@ module HrLite
|
|
|
45
51
|
SUPERADMIN_NAV_ITEMS = [
|
|
46
52
|
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
|
|
47
53
|
{ label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ] },
|
|
48
|
-
{ label: "Rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ] }
|
|
54
|
+
{ label: "Rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ] },
|
|
55
|
+
{ label: "Tax", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ] },
|
|
56
|
+
{ label: "Loans", path: :admin_loans_path, match: [ "/admin/loans" ] }
|
|
49
57
|
].freeze
|
|
50
58
|
|
|
59
|
+
# The ID card's QR: office address and phone as plain text, so any phone
|
|
60
|
+
# camera shows who to call about a found card. No URL, nothing to host.
|
|
61
|
+
def hrl_office_qr_svg(company, brand)
|
|
62
|
+
text = [ brand, company[:address], company[:phone] ].compact_blank.join("\n")
|
|
63
|
+
RQRCode::QRCode.new(text).as_svg(use_path: true, viewbox: true, color: "0F172A").html_safe
|
|
64
|
+
end
|
|
65
|
+
|
|
51
66
|
# Only items whose routes exist yet (the nav grows with each phase).
|
|
52
67
|
def hrl_nav_items(items)
|
|
53
68
|
items.select { |item| hr_lite_route?(item[:path]) }
|
|
@@ -4,7 +4,7 @@ module HrLite
|
|
|
4
4
|
class ApprovalFlow < ApplicationRecord
|
|
5
5
|
include Audited
|
|
6
6
|
|
|
7
|
-
has_many :approval_steps, -> { order(:position) },
|
|
7
|
+
has_many :approval_steps, -> { order(:position) }, inverse_of: :flow,
|
|
8
8
|
class_name: "HrLite::ApprovalStep", foreign_key: :flow_id, dependent: :destroy
|
|
9
9
|
accepts_nested_attributes_for :approval_steps, allow_destroy: true
|
|
10
10
|
|
|
@@ -13,7 +13,7 @@ module HrLite
|
|
|
13
13
|
# profiles as roots (founders/directors).
|
|
14
14
|
belongs_to :manager, class_name: HrLite.config.user_class, optional: true
|
|
15
15
|
|
|
16
|
-
encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc
|
|
16
|
+
encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc, :blood_group, :emergency_contact
|
|
17
17
|
# fy_opening_*: income already paid this financial year that this install
|
|
18
18
|
# did not run — a previous employer, or the months before payroll was
|
|
19
19
|
# switched on here. Feeds the TDS projection so a mid-year start does not
|
|
@@ -21,12 +21,21 @@ module HrLite
|
|
|
21
21
|
encrypted_money :declared_annual_deductions, :fy_opening_gross, :fy_opening_tds
|
|
22
22
|
|
|
23
23
|
TAX_REGIMES = %w[new old].freeze
|
|
24
|
+
BLOOD_GROUPS = %w[A+ A- B+ B- AB+ AB- O+ O-].freeze
|
|
25
|
+
# The ID-card photo. Images a browser can print; content-sniffed like
|
|
26
|
+
# Document, so the check is on the real bytes.
|
|
27
|
+
PHOTO_TYPES = %w[image/jpeg image/png image/webp].freeze
|
|
28
|
+
PHOTO_MAX_BYTES = 5.megabytes
|
|
29
|
+
|
|
30
|
+
has_one_attached :photo
|
|
24
31
|
|
|
25
32
|
# Onboarding-form virtuals (the controller hands them to
|
|
26
33
|
# config.onboard_user; never persisted, never audited).
|
|
27
34
|
attr_accessor :new_user_name, :new_user_email, :new_user_password
|
|
28
35
|
|
|
29
36
|
before_validation :assign_employee_code, on: :create
|
|
37
|
+
# Without a role a new hire cannot see policies or raise an HR request.
|
|
38
|
+
after_create { (role = Role.find_by(name: Role::EMPLOYEE)) && RoleAssignment.find_or_create_by!(user_id:, role:) }
|
|
30
39
|
|
|
31
40
|
validates :employee_code, presence: true, uniqueness: true
|
|
32
41
|
validates :user_id, uniqueness: true
|
|
@@ -37,6 +46,8 @@ module HrLite
|
|
|
37
46
|
validates :bank_ifsc, format: { with: /\A[A-Z]{4}0[A-Z0-9]{6}\z/, message: "is not a valid IFSC" },
|
|
38
47
|
allow_blank: true
|
|
39
48
|
validates :pf_uan, format: { with: /\A\d{12}\z/, message: "must be 12 digits" }, allow_blank: true
|
|
49
|
+
validates :blood_group, inclusion: { in: BLOOD_GROUPS }, allow_blank: true
|
|
50
|
+
validate :photo_is_a_small_image
|
|
40
51
|
validate :exit_after_joining
|
|
41
52
|
validate :manager_chain_acyclic
|
|
42
53
|
validate :manager_is_active_staff, if: :manager_id_changed?
|
|
@@ -97,7 +108,7 @@ module HrLite
|
|
|
97
108
|
private
|
|
98
109
|
|
|
99
110
|
# Codes are system-assigned: Settings prefix + zero-padded next number
|
|
100
|
-
# (
|
|
111
|
+
# (ESA-000001, ESA-000002, ...). Scans the highest existing suffix for the
|
|
101
112
|
# CURRENT prefix so changing the prefix restarts a fresh sequence
|
|
102
113
|
# without colliding with history.
|
|
103
114
|
def assign_employee_code
|
|
@@ -108,7 +119,7 @@ module HrLite
|
|
|
108
119
|
.pluck(:employee_code)
|
|
109
120
|
.filter_map { |code| code.delete_prefix(prefix)[/\A\d+\z/]&.to_i }
|
|
110
121
|
.max || 0
|
|
111
|
-
self.employee_code = format("%s%
|
|
122
|
+
self.employee_code = format("%s%06d", prefix, last + 1)
|
|
112
123
|
end
|
|
113
124
|
|
|
114
125
|
def sanitize_sql_like(value)
|
|
@@ -140,6 +151,13 @@ module HrLite
|
|
|
140
151
|
end
|
|
141
152
|
end
|
|
142
153
|
|
|
154
|
+
def photo_is_a_small_image
|
|
155
|
+
return unless photo.attached?
|
|
156
|
+
return errors.add(:photo, "must be a JPG, PNG or WebP image") unless PHOTO_TYPES.include?(photo.blob.content_type)
|
|
157
|
+
|
|
158
|
+
errors.add(:photo, "must be under #{PHOTO_MAX_BYTES / 1.megabyte} MB") if photo.blob.byte_size > PHOTO_MAX_BYTES
|
|
159
|
+
end
|
|
160
|
+
|
|
143
161
|
def mask_middle(value)
|
|
144
162
|
return nil if value.blank?
|
|
145
163
|
return "•" * value.length if value.length <= 4
|
|
@@ -8,7 +8,7 @@ module HrLite
|
|
|
8
8
|
|
|
9
9
|
CATEGORIES = %w[
|
|
10
10
|
salary_certificate employment_certificate address_change bank_change
|
|
11
|
-
document_request payroll_query tax_query insurance_query policy_query other
|
|
11
|
+
document_request payroll_query tax_query insurance_query policy_query id_card other
|
|
12
12
|
].freeze
|
|
13
13
|
|
|
14
14
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
@@ -17,6 +17,7 @@ module HrLite
|
|
|
17
17
|
validates :subject, presence: true
|
|
18
18
|
validates :category, inclusion: { in: CATEGORIES }
|
|
19
19
|
validates :status, inclusion: { in: STATUSES }
|
|
20
|
+
validate :id_card_can_be_printed, on: :create, if: -> { category == "id_card" }
|
|
20
21
|
|
|
21
22
|
scope :open_requests, -> { where(status: %w[open in_progress]) }
|
|
22
23
|
scope :recent_first, -> { order(created_at: :desc) }
|
|
@@ -25,7 +26,7 @@ module HrLite
|
|
|
25
26
|
|
|
26
27
|
after_create_commit :notify_desk
|
|
27
28
|
|
|
28
|
-
def category_label = category.humanize
|
|
29
|
+
def category_label = category.humanize.sub(/\AId\b/, "ID")
|
|
29
30
|
|
|
30
31
|
def assign!(actor:, assignee:)
|
|
31
32
|
update!(assigned_to_id: assignee.id, status: "in_progress")
|
|
@@ -68,8 +69,16 @@ module HrLite
|
|
|
68
69
|
Notifications.publish(
|
|
69
70
|
"hr_request.raised",
|
|
70
71
|
title: "#{HrLite.display_name(user)} asked: #{subject}",
|
|
71
|
-
body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk
|
|
72
|
+
body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk, email_to: desk
|
|
72
73
|
)
|
|
73
74
|
end
|
|
75
|
+
|
|
76
|
+
def id_card_can_be_printed
|
|
77
|
+
if !EmployeeProfile.find_by(user_id: user_id)&.photo&.attached?
|
|
78
|
+
errors.add(:base, "Add your photo to the ID card first")
|
|
79
|
+
elsif self.class.open_requests.exists?(user_id: user_id, category: "id_card")
|
|
80
|
+
errors.add(:base, "Your ID card print is already requested")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
74
83
|
end
|
|
75
84
|
end
|
|
@@ -7,6 +7,8 @@ module HrLite
|
|
|
7
7
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
8
8
|
belongs_to :leave_type
|
|
9
9
|
belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
|
|
10
|
+
# Set when HR records leave the employee forgot to apply for.
|
|
11
|
+
belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
|
|
10
12
|
|
|
11
13
|
validates :start_date, :end_date, presence: true
|
|
12
14
|
validates :status, inclusion: { in: STATUSES }
|
|
@@ -22,7 +24,7 @@ module HrLite
|
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
before_validation :cache_days_count, on: :create
|
|
25
|
-
after_create :notify_requested
|
|
27
|
+
after_create :notify_requested, unless: :recorded_by_hr?
|
|
26
28
|
|
|
27
29
|
scope :pending, -> { where(status: "pending") }
|
|
28
30
|
scope :approved, -> { where(status: "approved") }
|
|
@@ -209,13 +211,20 @@ module HrLite
|
|
|
209
211
|
LeaveDayCounter.count(self) > balance.available(as_of: start_date)
|
|
210
212
|
end
|
|
211
213
|
|
|
214
|
+
def recorded_by_hr? = created_by_id.present? && created_by_id != user_id
|
|
215
|
+
|
|
216
|
+
# Recorded leave is approved in the same transaction, so there is nothing
|
|
217
|
+
# left for a flow to route.
|
|
218
|
+
def open_approval_route = pending? && super
|
|
219
|
+
|
|
212
220
|
def notify_requested
|
|
213
221
|
Notifications.publish(
|
|
214
222
|
"leave.requested",
|
|
215
223
|
title: "#{HrLite.display_name(user)} applied for #{leave_type.name} (#{date_range_label})",
|
|
216
224
|
body: reason.presence,
|
|
217
225
|
path: "/admin/leave_requests/#{id}",
|
|
218
|
-
bell_to: HrLite.admin_users
|
|
226
|
+
bell_to: HrLite.admin_users,
|
|
227
|
+
email_to: HrLite.admin_users
|
|
219
228
|
)
|
|
220
229
|
end
|
|
221
230
|
|
|
@@ -224,6 +233,8 @@ module HrLite
|
|
|
224
233
|
# channel). Deliberately excludes the reason: dates are team-relevant,
|
|
225
234
|
# the why is not.
|
|
226
235
|
def notify_team
|
|
236
|
+
return if end_date < Date.current # nobody needs to hear who was out
|
|
237
|
+
|
|
227
238
|
team = HrLite.active_employees.reject { |member| member.id == user_id }
|
|
228
239
|
return if team.empty?
|
|
229
240
|
|
|
@@ -285,7 +296,7 @@ module HrLite
|
|
|
285
296
|
clash = self.class.where(user_id: user_id, status: %w[pending approved])
|
|
286
297
|
.where.not(id: id)
|
|
287
298
|
.overlapping_range(start_date, end_date)
|
|
288
|
-
errors.add(:base, "
|
|
299
|
+
errors.add(:base, "Leave already overlaps these dates") if clash.exists?
|
|
289
300
|
end
|
|
290
301
|
|
|
291
302
|
# A full-day leave over a day that already has a check-in makes no
|
|
@@ -296,7 +307,7 @@ module HrLite
|
|
|
296
307
|
|
|
297
308
|
punched = AttendanceRecord.where(user_id: user_id, date: start_date..end_date)
|
|
298
309
|
.where.not(check_in_at: nil)
|
|
299
|
-
errors.add(:base, "
|
|
310
|
+
errors.add(:base, "Attendance is marked in this period") if punched.exists?
|
|
300
311
|
end
|
|
301
312
|
|
|
302
313
|
def sufficient_balance
|
|
@@ -12,6 +12,8 @@ module HrLite
|
|
|
12
12
|
validates :effective_from, presence: true
|
|
13
13
|
validates :version, numericality: { greater_than: 0 }
|
|
14
14
|
|
|
15
|
+
after_create_commit :announce, if: :published?
|
|
16
|
+
|
|
15
17
|
scope :published, -> { where(published: true) }
|
|
16
18
|
scope :live_on, ->(date) { published.where(effective_from: ..date) }
|
|
17
19
|
scope :newest_first, -> { order(effective_from: :desc, version: :desc) }
|
|
@@ -34,6 +36,8 @@ module HrLite
|
|
|
34
36
|
)
|
|
35
37
|
end
|
|
36
38
|
|
|
39
|
+
def announcement? = !acknowledgement_required
|
|
40
|
+
|
|
37
41
|
def acknowledged_by?(user)
|
|
38
42
|
policy_acknowledgements.exists?(user_id: user.id)
|
|
39
43
|
end
|
|
@@ -55,5 +59,16 @@ module HrLite
|
|
|
55
59
|
rescue ActiveRecord::RecordNotUnique
|
|
56
60
|
policy_acknowledgements.find_by!(user_id: user.id)
|
|
57
61
|
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def announce
|
|
66
|
+
staff = HrLite.active_employees
|
|
67
|
+
Notifications.publish(
|
|
68
|
+
"policy.published",
|
|
69
|
+
title: "#{announcement? ? 'Announcement' : 'Policy'}: #{title}#{' (please read and acknowledge)' unless announcement?}",
|
|
70
|
+
body: body.truncate(300), path: "/policies/#{id}", bell_to: staff, email_to: staff
|
|
71
|
+
)
|
|
72
|
+
end
|
|
58
73
|
end
|
|
59
74
|
end
|
|
@@ -7,7 +7,7 @@ module HrLite
|
|
|
7
7
|
|
|
8
8
|
validates :weekend_policy, inclusion: { in: WEEKEND_POLICIES }
|
|
9
9
|
validates :employee_code_prefix, presence: true,
|
|
10
|
-
format: { with: /\A[A-Za-z]{1,10}
|
|
10
|
+
format: { with: /\A[A-Za-z]{1,10}-?\z/, message: "letters only (max 10), optionally ending in -" }
|
|
11
11
|
|
|
12
12
|
def self.instance
|
|
13
13
|
first_or_create!
|
|
@@ -11,8 +11,11 @@ module HrLite
|
|
|
11
11
|
STATUSES = %w[draft submitted verified rejected].freeze
|
|
12
12
|
REGIMES = %w[new old].freeze
|
|
13
13
|
|
|
14
|
+
# inverse_of is load-bearing, not decoration: the foreign key is not
|
|
15
|
+
# derived from the class name, so without it a nested item on an UNSAVED
|
|
16
|
+
# declaration cannot see its parent and fails "declaration must exist".
|
|
14
17
|
has_many :tax_declaration_items, -> { order(:section) },
|
|
15
|
-
class_name: "HrLite::TaxDeclarationItem",
|
|
18
|
+
class_name: "HrLite::TaxDeclarationItem", inverse_of: :declaration,
|
|
16
19
|
foreign_key: :declaration_id, dependent: :destroy
|
|
17
20
|
accepts_nested_attributes_for :tax_declaration_items, allow_destroy: true
|
|
18
21
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Documents" } %>
|
|
2
|
+
<div class="hrl-page__head"><h1 class="hrl-page__title">Documents</h1></div>
|
|
3
|
+
|
|
4
|
+
<section class="hrl-card">
|
|
5
|
+
<p class="hrl-muted hrl-small">
|
|
6
|
+
Seeing that a document exists and opening it are different things — a
|
|
7
|
+
passport is visible here, but the file itself only opens for whoever its
|
|
8
|
+
visibility allows.
|
|
9
|
+
</p>
|
|
10
|
+
<div class="hrl-row">
|
|
11
|
+
<a class="hrl-btn <%= "hrl-btn--primary" if @category.nil? %>"
|
|
12
|
+
href="<%= hr_lite.admin_documents_path %>">All</a>
|
|
13
|
+
<% @categories.each do |category| %>
|
|
14
|
+
<a class="hrl-btn <%= "hrl-btn--primary" if @category == category %>"
|
|
15
|
+
href="<%= hr_lite.admin_documents_path(category: category) %>"><%= category.humanize %></a>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
</section>
|
|
19
|
+
|
|
20
|
+
<section class="hrl-card">
|
|
21
|
+
<h2 class="hrl-card__title">Waiting to be checked (<%= @pending.size %>)</h2>
|
|
22
|
+
<% if @pending.empty? %>
|
|
23
|
+
<p class="hrl-muted">Nothing pending.</p>
|
|
24
|
+
<% else %>
|
|
25
|
+
<div class="hrl-table-wrap">
|
|
26
|
+
<table class="hrl-table hrl-table--stack">
|
|
27
|
+
<thead><tr><th>Person</th><th>Document</th><th>File</th><th></th></tr></thead>
|
|
28
|
+
<tbody>
|
|
29
|
+
<% @pending.each do |doc| %>
|
|
30
|
+
<tr>
|
|
31
|
+
<td data-label="Person"><%= hr_display_name(doc.user) %></td>
|
|
32
|
+
<td data-label="Document">
|
|
33
|
+
<%= doc.title %>
|
|
34
|
+
<div class="hrl-small hrl-muted"><%= doc.category.humanize %></div>
|
|
35
|
+
</td>
|
|
36
|
+
<td data-label="File">
|
|
37
|
+
<% if doc.readable_by?(hr_current_user) && doc.file.attached? %>
|
|
38
|
+
<a class="hrl-small" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
|
|
39
|
+
<% else %>
|
|
40
|
+
<span class="hrl-small hrl-muted">Not yours to open</span>
|
|
41
|
+
<% end %>
|
|
42
|
+
</td>
|
|
43
|
+
<td data-label="">
|
|
44
|
+
<%= button_to "Verify", hr_lite.verify_admin_document_path(doc),
|
|
45
|
+
method: :post, class: "hrl-btn hrl-btn--primary" %>
|
|
46
|
+
<%= form_with url: hr_lite.reject_admin_document_path(doc), method: :post, local: true do %>
|
|
47
|
+
<%= text_field_tag :note, nil, placeholder: "What is wrong with it" %>
|
|
48
|
+
<%= submit_tag "Reject", class: "hrl-btn" %>
|
|
49
|
+
<% end %>
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
<% end %>
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
56
|
+
<% end %>
|
|
57
|
+
</section>
|
|
58
|
+
|
|
59
|
+
<section class="hrl-card">
|
|
60
|
+
<h2 class="hrl-card__title">Everything on file</h2>
|
|
61
|
+
<div class="hrl-table-wrap">
|
|
62
|
+
<table class="hrl-table hrl-table--stack">
|
|
63
|
+
<thead><tr><th>Person</th><th>Document</th><th>Expires</th><th>Checked</th></tr></thead>
|
|
64
|
+
<tbody>
|
|
65
|
+
<% @documents.each do |doc| %>
|
|
66
|
+
<tr>
|
|
67
|
+
<td data-label="Person">
|
|
68
|
+
<a href="<%= hr_lite.admin_employee_documents_path(user_id: doc.user_id) %>">
|
|
69
|
+
<%= hr_display_name(doc.user) %>
|
|
70
|
+
</a>
|
|
71
|
+
</td>
|
|
72
|
+
<td data-label="Document"><%= doc.title %></td>
|
|
73
|
+
<td data-label="Expires">
|
|
74
|
+
<% if doc.expires_on %>
|
|
75
|
+
<span class="<%= "hrl-badge hrl-badge--bad" if doc.expired? %>">
|
|
76
|
+
<%= doc.expires_on.strftime("%d %b %Y") %>
|
|
77
|
+
</span>
|
|
78
|
+
<% else %>—<% end %>
|
|
79
|
+
</td>
|
|
80
|
+
<td data-label="Checked">
|
|
81
|
+
<%= hrl_request_status_badge(doc.verification == "verified" ? "approved" : doc.verification) %>
|
|
82
|
+
</td>
|
|
83
|
+
</tr>
|
|
84
|
+
<% end %>
|
|
85
|
+
</tbody>
|
|
86
|
+
</table>
|
|
87
|
+
</div>
|
|
88
|
+
<%= hrl_pagination %>
|
|
89
|
+
</section>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<% content_for(:page_title) { hr_display_name(@employee) } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= hr_display_name(@employee) %> — documents</h1>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<% if @missing.any? %>
|
|
7
|
+
<section class="hrl-card hrl-card--warn">
|
|
8
|
+
<h2 class="hrl-card__title">Not on file</h2>
|
|
9
|
+
<p><%= @missing.map(&:humanize).to_sentence %></p>
|
|
10
|
+
</section>
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<section class="hrl-card">
|
|
14
|
+
<% if @documents.empty? %>
|
|
15
|
+
<p class="hrl-muted">Nothing uploaded.</p>
|
|
16
|
+
<% else %>
|
|
17
|
+
<div class="hrl-table-wrap">
|
|
18
|
+
<table class="hrl-table hrl-table--stack">
|
|
19
|
+
<thead><tr><th>Document</th><th>Expires</th><th>Checked</th><th></th></tr></thead>
|
|
20
|
+
<tbody>
|
|
21
|
+
<% @documents.each do |doc| %>
|
|
22
|
+
<tr>
|
|
23
|
+
<td data-label="Document">
|
|
24
|
+
<strong><%= doc.title %></strong>
|
|
25
|
+
<div class="hrl-small hrl-muted"><%= doc.category.humanize %></div>
|
|
26
|
+
</td>
|
|
27
|
+
<td data-label="Expires"><%= doc.expires_on&.strftime("%d %b %Y") || "—" %></td>
|
|
28
|
+
<td data-label="Checked">
|
|
29
|
+
<%= hrl_request_status_badge(doc.verification == "verified" ? "approved" : doc.verification) %>
|
|
30
|
+
</td>
|
|
31
|
+
<td data-label="">
|
|
32
|
+
<% if doc.readable_by?(hr_current_user) && doc.file.attached? %>
|
|
33
|
+
<a class="hrl-small" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
|
|
34
|
+
<% end %>
|
|
35
|
+
</td>
|
|
36
|
+
</tr>
|
|
37
|
+
<% end %>
|
|
38
|
+
</tbody>
|
|
39
|
+
</table>
|
|
40
|
+
</div>
|
|
41
|
+
<% end %>
|
|
42
|
+
</section>
|
|
@@ -57,6 +57,11 @@
|
|
|
57
57
|
<div class="hrl-field"><%= f.label :work_location %><%= f.text_field :work_location %></div>
|
|
58
58
|
<div class="hrl-field"><%= f.label :date_of_birth %><%= f.date_field :date_of_birth %></div>
|
|
59
59
|
<div class="hrl-field"><%= f.label :date_of_joining %><%= f.date_field :date_of_joining %></div>
|
|
60
|
+
<div class="hrl-field">
|
|
61
|
+
<%= f.label :blood_group %>
|
|
62
|
+
<%= f.select :blood_group, HrLite::EmployeeProfile::BLOOD_GROUPS, include_blank: "—" %>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="hrl-field"><%= f.label :emergency_contact, "Emergency contact (printed on the ID card)" %><%= f.telephone_field :emergency_contact %></div>
|
|
60
65
|
<div class="hrl-field">
|
|
61
66
|
<%= f.label :date_of_exit, "Date of exit (leave blank while employed)" %>
|
|
62
67
|
<%= f.date_field :date_of_exit %>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<h1 class="hrl-page__title"><%= hr_display_name(@profile.user) %> <span class="hrl-muted">(<%= @profile.employee_code %>)</span></h1>
|
|
4
4
|
<div class="hrl-page__actions">
|
|
5
5
|
<a class="hrl-btn" href="<%= hr_lite.edit_admin_employee_path(@profile) %>">Edit</a>
|
|
6
|
+
<a class="hrl-btn" href="<%= hr_lite.id_card_path(user_id: @profile.user_id) %>">ID card</a>
|
|
6
7
|
<% if hr_superadmin? %>
|
|
7
8
|
<a class="hrl-btn" href="<%= hr_lite.new_admin_employee_appraisal_path(@profile) %>">New appraisal</a>
|
|
8
9
|
<a class="hrl-btn" href="<%= hr_lite.new_admin_employee_designation_change_path(@profile) %>">Role change</a>
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
<%= @request.created_at.to_date.strftime("%d %b %Y") %> ·
|
|
8
8
|
<%= hrl_request_status_badge(@request.status) %>
|
|
9
9
|
</p>
|
|
10
|
+
<% if @request.category == "id_card" %>
|
|
11
|
+
<a class="hrl-btn hrl-mt" href="<%= hr_lite.id_card_path(user_id: @request.user_id) %>">Open the ID card to print</a>
|
|
12
|
+
<% end %>
|
|
10
13
|
<% if @request.body.present? %>
|
|
11
14
|
<div class="hrl-prose"><%= simple_format(@request.body) %></div>
|
|
12
15
|
<% end %>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<% content_for(:page_title) { "Leave approvals" } %>
|
|
2
2
|
<div class="hrl-page__head">
|
|
3
3
|
<h1 class="hrl-page__title">Leave approvals</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn" href="<%= hr_lite.new_admin_leave_request_path %>">Record leave</a>
|
|
6
|
+
</div>
|
|
4
7
|
</div>
|
|
5
8
|
|
|
6
9
|
<%= render "hr_lite/admin/shared/approvals_tabs", active: :leaves %>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Record leave" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Record leave for an employee</h1>
|
|
4
|
+
<p class="hrl-page__sub">For leave somebody took but never applied for. It is saved as approved, and they get an email.</p>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<section class="hrl-card">
|
|
8
|
+
<%= form_with model: @request, url: hr_lite.admin_leave_requests_path, method: :post, local: true do |f| %>
|
|
9
|
+
<%= render "hr_lite/shared/form_errors", record: @request %>
|
|
10
|
+
<div class="hrl-field">
|
|
11
|
+
<%= label_tag :employee_code, "Employee (type a name or code)" %>
|
|
12
|
+
<%= text_field_tag :employee_code, params[:employee_code], list: "hrl-employees", required: true, autocomplete: "off" %>
|
|
13
|
+
<datalist id="hrl-employees">
|
|
14
|
+
<% @people.each do |p| %>
|
|
15
|
+
<option value="<%= p.employee_code %>"><%= hr_display_name(p.user) %></option>
|
|
16
|
+
<% end %>
|
|
17
|
+
</datalist>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="hrl-field">
|
|
20
|
+
<%= f.label :leave_type_id, "Type" %>
|
|
21
|
+
<%= f.collection_select :leave_type_id, HrLite::LeaveType.active, :id, :name %>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="hrl-field"><%= f.label :start_date, "From" %><%= f.date_field :start_date %></div>
|
|
24
|
+
<div class="hrl-field"><%= f.label :end_date, "To" %><%= f.date_field :end_date %></div>
|
|
25
|
+
<div class="hrl-field hrl-field--check">
|
|
26
|
+
<%= f.check_box :half_day %>
|
|
27
|
+
<%= f.label :half_day, "Half day (single-day only)" %>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="hrl-field"><%= f.label :reason, "Note (optional)" %><%= f.text_area :reason %></div>
|
|
30
|
+
<div class="hrl-form-actions">
|
|
31
|
+
<%= f.submit "Record leave", class: "hrl-btn hrl-btn--primary" %>
|
|
32
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_leave_requests_path %>">Cancel</a>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
35
|
+
</section>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Loans" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Loans & advances</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_loan_path %>">Record a loan</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<section class="hrl-card">
|
|
10
|
+
<p>Outstanding across every active loan: <strong><%= hrl_money(@outstanding) %></strong></p>
|
|
11
|
+
<div class="hrl-row">
|
|
12
|
+
<% HrLite::Loan::STATUSES.each do |status| %>
|
|
13
|
+
<a class="hrl-btn <%= "hrl-btn--primary" if @status == status %>"
|
|
14
|
+
href="<%= hr_lite.admin_loans_path(status: status) %>"><%= status.humanize %></a>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
</section>
|
|
18
|
+
|
|
19
|
+
<section class="hrl-card">
|
|
20
|
+
<% if @loans.empty? %>
|
|
21
|
+
<p class="hrl-muted">Nothing <%= @status %>.</p>
|
|
22
|
+
<% else %>
|
|
23
|
+
<div class="hrl-table-wrap">
|
|
24
|
+
<table class="hrl-table hrl-table--stack">
|
|
25
|
+
<thead><tr><th>Person</th><th>Principal</th><th>Instalment</th><th>Outstanding</th><th></th></tr></thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
<% @loans.each do |loan| %>
|
|
28
|
+
<tr>
|
|
29
|
+
<td data-label="Person"><%= hr_display_name(loan.user) %></td>
|
|
30
|
+
<td data-label="Principal" class="hrl-num"><%= hrl_money(loan.principal) %></td>
|
|
31
|
+
<td data-label="Instalment" class="hrl-num"><%= hrl_money(loan.monthly_instalment) %></td>
|
|
32
|
+
<td data-label="Outstanding" class="hrl-num"><%= hrl_money(loan.outstanding) %></td>
|
|
33
|
+
<td data-label="">
|
|
34
|
+
<a class="hrl-small" href="<%= hr_lite.admin_loan_path(loan) %>">Open</a>
|
|
35
|
+
</td>
|
|
36
|
+
</tr>
|
|
37
|
+
<% end %>
|
|
38
|
+
</tbody>
|
|
39
|
+
</table>
|
|
40
|
+
</div>
|
|
41
|
+
<% end %>
|
|
42
|
+
<%= hrl_pagination %>
|
|
43
|
+
</section>
|