hr_lite 0.15.0 → 0.18.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 +111 -0
- data/README.md +5 -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/benefits_controller.rb +61 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/expense_categories_controller.rb +43 -0
- data/app/controllers/hr_lite/admin/holidays_controller.rb +7 -0
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -1
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +24 -1
- data/app/controllers/hr_lite/admin/payroll_line_items_controller.rb +47 -0
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +3 -1
- data/app/controllers/hr_lite/admin/policies_controller.rb +29 -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/leave_requests_controller.rb +3 -1
- data/app/controllers/hr_lite/salary_slips_controller.rb +6 -6
- data/app/helpers/hr_lite/application_helper.rb +21 -6
- data/app/jobs/hr_lite/payroll_auto_draft_job.rb +4 -3
- data/app/jobs/hr_lite/slips_ready_job.rb +10 -0
- data/app/models/hr_lite/audit_log.rb +1 -1
- data/app/models/hr_lite/benefit_enrolment.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_balance.rb +2 -1
- data/app/models/hr_lite/leave_request.rb +51 -19
- data/app/models/hr_lite/payroll_line_item.rb +5 -3
- data/app/models/hr_lite/payroll_run.rb +19 -3
- data/app/models/hr_lite/policy.rb +15 -0
- data/app/models/hr_lite/salary_slip.rb +6 -0
- data/app/models/hr_lite/setting.rb +1 -1
- data/app/services/hr_lite/attendance_summary.rb +12 -15
- data/app/views/hr_lite/admin/benefits/index.html.erb +65 -0
- data/app/views/hr_lite/admin/benefits/new.html.erb +37 -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/expense_categories/_form.html.erb +16 -0
- data/app/views/hr_lite/admin/expense_categories/edit.html.erb +3 -0
- data/app/views/hr_lite/admin/expense_categories/index.html.erb +30 -0
- data/app/views/hr_lite/admin/expense_categories/new.html.erb +3 -0
- data/app/views/hr_lite/admin/hr_requests/show.html.erb +3 -0
- data/app/views/hr_lite/admin/leave_balances/index.html.erb +4 -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/leave_requests/show.html.erb +3 -0
- data/app/views/hr_lite/admin/leave_types/index.html.erb +1 -0
- data/app/views/hr_lite/admin/payroll_line_items/index.html.erb +47 -0
- data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +42 -0
- data/app/views/hr_lite/admin/payroll_runs/show.html.erb +1 -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/salary_structures/_form.html.erb +5 -1
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +3 -1
- data/app/views/hr_lite/id_cards/show.html.erb +86 -0
- data/app/views/hr_lite/leave_requests/index.html.erb +1 -0
- data/app/views/hr_lite/leave_requests/new.html.erb +1 -0
- data/app/views/hr_lite/salary_slips/index.html.erb +1 -1
- data/app/views/hr_lite/shared/_bell.html.erb +3 -0
- data/app/views/layouts/hr_lite/application.html.erb +6 -4
- data/config/routes.rb +9 -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/db/migrate/20260929081109_add_paid_days_to_hr_lite_leave_requests.rb +5 -0
- data/lib/hr_lite/configuration.rb +15 -2
- data/lib/hr_lite/notifications.rb +10 -5
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +1 -0
- metadata +38 -1
|
@@ -11,7 +11,8 @@ module HrLite
|
|
|
11
11
|
# belongs in "More" rather than displacing something everybody uses.
|
|
12
12
|
# Employee tier all the same — holding an approval IS the authorisation,
|
|
13
13
|
# so a manager and a stand-in covering for one reach the same screen.
|
|
14
|
-
{ label: "Approvals", path: :approvals_path, match: [ "/approvals" ]
|
|
14
|
+
{ label: "Approvals", path: :approvals_path, match: [ "/approvals" ],
|
|
15
|
+
if: -> { HrLite::ApprovalFlow.where(active: true).exists? } },
|
|
15
16
|
{ label: "Expenses", path: :expenses_path, match: [ "/expenses" ] },
|
|
16
17
|
{ label: "Benefits", path: :benefits_path, match: [ "/benefits" ] },
|
|
17
18
|
{ label: "Policies", path: :policies_path, match: [ "/policies" ] },
|
|
@@ -19,8 +20,10 @@ module HrLite
|
|
|
19
20
|
{ label: "Org", path: :org_path, match: [ "/org" ] },
|
|
20
21
|
{ label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
|
|
21
22
|
{ label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
|
|
22
|
-
{ label: "Career", path: :career_path, match: [ "/career", "/appraisals"
|
|
23
|
+
{ label: "Career", path: :career_path, match: [ "/career", "/appraisals" ] },
|
|
24
|
+
{ label: "Profile", path: :employee_profile_path, match: [ "/profile", "/resignation" ] },
|
|
23
25
|
{ label: "Documents", path: :documents_path, match: [ "/documents" ] },
|
|
26
|
+
{ label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
|
|
24
27
|
{ label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
|
|
25
28
|
{ label: "Loans", path: :loans_path, match: [ "/loans" ] }
|
|
26
29
|
].freeze
|
|
@@ -30,16 +33,19 @@ module HrLite
|
|
|
30
33
|
{ label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
|
|
31
34
|
{ label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
|
|
32
35
|
{ label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] },
|
|
33
|
-
{ label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ]
|
|
36
|
+
{ label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ],
|
|
37
|
+
if: -> { hr_can?("expense.approve", scope: :team) } },
|
|
34
38
|
{ label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
|
|
35
39
|
{ label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
|
|
40
|
+
{ label: "Benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ] },
|
|
36
41
|
{ label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
|
|
37
42
|
].freeze
|
|
38
43
|
|
|
39
44
|
LEADERSHIP_NAV_ITEMS = [
|
|
40
45
|
{ label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
|
|
41
|
-
{ label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
|
|
46
|
+
{ label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting", "/admin/expense_categories" ] },
|
|
42
47
|
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
|
|
48
|
+
{ label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
|
|
43
49
|
{ label: "Documents", path: :admin_documents_path, match: [ "/admin/documents" ] }
|
|
44
50
|
].freeze
|
|
45
51
|
|
|
@@ -54,9 +60,18 @@ module HrLite
|
|
|
54
60
|
{ label: "Loans", path: :admin_loans_path, match: [ "/admin/loans" ] }
|
|
55
61
|
].freeze
|
|
56
62
|
|
|
57
|
-
#
|
|
63
|
+
# The ID card's QR: office address and phone as plain text, so any phone
|
|
64
|
+
# camera shows who to call about a found card. No URL, nothing to host.
|
|
65
|
+
def hrl_office_qr_svg(company, brand)
|
|
66
|
+
text = [ brand, company[:address], company[:phone] ].compact_blank.join("\n")
|
|
67
|
+
RQRCode::QRCode.new(text).as_svg(use_path: true, viewbox: true, color: "0F172A").html_safe
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Only items whose routes exist yet (the nav grows with each phase), and
|
|
71
|
+
# whose optional `if:` holds — a link that always lands on "access
|
|
72
|
+
# denied" or an empty page is worse than no link.
|
|
58
73
|
def hrl_nav_items(items)
|
|
59
|
-
items.select { |item| hr_lite_route?(item[:path]) }
|
|
74
|
+
items.select { |item| hr_lite_route?(item[:path]) && (item[:if].nil? || instance_exec(&item[:if])) }
|
|
60
75
|
end
|
|
61
76
|
|
|
62
77
|
def hr_lite_route?(helper_name)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
# Monthly automation: on the 1st, draft + compute the previous month's
|
|
3
|
-
# payroll from attendance and the policy, then tell
|
|
4
|
-
# waiting for review. Publishing stays a deliberate human action —
|
|
3
|
+
# payroll from attendance and the policy, then tell whoever runs payroll
|
|
4
|
+
# (payroll.manage) it is waiting for review — leadership cannot open it. Publishing stays a deliberate human action —
|
|
5
5
|
# the system prepares, people approve.
|
|
6
6
|
class PayrollAutoDraftJob < ApplicationJob
|
|
7
7
|
queue_as :default
|
|
@@ -16,10 +16,11 @@ module HrLite
|
|
|
16
16
|
return unless run.draft? && run.salary_slips.none?
|
|
17
17
|
|
|
18
18
|
run.compute!(actor: nil)
|
|
19
|
+
payroll = HrLite.users_holding("payroll.manage", scope: :all).to_a
|
|
19
20
|
Notifications.publish(
|
|
20
21
|
"payroll.draft_ready",
|
|
21
22
|
title: "Payroll #{run.label} computed from attendance — #{run.salary_slips.count} slips await review",
|
|
22
|
-
path: "/admin/payroll_runs/#{run.id}"
|
|
23
|
+
path: "/admin/payroll_runs/#{run.id}", bell_to: payroll, email_to: payroll
|
|
23
24
|
)
|
|
24
25
|
end
|
|
25
26
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Tells employees their slip is ready on config.slip_release_day, when
|
|
3
|
+
# SalarySlip.released first lets them open it. Enqueued by
|
|
4
|
+
# PayrollRun#publish!, which already sent the leadership copy.
|
|
5
|
+
class SlipsReadyJob < ApplicationJob
|
|
6
|
+
def perform(run)
|
|
7
|
+
run.notify_slips_ready(leadership: false)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -14,7 +14,7 @@ module HrLite
|
|
|
14
14
|
# leadership audit screen or the policy.changed email.
|
|
15
15
|
MONEY_TIER_TYPES = %w[
|
|
16
16
|
HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
|
|
17
|
-
HrLite::PayrollRun HrLite::SalarySlip
|
|
17
|
+
HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
20
|
scope :recent, -> { order(created_at: :desc) }
|
|
@@ -9,7 +9,7 @@ module HrLite
|
|
|
9
9
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
10
10
|
|
|
11
11
|
validates :enrolled_on, presence: true
|
|
12
|
-
validates :user_id, uniqueness: { scope: :benefit_id }
|
|
12
|
+
validates :user_id, uniqueness: { scope: :benefit_id, message: "is already enrolled" }
|
|
13
13
|
validates :dependants, numericality: { greater_than_or_equal_to: 0 }
|
|
14
14
|
validate :ended_after_it_started
|
|
15
15
|
|
|
@@ -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
|
|
@@ -63,7 +63,8 @@ module HrLite
|
|
|
63
63
|
# each — and the admin balances grid reads this cell for every employee
|
|
64
64
|
# times every leave type.
|
|
65
65
|
calendar = WorkingCalendar.new(range)
|
|
66
|
-
|
|
66
|
+
# Loss-of-pay days (beyond paid_days) never drew on the balance.
|
|
67
|
+
requests.sum { |request| [ LeaveDayCounter.count(request, calendar: calendar), request.paid_days ].compact.min }
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def available(as_of: Date.current)
|
|
@@ -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") }
|
|
@@ -38,6 +40,26 @@ module HrLite
|
|
|
38
40
|
leave_type.paid
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# Days this request would take past the balance — the loss of pay
|
|
44
|
+
# approving it today would fix, or did fix once approved. 0 for unlimited types.
|
|
45
|
+
def days_beyond_balance
|
|
46
|
+
return 0 if leave_type.unlimited?
|
|
47
|
+
return paid_days ? LeaveDayCounter.count(self) - paid_days : 0 if approved?
|
|
48
|
+
|
|
49
|
+
[ LeaveDayCounter.count(self) - covered_by(balance.available(as_of: start_date)), 0 ].max
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Unpaid share (0, 0.5 or 1) of `date`: the balance covered the leave's
|
|
53
|
+
# EARLIEST working days (paid_days of them), so the rest are loss of pay.
|
|
54
|
+
def unpaid_on(date)
|
|
55
|
+
return 0 if paid_days.nil?
|
|
56
|
+
|
|
57
|
+
@leave_calendar ||= WorkingCalendar.new(start_date..end_date)
|
|
58
|
+
through = LeaveDayCounter.count_range(start_date: start_date, end_date: date, half_day: half_day,
|
|
59
|
+
calendar: @leave_calendar)
|
|
60
|
+
(through - paid_days).clamp(0, half_day ? BigDecimal("0.5") : 1)
|
|
61
|
+
end
|
|
62
|
+
|
|
41
63
|
# --- transitions -------------------------------------------------------
|
|
42
64
|
|
|
43
65
|
# With a flow configured this is ONE RUNG: the request becomes `approved`
|
|
@@ -45,9 +67,10 @@ module HrLite
|
|
|
45
67
|
# the flow is not waiting on, such as HR overriding — the first approval
|
|
46
68
|
# settles it, exactly as before.
|
|
47
69
|
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
70
|
+
# Days beyond the balance are approved as loss of pay (paid_days); only
|
|
71
|
+
# comp-off returns false (still pending) when its credit no longer covers
|
|
72
|
+
# it. The balance is read inside the row lock, so two concurrent
|
|
73
|
+
# approvals cannot both spend it.
|
|
51
74
|
def approve!(actor:, note: nil)
|
|
52
75
|
return record_routed_decision!(actor, note, :approved) if awaiting?(actor)
|
|
53
76
|
|
|
@@ -130,15 +153,20 @@ module HrLite
|
|
|
130
153
|
def approve_outright!(actor:, note:)
|
|
131
154
|
insufficient = false
|
|
132
155
|
transition!("approved", actor, note) do
|
|
156
|
+
next if leave_type.unlimited?
|
|
157
|
+
|
|
133
158
|
# Serialize on the BALANCE row. `transition!`'s own lock is on this
|
|
134
159
|
# request, and two pending requests are two different rows — so both
|
|
135
160
|
# approvals could read the same untouched balance and overdraw it.
|
|
136
|
-
|
|
161
|
+
# As of start_date: the same date the create-time check uses.
|
|
162
|
+
covered = covered_by(LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)).available(as_of: start_date))
|
|
163
|
+
next if LeaveDayCounter.count(self) <= covered
|
|
137
164
|
|
|
138
|
-
if
|
|
165
|
+
if leave_type.comp_off
|
|
139
166
|
insufficient = true
|
|
140
167
|
raise ActiveRecord::Rollback
|
|
141
168
|
end
|
|
169
|
+
self.paid_days = covered
|
|
142
170
|
end
|
|
143
171
|
return false if insufficient
|
|
144
172
|
|
|
@@ -193,21 +221,20 @@ module HrLite
|
|
|
193
221
|
"type" => leave_type.code,
|
|
194
222
|
"dates" => date_range_label,
|
|
195
223
|
"days" => days_count&.to_s("F"),
|
|
224
|
+
"paid_days" => paid_days&.to_s("F"),
|
|
196
225
|
"note" => note.presence
|
|
197
226
|
}.compact
|
|
198
227
|
)
|
|
199
228
|
end
|
|
200
229
|
|
|
201
|
-
|
|
202
|
-
# approving is valid iff the request fits the remaining balance.
|
|
203
|
-
def insufficient_balance_now?
|
|
204
|
-
return false if leave_type.unlimited?
|
|
230
|
+
def recorded_by_hr? = created_by_id.present? && created_by_id != user_id
|
|
205
231
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
232
|
+
# The balance covers whole half-days only, never less than nothing.
|
|
233
|
+
def covered_by(available) = [ (BigDecimal(available.to_s) * 2).floor / BigDecimal(2), 0 ].max
|
|
234
|
+
|
|
235
|
+
# Recorded leave is approved in the same transaction, so there is nothing
|
|
236
|
+
# left for a flow to route.
|
|
237
|
+
def open_approval_route = pending? && super
|
|
211
238
|
|
|
212
239
|
def notify_requested
|
|
213
240
|
Notifications.publish(
|
|
@@ -215,7 +242,8 @@ module HrLite
|
|
|
215
242
|
title: "#{HrLite.display_name(user)} applied for #{leave_type.name} (#{date_range_label})",
|
|
216
243
|
body: reason.presence,
|
|
217
244
|
path: "/admin/leave_requests/#{id}",
|
|
218
|
-
bell_to: HrLite.admin_users
|
|
245
|
+
bell_to: HrLite.admin_users,
|
|
246
|
+
email_to: HrLite.admin_users
|
|
219
247
|
)
|
|
220
248
|
end
|
|
221
249
|
|
|
@@ -224,6 +252,8 @@ module HrLite
|
|
|
224
252
|
# channel). Deliberately excludes the reason: dates are team-relevant,
|
|
225
253
|
# the why is not.
|
|
226
254
|
def notify_team
|
|
255
|
+
return if end_date < Date.current # nobody needs to hear who was out
|
|
256
|
+
|
|
227
257
|
team = HrLite.active_employees.reject { |member| member.id == user_id }
|
|
228
258
|
return if team.empty?
|
|
229
259
|
|
|
@@ -285,7 +315,7 @@ module HrLite
|
|
|
285
315
|
clash = self.class.where(user_id: user_id, status: %w[pending approved])
|
|
286
316
|
.where.not(id: id)
|
|
287
317
|
.overlapping_range(start_date, end_date)
|
|
288
|
-
errors.add(:base, "
|
|
318
|
+
errors.add(:base, "Leave already overlaps these dates") if clash.exists?
|
|
289
319
|
end
|
|
290
320
|
|
|
291
321
|
# A full-day leave over a day that already has a check-in makes no
|
|
@@ -296,12 +326,14 @@ module HrLite
|
|
|
296
326
|
|
|
297
327
|
punched = AttendanceRecord.where(user_id: user_id, date: start_date..end_date)
|
|
298
328
|
.where.not(check_in_at: nil)
|
|
299
|
-
errors.add(:base, "
|
|
329
|
+
errors.add(:base, "Attendance is marked in this period") if punched.exists?
|
|
300
330
|
end
|
|
301
331
|
|
|
332
|
+
# Only comp-off is refused past its balance: it is earned credit, never
|
|
333
|
+
# borrowed. Any other type's excess becomes loss of pay at approval.
|
|
302
334
|
def sufficient_balance
|
|
303
335
|
return unless start_date && end_date && leave_type
|
|
304
|
-
return if leave_type.unlimited? || errors.any?
|
|
336
|
+
return if leave_type.unlimited? || !leave_type.comp_off || errors.any?
|
|
305
337
|
|
|
306
338
|
if LeaveDayCounter.count(self) > balance.available(as_of: start_date)
|
|
307
339
|
errors.add(:base, "Not enough #{leave_type.name} balance")
|
|
@@ -18,6 +18,8 @@ module HrLite
|
|
|
18
18
|
validate :period_is_first_of_month
|
|
19
19
|
validate :amount_is_positive
|
|
20
20
|
validate :period_is_not_already_paid, on: :create
|
|
21
|
+
# Removing a line from a paid month rewrites a settled payslip just the same.
|
|
22
|
+
before_destroy { throw :abort if paid_month? }
|
|
21
23
|
|
|
22
24
|
scope :for_month, ->(month) { where(period_month: month) }
|
|
23
25
|
|
|
@@ -47,11 +49,11 @@ module HrLite
|
|
|
47
49
|
# TDS projector reads.
|
|
48
50
|
def period_is_not_already_paid
|
|
49
51
|
return if user_id.nil? || period_month.nil?
|
|
50
|
-
|
|
51
|
-
settled = SalarySlip.settled.exists?(user_id: user_id, period_month: period_month)
|
|
52
|
-
return unless settled
|
|
52
|
+
return unless paid_month?
|
|
53
53
|
|
|
54
54
|
errors.add(:period_month, "has already been paid — put this on the next open month")
|
|
55
55
|
end
|
|
56
|
+
|
|
57
|
+
def paid_month? = SalarySlip.settled.exists?(user_id: user_id, period_month: period_month)
|
|
56
58
|
end
|
|
57
59
|
end
|
|
@@ -96,7 +96,23 @@ module HrLite
|
|
|
96
96
|
audit!("payroll.published", actor, "slips" => salary_slips.count)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
# config.slip_release_day holds the employee notice until the slip opens
|
|
100
|
+
# (SalarySlip.released); leadership still hears at publish.
|
|
101
|
+
day = HrLite.config.slip_release_day
|
|
102
|
+
release_at = day && period_month.next_month.change(day: day).in_time_zone(HrLite.config.time_zone)
|
|
103
|
+
if release_at&.future?
|
|
104
|
+
notify_slips_ready(employees: false)
|
|
105
|
+
SlipsReadyJob.set(wait_until: release_at).perform_later(self)
|
|
106
|
+
else
|
|
107
|
+
notify_slips_ready
|
|
108
|
+
end
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# "Your salary slip is ready". SlipsReadyJob sends the employee half on
|
|
113
|
+
# the release day, without repeating the leadership copy.
|
|
114
|
+
def notify_slips_ready(employees: true, leadership: true)
|
|
115
|
+
slips = employees ? salary_slips.includes(:user).to_a : []
|
|
100
116
|
# Everyone is emailed — a final settlement matters most to the person who
|
|
101
117
|
# has left — but a bell is only useful to someone who can still sign in,
|
|
102
118
|
# and offboarding revokes that. Sending one pointed at a page they cannot
|
|
@@ -111,9 +127,9 @@ module HrLite
|
|
|
111
127
|
body: "Open Earthly HR to view or download it.",
|
|
112
128
|
path: "/salary_slips",
|
|
113
129
|
bell_to: slips.reject { |slip| exited.include?(slip.user_id) }.map(&:user),
|
|
114
|
-
email_to: slips.map(&:user)
|
|
130
|
+
email_to: slips.map(&:user),
|
|
131
|
+
skip_leadership: !leadership
|
|
115
132
|
)
|
|
116
|
-
true
|
|
117
133
|
end
|
|
118
134
|
|
|
119
135
|
# Ruby-side aggregates (amounts are encrypted — no SQL sums).
|
|
@@ -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
|
|
@@ -18,6 +18,12 @@ module HrLite
|
|
|
18
18
|
before_save :ensure_run_editable
|
|
19
19
|
|
|
20
20
|
scope :published, -> { joins(:payroll_run).where(hr_lite_payroll_runs: { status: "published" }) }
|
|
21
|
+
# What an employee may open: published, and with config.slip_release_day
|
|
22
|
+
# only from that day of the next month (day 10: October's slip on 10 Nov).
|
|
23
|
+
scope :released, ->(on: Date.current) {
|
|
24
|
+
day = HrLite.config.slip_release_day
|
|
25
|
+
day ? published.where(period_month: ..(on - (day - 1)).beginning_of_month.prev_month) : published
|
|
26
|
+
}
|
|
21
27
|
# What counts as already paid for tax-to-date. A finalized run is money
|
|
22
28
|
# already decided; waiting for publication made the next month's TDS
|
|
23
29
|
# projection read those earnings as zero.
|
|
@@ -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!
|
|
@@ -2,7 +2,8 @@ module HrLite
|
|
|
2
2
|
# Month roll-up per user — the payroll contract. Exact math per date:
|
|
3
3
|
#
|
|
4
4
|
# holiday / weekend / present / paid full-day leave -> payable +1
|
|
5
|
-
# unpaid full-day leave (LWP
|
|
5
|
+
# unpaid full-day leave (LWP, or a paid-type day beyond the balance —
|
|
6
|
+
# LeaveRequest#unpaid_on) / absent -> lop +1
|
|
6
7
|
# half-day punch (no leave) -> payable +0.5, lop +0.5
|
|
7
8
|
# half-day PAID leave: leave half payable; other half payable if
|
|
8
9
|
# punched, else lop (unpaid half-day leave: leave half is lop too)
|
|
@@ -54,13 +55,11 @@ module HrLite
|
|
|
54
55
|
summary[:payable_days] += BigDecimal("0.5")
|
|
55
56
|
summary[:lop_days] += BigDecimal("0.5")
|
|
56
57
|
when :leave
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
summary[:lop_days] += 1
|
|
63
|
-
end
|
|
58
|
+
unpaid = day.leave.paid? ? day.leave.unpaid_on(date) : 1
|
|
59
|
+
summary[:paid_leave] += 1 - unpaid
|
|
60
|
+
summary[:payable_days] += 1 - unpaid
|
|
61
|
+
summary[:unpaid_leave] += unpaid
|
|
62
|
+
summary[:lop_days] += unpaid
|
|
64
63
|
when :half_day_leave
|
|
65
64
|
apply_half_day_leave(summary, day)
|
|
66
65
|
when :upcoming
|
|
@@ -77,13 +76,11 @@ module HrLite
|
|
|
77
76
|
def self.apply_half_day_leave(summary, day)
|
|
78
77
|
half = BigDecimal("0.5")
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
summary[:lop_days] += half
|
|
86
|
-
end
|
|
79
|
+
unpaid = day.leave.paid? ? day.leave.unpaid_on(day.leave.start_date) : half
|
|
80
|
+
summary[:paid_leave] += half - unpaid
|
|
81
|
+
summary[:payable_days] += half - unpaid
|
|
82
|
+
summary[:unpaid_leave] += unpaid
|
|
83
|
+
summary[:lop_days] += unpaid
|
|
87
84
|
|
|
88
85
|
if day.record&.check_in_at
|
|
89
86
|
summary[:present] += half
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Benefits" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Benefits</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_benefit_path %>">Add benefit</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<% if @benefits.none? %>
|
|
10
|
+
<section class="hrl-card"><p class="hrl-card--empty">No benefits yet — add one, then enrol people here.</p></section>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% people = HrLite.active_employees.map { |u| [ hr_display_name(u), u.id ] } %>
|
|
13
|
+
<% @benefits.each do |benefit| %>
|
|
14
|
+
<section class="hrl-card">
|
|
15
|
+
<h2 class="hrl-card__title"><%= benefit.name %></h2>
|
|
16
|
+
<p class="hrl-small hrl-muted">
|
|
17
|
+
<%= benefit.kind.humanize %><%= " · #{benefit.provider}" if benefit.provider.present? %>
|
|
18
|
+
· cover <%= hrl_money(benefit.coverage) %>
|
|
19
|
+
</p>
|
|
20
|
+
<%= form_with url: hr_lite.enrol_admin_benefit_path(benefit), method: :post, local: true do %>
|
|
21
|
+
<div class="hrl-field">
|
|
22
|
+
<%= label_tag "enrol_user_#{benefit.id}", "Employee" %>
|
|
23
|
+
<%= select_tag :user_id, options_for_select(people), id: "enrol_user_#{benefit.id}" %>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="hrl-field">
|
|
26
|
+
<%= label_tag "enrol_on_#{benefit.id}", "Covered from" %>
|
|
27
|
+
<%= date_field_tag :enrolled_on, Date.current, id: "enrol_on_#{benefit.id}" %>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="hrl-field">
|
|
30
|
+
<%= label_tag "enrol_dependants_#{benefit.id}", "Dependants covered" %>
|
|
31
|
+
<%= number_field_tag :dependants, 0, min: 0, id: "enrol_dependants_#{benefit.id}" %>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="hrl-form-actions"><%= submit_tag "Enrol", class: "hrl-btn hrl-btn--primary" %></div>
|
|
34
|
+
<% end %>
|
|
35
|
+
</section>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<% if @enrolments.any? %>
|
|
39
|
+
<section class="hrl-card">
|
|
40
|
+
<h2 class="hrl-card__title">Enrolled</h2>
|
|
41
|
+
<div class="hrl-table-wrap">
|
|
42
|
+
<table class="hrl-table hrl-table--stack">
|
|
43
|
+
<thead><tr><th>Who</th><th>Benefit</th><th>Since</th><th>Dependants</th><th></th></tr></thead>
|
|
44
|
+
<tbody>
|
|
45
|
+
<% @enrolments.each do |enrolment| %>
|
|
46
|
+
<tr>
|
|
47
|
+
<td data-label="Who"><%= hr_display_name(enrolment.user) %></td>
|
|
48
|
+
<td data-label="Benefit"><%= enrolment.benefit.name %></td>
|
|
49
|
+
<td data-label="Since"><%= enrolment.enrolled_on.strftime("%d %b %Y") %></td>
|
|
50
|
+
<td data-label="Dependants" class="hrl-num"><%= enrolment.dependants %></td>
|
|
51
|
+
<td data-label="">
|
|
52
|
+
<%= form_with url: hr_lite.unenrol_admin_benefit_path(enrolment.benefit), method: :post do %>
|
|
53
|
+
<%= hidden_field_tag :user_id, enrolment.user_id %>
|
|
54
|
+
<%= date_field_tag :ended_on, Date.current, class: "hrl-input", "aria-label": "Last day of cover" %>
|
|
55
|
+
<%= submit_tag "End cover", class: "hrl-btn" %>
|
|
56
|
+
<% end %>
|
|
57
|
+
</td>
|
|
58
|
+
</tr>
|
|
59
|
+
<% end %>
|
|
60
|
+
</tbody>
|
|
61
|
+
</table>
|
|
62
|
+
</div>
|
|
63
|
+
<%= hrl_pagination %>
|
|
64
|
+
</section>
|
|
65
|
+
<% end %>
|