hr_lite 0.16.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 +63 -0
- data/README.md +1 -0
- data/app/controllers/hr_lite/admin/benefits_controller.rb +61 -0
- data/app/controllers/hr_lite/admin/expense_categories_controller.rb +43 -0
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -1
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +3 -3
- 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/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 +12 -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/leave_balance.rb +2 -1
- data/app/models/hr_lite/leave_request.rb +38 -17
- 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/salary_slip.rb +6 -0
- 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/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/leave_balances/index.html.erb +4 -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/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/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/layouts/hr_lite/application.html.erb +4 -4
- data/config/routes.rb +5 -0
- data/db/migrate/20260929081109_add_paid_days_to_hr_lite_leave_requests.rb +5 -0
- data/lib/hr_lite/configuration.rb +11 -1
- data/lib/hr_lite/notifications.rb +6 -2
- data/lib/hr_lite/version.rb +1 -1
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c73e1512e67fbae1abb7b12f6134564974819e2055c937e3fabcb294a196bea9
|
|
4
|
+
data.tar.gz: 93bd305c7aa148e6e2f034f9355cc15078ddf8252cf0af92f938da136b945ac1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f4c2331de1a65bbfb074277c9a309b1276c213a55747fd8f8446c5d54c72a41edfb3087a950bfd19aff5fc00ed2b96130e20d200583e9e8970dc0edd10750806
|
|
7
|
+
data.tar.gz: 25103c4830b09f09e13996557e926dfb4bde694fb0cb60a8f10d8adb355ba4f25d553b226ec38f039df395a339a832c4822ecd2a4aaa5ebbf5b6f111a957e88c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.18.0] - 2026-09-29
|
|
11
|
+
|
|
12
|
+
Salary follows leave. One migration (`paid_days` on leave requests, nullable;
|
|
13
|
+
existing rows read as fully paid).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Leave beyond the balance is loss of pay, not refused.** On approval —
|
|
18
|
+
applied by the employee or recorded by HR — the balance covers the leave's
|
|
19
|
+
earliest working days in half-day steps (`paid_days`), and payroll cuts the
|
|
20
|
+
rest at the month's gross divided by its calendar days. The balance is never
|
|
21
|
+
overdrawn by it, so next month's accrual is not swallowed. Each unpaid day is
|
|
22
|
+
cut in its own month when a leave spans two. The split is fixed at approval;
|
|
23
|
+
correct it with the slip's LOP override while the run is in review.
|
|
24
|
+
- Comp-off is still refused past its earned credit.
|
|
25
|
+
- The monthly draft alert on the 1st goes to whoever runs payroll
|
|
26
|
+
(`payroll.manage`), with email, instead of leadership — who cannot open it.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- The employee is told on applying how many days will be unpaid; the approver
|
|
31
|
+
sees it on the request before approving.
|
|
32
|
+
- `config.slip_release_day` (1..28): employees see a published slip from that
|
|
33
|
+
day of the following month, and the "slip ready" bell and email wait until
|
|
34
|
+
then. `nil` (default) keeps today's behaviour. Needs an ActiveJob backend
|
|
35
|
+
that runs scheduled jobs.
|
|
36
|
+
|
|
37
|
+
## [0.17.0] - 2026-09-29
|
|
38
|
+
|
|
39
|
+
Screens for the things other screens were waiting on. An audit of every HR
|
|
40
|
+
screen found four that could never show anything, because the rows they list
|
|
41
|
+
could only be created from a console. No migration.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **Expense categories** (Settings → Expense categories, leadership). Until
|
|
46
|
+
now nothing created one, so the claim form had no categories and every
|
|
47
|
+
expense claim failed with "Category must exist". Add or edit name, monthly
|
|
48
|
+
cap, receipt rule and active; untick Active to retire one (no delete —
|
|
49
|
+
claims point at it).
|
|
50
|
+
- **One-off pay items** (Payroll → a run → One-off pay items, money tier): a
|
|
51
|
+
bonus, incentive, arrears, reimbursement or one-time deduction for a person
|
|
52
|
+
and month, carried by that month's computed slip. A month already paid
|
|
53
|
+
refuses both adding and removing an item; the loan repayment line stays
|
|
54
|
+
with the loan. Items are money-tier in the audit trail, so the leadership
|
|
55
|
+
email no longer carries the amount.
|
|
56
|
+
- **Benefits admin** (Manage → Benefits, `benefit.manage`): add a benefit,
|
|
57
|
+
enrol somebody with their dependants, end their cover. Employees' Benefits
|
|
58
|
+
page was always empty before.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **A manager could adjust anybody's leave balance.** The adjust action never
|
|
63
|
+
asked whose balance it was. It now needs `leave.manage` reaching that person;
|
|
64
|
+
the balances grid lists only people the viewer can see, and the adjust form
|
|
65
|
+
shows only to someone who holds `leave.manage`.
|
|
66
|
+
- The professional-tax state picker on the salary structure form had no
|
|
67
|
+
options.
|
|
68
|
+
- Sidebar links that led nowhere: the employee Approvals inbox shows only once
|
|
69
|
+
an approval flow exists; Claims shows to whoever approves claims (Finance
|
|
70
|
+
sees it, HR no longer lands on access denied); Profile is linked, and with it
|
|
71
|
+
Resignation; both leave balance pages are linked.
|
|
72
|
+
|
|
10
73
|
## [0.16.0] - 2026-09-27
|
|
11
74
|
|
|
12
75
|
Employee ID cards, print requests through Ask HR, recording leave for
|
data/README.md
CHANGED
|
@@ -141,6 +141,7 @@ HrLite.configure do |c|
|
|
|
141
141
|
c.render_pdf = ->(template:, assigns:, cache_key:) {
|
|
142
142
|
PdfRenderer.render(template: template, assigns: assigns, cache_key: cache_key)
|
|
143
143
|
}
|
|
144
|
+
c.slip_release_day = 10 # employees see a slip from the 10th of the next month; nil (default) = on publish
|
|
144
145
|
|
|
145
146
|
# Mirror promotions into your own user model (optional).
|
|
146
147
|
c.on_designation_change = ->(user, designation) { user.update!(designation: designation) }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Who is covered by what. Before this screen nothing created a benefit,
|
|
4
|
+
# so every employee's /benefits page said "not enrolled".
|
|
5
|
+
class BenefitsController < BaseController
|
|
6
|
+
skip_before_action :require_operations_access!
|
|
7
|
+
before_action :require_benefits!
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
@benefits = Benefit.order(:name)
|
|
11
|
+
@enrolments = paginate(covered.includes(:benefit, :user).order(:enrolled_on))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def new
|
|
15
|
+
@benefit = Benefit.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
@benefit = Benefit.new(benefit_params)
|
|
20
|
+
if @benefit.save
|
|
21
|
+
redirect_to admin_benefits_path, notice: "#{@benefit.name} added."
|
|
22
|
+
else
|
|
23
|
+
render :new, status: :unprocessable_entity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def enrol
|
|
28
|
+
benefit = Benefit.find(params[:id])
|
|
29
|
+
user = HrLite.user_klass.find(params[:user_id])
|
|
30
|
+
benefit.benefit_enrolments.create!(user_id: user.id, enrolled_on: parse_date_param(params[:enrolled_on]),
|
|
31
|
+
dependants: params[:dependants].presence || 0)
|
|
32
|
+
redirect_to admin_benefits_path, notice: "#{HrLite.display_name(user)} enrolled in #{benefit.name}."
|
|
33
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
34
|
+
redirect_to admin_benefits_path, alert: e.record.errors.full_messages.to_sentence
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Ends cover rather than deleting the row: who was covered when is
|
|
38
|
+
# the record an insurance claim gets checked against.
|
|
39
|
+
def unenrol
|
|
40
|
+
enrolment = covered.where(benefit_id: params[:id]).find_by!(user_id: params[:user_id])
|
|
41
|
+
enrolment.update!(ended_on: parse_date_param(params[:ended_on]))
|
|
42
|
+
redirect_to admin_benefits_path, notice: "Cover ended for #{HrLite.display_name(enrolment.user)}."
|
|
43
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
44
|
+
redirect_to admin_benefits_path, alert: e.record.errors.full_messages.to_sentence
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def require_benefits! = hr_require_permission!("benefit.manage", scope: :all)
|
|
50
|
+
|
|
51
|
+
# Still covered today — including cover that ends on a future date, which
|
|
52
|
+
# HR must still be able to see and change.
|
|
53
|
+
def covered = BenefitEnrolment.where("ended_on IS NULL OR ended_on >= ?", Date.current)
|
|
54
|
+
|
|
55
|
+
def benefit_params
|
|
56
|
+
params.require(:benefit).permit(:name, :kind, :provider, :policy_number, :coverage, :employer_premium,
|
|
57
|
+
:employee_premium, :effective_from, :expires_on, :notes)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# No destroy: claims hold the category by foreign key. Retire one by
|
|
4
|
+
# unticking Active, which takes it off the claim form.
|
|
5
|
+
class ExpenseCategoriesController < LeadershipController
|
|
6
|
+
def index
|
|
7
|
+
@expense_categories = ExpenseCategory.alphabetical
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def new
|
|
11
|
+
@expense_category = ExpenseCategory.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
@expense_category = ExpenseCategory.new(expense_category_params)
|
|
16
|
+
if @expense_category.save
|
|
17
|
+
redirect_to admin_expense_categories_path, notice: "Category added."
|
|
18
|
+
else
|
|
19
|
+
render :new, status: :unprocessable_entity
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def edit
|
|
24
|
+
@expense_category = ExpenseCategory.find(params[:id])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def update
|
|
28
|
+
@expense_category = ExpenseCategory.find(params[:id])
|
|
29
|
+
if @expense_category.update(expense_category_params)
|
|
30
|
+
redirect_to admin_expense_categories_path, notice: "Category updated."
|
|
31
|
+
else
|
|
32
|
+
render :edit, status: :unprocessable_entity
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def expense_category_params
|
|
39
|
+
params.require(:expense_category).permit(:name, :monthly_cap, :receipt_required, :active)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -4,12 +4,15 @@ module HrLite
|
|
|
4
4
|
def index
|
|
5
5
|
@year = sanitized_year
|
|
6
6
|
@types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
|
|
7
|
-
@employees = HrLite.employees
|
|
7
|
+
@employees = HrLite.employees.select { |employee| hr_reaches?("leave.view", employee) }
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
# Manual credit/debit — also the comp-off credit mechanism.
|
|
11
11
|
def adjust
|
|
12
12
|
user = HrLite.user_klass.find(params[:user_id])
|
|
13
|
+
# A manager reaches this screen through leave.approve; changing
|
|
14
|
+
# somebody's quota is leave.manage, for THAT person.
|
|
15
|
+
hr_require_reach!("leave.manage", user)
|
|
13
16
|
type = LeaveType.find(params[:leave_type_id])
|
|
14
17
|
# Unsanitised, a missing param wrote the adjustment into leave year 0,
|
|
15
18
|
# where no screen can ever show it.
|
|
@@ -17,7 +17,7 @@ module HrLite
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# HR records leave somebody forgot to apply for. Approved on save, by the
|
|
20
|
-
# person recording it; the balance
|
|
20
|
+
# person recording it; days beyond the balance become loss of pay.
|
|
21
21
|
def new
|
|
22
22
|
@request = LeaveRequest.new(start_date: Date.current, end_date: Date.current)
|
|
23
23
|
@people = recordable
|
|
@@ -32,7 +32,7 @@ module HrLite
|
|
|
32
32
|
end
|
|
33
33
|
return redirect_to(admin_leave_request_path(@request), notice: "Leave recorded and approved.") if recorded
|
|
34
34
|
|
|
35
|
-
@request.errors.add(:base, profile ? "Not enough
|
|
35
|
+
@request.errors.add(:base, profile ? "Not enough comp-off credit" : "Pick an employee from the list") if @request.errors.empty?
|
|
36
36
|
@people = recordable
|
|
37
37
|
render :new, status: :unprocessable_entity
|
|
38
38
|
end
|
|
@@ -43,7 +43,7 @@ module HrLite
|
|
|
43
43
|
redirect_to admin_leave_requests_path, notice: "Leave approved."
|
|
44
44
|
else
|
|
45
45
|
redirect_to admin_leave_request_path(request),
|
|
46
|
-
alert: "Cannot approve —
|
|
46
|
+
alert: "Cannot approve — comp-off credit no longer covers this request."
|
|
47
47
|
end
|
|
48
48
|
rescue ActiveRecord::RecordInvalid
|
|
49
49
|
redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# One-off payslip lines: a bonus, an incentive, arrears, a reimbursement,
|
|
4
|
+
# a one-time deduction. Money tier, like loans — this is somebody's pay.
|
|
5
|
+
# Create and destroy are audited by PayrollLineItem's Audited concern.
|
|
6
|
+
class PayrollLineItemsController < SuperadminController
|
|
7
|
+
def index
|
|
8
|
+
@month = parse_month_param(params[:month])
|
|
9
|
+
@items = paginate(PayrollLineItem.for_month(@month).includes(:user, :component).order(:created_at))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def new
|
|
13
|
+
@item = PayrollLineItem.new(period_month: parse_month_param(params[:month]))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create
|
|
17
|
+
@item = PayrollLineItem.new(item_params.merge(
|
|
18
|
+
period_month: parse_month_param(params.dig(:payroll_line_item, :period_month)),
|
|
19
|
+
created_by_id: hr_current_user.id,
|
|
20
|
+
# Same list the form offers: loan repayments come from the loan itself.
|
|
21
|
+
component: addable_components.find_by(id: params.dig(:payroll_line_item, :component_id))
|
|
22
|
+
))
|
|
23
|
+
if @item.save
|
|
24
|
+
redirect_to admin_payroll_line_items_path(month: @item.period_month.strftime("%Y-%m")),
|
|
25
|
+
notice: "#{@item.component.label} added for #{HrLite.display_name(@item.user)}."
|
|
26
|
+
else
|
|
27
|
+
render :new, status: :unprocessable_entity
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def destroy
|
|
32
|
+
item = PayrollLineItem.find(params[:id])
|
|
33
|
+
removed = item.destroy
|
|
34
|
+
redirect_to admin_payroll_line_items_path(month: item.period_month.strftime("%Y-%m")), status: :see_other,
|
|
35
|
+
**(removed ? { notice: "Removed." } : { alert: "That month has already been paid — it can't change now." })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def item_params
|
|
41
|
+
params.require(:payroll_line_item).permit(:user_id, :amount, :note)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def addable_components = SalaryComponent.active.where.not(code: "loan_repayment")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -48,7 +48,9 @@ module HrLite
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def publish
|
|
51
|
-
|
|
51
|
+
day = HrLite.config.slip_release_day
|
|
52
|
+
opens = day && "Published — employees can open it, and are notified, from the #{day.ordinalize} of next month."
|
|
53
|
+
transition { |run| run.publish!(actor: hr_current_user) && (opens || "Published — employees notified.") }
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
# Payout register: the full money sheet including bank details —
|
|
@@ -19,7 +19,9 @@ module HrLite
|
|
|
19
19
|
def create
|
|
20
20
|
@request = LeaveRequest.new(request_params.merge(user_id: hr_current_user.id))
|
|
21
21
|
if @request.save
|
|
22
|
-
|
|
22
|
+
# Say it now, not on the payslip: days past the balance are unpaid.
|
|
23
|
+
short = @request.days_beyond_balance
|
|
24
|
+
redirect_to leave_requests_path, notice: "Leave request submitted.#{" #{short.to_s('F')} day(s) are beyond your balance and will be unpaid if approved." if short.positive?}"
|
|
23
25
|
else
|
|
24
26
|
@balances = balance_cards
|
|
25
27
|
render :new, status: :unprocessable_entity
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
module HrLite
|
|
2
|
-
# Employee surface: strictly own
|
|
3
|
-
#
|
|
2
|
+
# Employee surface: strictly own RELEASED slips — a foreign, unpublished
|
|
3
|
+
# or not-yet-released id 404s through the scoped relation, never 403s.
|
|
4
4
|
class SalarySlipsController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
@slips = paginate(
|
|
6
|
+
@slips = paginate(own_released.recent_first)
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
|
-
@slip =
|
|
10
|
+
@slip = own_released.find(params[:id])
|
|
11
11
|
|
|
12
12
|
respond_to do |format|
|
|
13
13
|
format.html
|
|
@@ -17,8 +17,8 @@ module HrLite
|
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
|
-
def
|
|
21
|
-
SalarySlip.
|
|
20
|
+
def own_released
|
|
21
|
+
SalarySlip.released.where(user_id: hr_current_user.id)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def send_slip_pdf(slip)
|
|
@@ -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,7 +20,8 @@ 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" ] },
|
|
24
26
|
{ label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
|
|
25
27
|
{ label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
|
|
@@ -31,15 +33,17 @@ module HrLite
|
|
|
31
33
|
{ label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
|
|
32
34
|
{ label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
|
|
33
35
|
{ label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] },
|
|
34
|
-
{ 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) } },
|
|
35
38
|
{ label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
|
|
36
39
|
{ label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
|
|
40
|
+
{ label: "Benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ] },
|
|
37
41
|
{ label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
|
|
38
42
|
].freeze
|
|
39
43
|
|
|
40
44
|
LEADERSHIP_NAV_ITEMS = [
|
|
41
45
|
{ label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
|
|
42
|
-
{ 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" ] },
|
|
43
47
|
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
|
|
44
48
|
{ label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
|
|
45
49
|
{ label: "Documents", path: :admin_documents_path, match: [ "/admin/documents" ] }
|
|
@@ -63,9 +67,11 @@ module HrLite
|
|
|
63
67
|
RQRCode::QRCode.new(text).as_svg(use_path: true, viewbox: true, color: "0F172A").html_safe
|
|
64
68
|
end
|
|
65
69
|
|
|
66
|
-
# Only items whose routes exist yet (the nav grows with each phase)
|
|
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.
|
|
67
73
|
def hrl_nav_items(items)
|
|
68
|
-
items.select { |item| hr_lite_route?(item[:path]) }
|
|
74
|
+
items.select { |item| hr_lite_route?(item[:path]) && (item[:if].nil? || instance_exec(&item[:if])) }
|
|
69
75
|
end
|
|
70
76
|
|
|
71
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
|
|
|
@@ -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)
|
|
@@ -40,6 +40,26 @@ module HrLite
|
|
|
40
40
|
leave_type.paid
|
|
41
41
|
end
|
|
42
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
|
+
|
|
43
63
|
# --- transitions -------------------------------------------------------
|
|
44
64
|
|
|
45
65
|
# With a flow configured this is ONE RUNG: the request becomes `approved`
|
|
@@ -47,9 +67,10 @@ module HrLite
|
|
|
47
67
|
# the flow is not waiting on, such as HR overriding — the first approval
|
|
48
68
|
# settles it, exactly as before.
|
|
49
69
|
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
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.
|
|
53
74
|
def approve!(actor:, note: nil)
|
|
54
75
|
return record_routed_decision!(actor, note, :approved) if awaiting?(actor)
|
|
55
76
|
|
|
@@ -132,15 +153,20 @@ module HrLite
|
|
|
132
153
|
def approve_outright!(actor:, note:)
|
|
133
154
|
insufficient = false
|
|
134
155
|
transition!("approved", actor, note) do
|
|
156
|
+
next if leave_type.unlimited?
|
|
157
|
+
|
|
135
158
|
# Serialize on the BALANCE row. `transition!`'s own lock is on this
|
|
136
159
|
# request, and two pending requests are two different rows — so both
|
|
137
160
|
# approvals could read the same untouched balance and overdraw it.
|
|
138
|
-
|
|
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
|
|
139
164
|
|
|
140
|
-
if
|
|
165
|
+
if leave_type.comp_off
|
|
141
166
|
insufficient = true
|
|
142
167
|
raise ActiveRecord::Rollback
|
|
143
168
|
end
|
|
169
|
+
self.paid_days = covered
|
|
144
170
|
end
|
|
145
171
|
return false if insufficient
|
|
146
172
|
|
|
@@ -195,24 +221,17 @@ module HrLite
|
|
|
195
221
|
"type" => leave_type.code,
|
|
196
222
|
"dates" => date_range_label,
|
|
197
223
|
"days" => days_count&.to_s("F"),
|
|
224
|
+
"paid_days" => paid_days&.to_s("F"),
|
|
198
225
|
"note" => note.presence
|
|
199
226
|
}.compact
|
|
200
227
|
)
|
|
201
228
|
end
|
|
202
229
|
|
|
203
|
-
# This request is still pending here, so balance.used excludes it:
|
|
204
|
-
# approving is valid iff the request fits the remaining balance.
|
|
205
|
-
def insufficient_balance_now?
|
|
206
|
-
return false if leave_type.unlimited?
|
|
207
|
-
|
|
208
|
-
# Same as-of date the create-time check used. Reading it as of TODAY
|
|
209
|
-
# meant a request validated against the balance it would have on the
|
|
210
|
-
# leave dates could never be approved on a monthly-accrual type.
|
|
211
|
-
LeaveDayCounter.count(self) > balance.available(as_of: start_date)
|
|
212
|
-
end
|
|
213
|
-
|
|
214
230
|
def recorded_by_hr? = created_by_id.present? && created_by_id != user_id
|
|
215
231
|
|
|
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
|
+
|
|
216
235
|
# Recorded leave is approved in the same transaction, so there is nothing
|
|
217
236
|
# left for a flow to route.
|
|
218
237
|
def open_approval_route = pending? && super
|
|
@@ -310,9 +329,11 @@ module HrLite
|
|
|
310
329
|
errors.add(:base, "Attendance is marked in this period") if punched.exists?
|
|
311
330
|
end
|
|
312
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.
|
|
313
334
|
def sufficient_balance
|
|
314
335
|
return unless start_date && end_date && leave_type
|
|
315
|
-
return if leave_type.unlimited? || errors.any?
|
|
336
|
+
return if leave_type.unlimited? || !leave_type.comp_off || errors.any?
|
|
316
337
|
|
|
317
338
|
if LeaveDayCounter.count(self) > balance.available(as_of: start_date)
|
|
318
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).
|
|
@@ -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.
|