hr_lite 0.4.0 → 0.5.2
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 +173 -1
- data/README.md +312 -99
- data/app/assets/javascripts/hr_lite/confirm.js +31 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +28 -0
- data/app/controllers/hr_lite/admin/appraisals_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/attendances_controller.rb +16 -1
- data/app/controllers/hr_lite/admin/audit_logs_controller.rb +4 -1
- data/app/controllers/hr_lite/admin/designation_changes_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/employees_controller.rb +37 -13
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +15 -8
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +16 -0
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/resignations_controller.rb +17 -5
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +18 -3
- data/app/controllers/hr_lite/admin/salary_structures_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +21 -0
- data/app/controllers/hr_lite/application_controller.rb +5 -1
- data/app/controllers/hr_lite/org_controller.rb +6 -1
- data/app/controllers/hr_lite/resignations_controller.rb +6 -1
- data/app/helpers/hr_lite/application_helper.rb +7 -13
- data/app/jobs/hr_lite/application_job.rb +5 -0
- data/app/jobs/hr_lite/daily_digest_job.rb +1 -1
- data/app/jobs/hr_lite/leave_year_rollover_job.rb +1 -1
- data/app/jobs/hr_lite/payroll_auto_draft_job.rb +5 -2
- data/app/models/concerns/hr_lite/audited.rb +11 -4
- data/app/models/hr_lite/appraisal.rb +9 -1
- data/app/models/hr_lite/audit_log.rb +11 -0
- data/app/models/hr_lite/comp_off_request.rb +5 -15
- data/app/models/hr_lite/designation_change.rb +18 -1
- data/app/models/hr_lite/employee_profile.rb +26 -1
- data/app/models/hr_lite/leave_balance.rb +51 -5
- data/app/models/hr_lite/leave_request.rb +9 -1
- data/app/models/hr_lite/leave_type.rb +13 -3
- data/app/models/hr_lite/payroll_run.rb +39 -10
- data/app/models/hr_lite/regularization_request.rb +8 -0
- data/app/models/hr_lite/resignation.rb +27 -0
- data/app/models/hr_lite/salary_slip.rb +7 -3
- data/app/models/hr_lite/setting.rb +2 -0
- data/app/services/hr_lite/attendance_puncher.rb +8 -1
- data/app/services/hr_lite/calculators/esi.rb +7 -4
- data/app/services/hr_lite/calculators/professional_tax.rb +5 -1
- data/app/services/hr_lite/calculators/tds.rb +8 -1
- data/app/services/hr_lite/leave_day_counter.rb +9 -4
- data/app/services/hr_lite/overview_query.rb +12 -1
- data/app/services/hr_lite/payroll_run_processor.rb +5 -0
- data/app/services/hr_lite/slip_builder.rb +54 -8
- data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
- data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +19 -5
- data/app/views/hr_lite/admin/employees/_form.html.erb +30 -3
- data/app/views/hr_lite/admin/employees/index.html.erb +3 -0
- data/app/views/hr_lite/admin/employees/show.html.erb +34 -18
- data/app/views/hr_lite/admin/leave_requests/show.html.erb +37 -5
- data/app/views/hr_lite/admin/overview/index.html.erb +21 -0
- data/app/views/hr_lite/admin/payroll_runs/show.html.erb +3 -1
- data/app/views/hr_lite/admin/regularization_requests/show.html.erb +18 -4
- data/app/views/hr_lite/admin/settings/edit.html.erb +7 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -2
- data/app/views/hr_lite/career/show.html.erb +7 -1
- data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -1
- data/app/views/hr_lite/salary_slips/pdf.html.erb +5 -5
- data/app/views/layouts/hr_lite/application.html.erb +47 -4
- data/config/routes.rb +1 -1
- data/db/migrate/20260720094454_add_employee_code_prefix_to_hr_lite_settings.rb +7 -0
- data/db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb +28 -0
- data/db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb +14 -0
- data/db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb +12 -0
- data/lib/hr_lite/amount_in_words.rb +15 -2
- data/lib/hr_lite/configuration.rb +8 -0
- data/lib/hr_lite/money.rb +18 -0
- data/lib/hr_lite/notifications.rb +23 -8
- data/lib/hr_lite/statutory_rate_card.rb +6 -1
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +9 -1
- metadata +7 -1
|
@@ -7,7 +7,9 @@ module HrLite
|
|
|
7
7
|
class EmployeesController < LeadershipController
|
|
8
8
|
def index
|
|
9
9
|
@profiles = paginate(EmployeeProfile.includes(:user).order(:employee_code))
|
|
10
|
-
|
|
10
|
+
# One pluck instead of an EXISTS query per employee.
|
|
11
|
+
profiled = EmployeeProfile.pluck(:user_id).to_set
|
|
12
|
+
@users_without_profile = HrLite.employees.reject { |u| profiled.include?(u.id) }
|
|
11
13
|
@pending_resignations = Resignation.pending.includes(:user).recent_first
|
|
12
14
|
end
|
|
13
15
|
|
|
@@ -24,17 +26,26 @@ module HrLite
|
|
|
24
26
|
def create
|
|
25
27
|
new_login = params.dig(:employee_profile, :new_user_email).present?
|
|
26
28
|
@profile = EmployeeProfile.new(profile_params)
|
|
29
|
+
saved = false
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
# One transaction: a profile that fails validation used to leave the
|
|
32
|
+
# freshly-created login committed — a role-bearing account with no HR
|
|
33
|
+
# profile, invisible on every HR screen.
|
|
34
|
+
ActiveRecord::Base.transaction do
|
|
35
|
+
if new_login
|
|
36
|
+
user = HrLite.config.onboard_user.call(
|
|
37
|
+
name: params.dig(:employee_profile, :new_user_name).to_s,
|
|
38
|
+
email: params.dig(:employee_profile, :new_user_email).to_s.strip,
|
|
39
|
+
password: params.dig(:employee_profile, :new_user_password).to_s
|
|
40
|
+
)
|
|
41
|
+
@profile.user_id = user.id
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
saved = @profile.save
|
|
45
|
+
raise ActiveRecord::Rollback unless saved
|
|
35
46
|
end
|
|
36
47
|
|
|
37
|
-
if
|
|
48
|
+
if saved
|
|
38
49
|
if new_login
|
|
39
50
|
invite_url = HrLite.config.invite_url_for&.call(@profile.user)
|
|
40
51
|
Notifications.publish(
|
|
@@ -66,7 +77,7 @@ module HrLite
|
|
|
66
77
|
|
|
67
78
|
def update
|
|
68
79
|
@profile = EmployeeProfile.find(params[:id])
|
|
69
|
-
if @profile.update(
|
|
80
|
+
if @profile.update(update_profile_params)
|
|
70
81
|
redirect_to admin_employee_path(@profile), notice: "Employee profile updated."
|
|
71
82
|
else
|
|
72
83
|
render :edit, status: :unprocessable_entity
|
|
@@ -77,7 +88,9 @@ module HrLite
|
|
|
77
88
|
# access via the host hook. Audited via the profile update.
|
|
78
89
|
def offboard
|
|
79
90
|
@profile = EmployeeProfile.find(params[:id])
|
|
80
|
-
|
|
91
|
+
# `.to_date` on a raw param raises Date::Error and 500s; the
|
|
92
|
+
# controller's own parser exists for exactly this.
|
|
93
|
+
exit_date = params[:date_of_exit].present? ? parse_date_param(params[:date_of_exit]) : Date.current
|
|
81
94
|
|
|
82
95
|
@profile.update!(date_of_exit: exit_date)
|
|
83
96
|
begin
|
|
@@ -87,17 +100,28 @@ module HrLite
|
|
|
87
100
|
end
|
|
88
101
|
redirect_to admin_employee_path(@profile),
|
|
89
102
|
notice: "Offboarded — last day #{exit_date.strftime('%d %b %Y')}, access revoked."
|
|
103
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
104
|
+
redirect_to admin_employee_path(@profile), alert: e.record.errors.full_messages.to_sentence
|
|
90
105
|
end
|
|
91
106
|
|
|
92
107
|
private
|
|
93
108
|
|
|
94
109
|
def profile_params
|
|
95
110
|
params.require(:employee_profile).permit(
|
|
96
|
-
:user_id, :manager_id, :
|
|
111
|
+
:user_id, :manager_id, :designation, :date_of_birth, :date_of_joining,
|
|
97
112
|
:date_of_exit, :department, :work_location, :pan_number, :pf_uan, :esi_number,
|
|
98
|
-
:bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions
|
|
113
|
+
:bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions,
|
|
114
|
+
:fy_opening_gross, :fy_opening_tds
|
|
99
115
|
)
|
|
100
116
|
end
|
|
117
|
+
|
|
118
|
+
# user_id says WHOSE profile this is, and salary structures, appraisals
|
|
119
|
+
# and slips all key off it independently. Repointing an existing profile
|
|
120
|
+
# hands one person's PAN, bank details and pay history to another, so it
|
|
121
|
+
# is a create-time field only.
|
|
122
|
+
def update_profile_params
|
|
123
|
+
profile_params.except(:user_id)
|
|
124
|
+
end
|
|
101
125
|
end
|
|
102
126
|
end
|
|
103
127
|
end
|
|
@@ -2,8 +2,7 @@ module HrLite
|
|
|
2
2
|
module Admin
|
|
3
3
|
class LeaveBalancesController < BaseController
|
|
4
4
|
def index
|
|
5
|
-
year =
|
|
6
|
-
@year = year.between?(2000, 2100) ? year : LeaveYear.current_key
|
|
5
|
+
@year = sanitized_year
|
|
7
6
|
@types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
|
|
8
7
|
@employees = HrLite.employees
|
|
9
8
|
end
|
|
@@ -12,7 +11,9 @@ module HrLite
|
|
|
12
11
|
def adjust
|
|
13
12
|
user = HrLite.user_klass.find(params[:user_id])
|
|
14
13
|
type = LeaveType.find(params[:leave_type_id])
|
|
15
|
-
|
|
14
|
+
# Unsanitised, a missing param wrote the adjustment into leave year 0,
|
|
15
|
+
# where no screen can ever show it.
|
|
16
|
+
year = sanitized_year
|
|
16
17
|
delta = BigDecimal(params[:delta].to_s)
|
|
17
18
|
note = params[:note].to_s.strip
|
|
18
19
|
|
|
@@ -20,10 +21,7 @@ module HrLite
|
|
|
20
21
|
return redirect_to admin_leave_balances_path(year: year), alert: "A note is required."
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
balance = LeaveBalance.
|
|
24
|
-
balance.adjustment += delta
|
|
25
|
-
balance.adjustment_note = [ balance.adjustment_note.presence, "#{delta.to_f} — #{note}" ].compact.join("; ")
|
|
26
|
-
balance.save!
|
|
24
|
+
balance = LeaveBalance.adjust!(user, type, year, delta: delta, note: "#{delta.to_f} — #{note}")
|
|
27
25
|
|
|
28
26
|
AuditLog.create!(
|
|
29
27
|
actor: hr_current_user, action: "adjust",
|
|
@@ -34,7 +32,16 @@ module HrLite
|
|
|
34
32
|
redirect_to admin_leave_balances_path(year: year),
|
|
35
33
|
notice: "Balance adjusted for #{HrLite.display_name(user)}."
|
|
36
34
|
rescue ArgumentError
|
|
37
|
-
|
|
35
|
+
# Keep the admin on the year they were looking at.
|
|
36
|
+
redirect_to admin_leave_balances_path(year: sanitized_year),
|
|
37
|
+
alert: "Enter a valid adjustment number."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def sanitized_year
|
|
43
|
+
year = params[:year].to_i
|
|
44
|
+
year.between?(2000, 2100) ? year : LeaveYear.current_key
|
|
38
45
|
end
|
|
39
46
|
end
|
|
40
47
|
end
|
|
@@ -24,6 +24,22 @@ module HrLite
|
|
|
24
24
|
redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# LeaveRequest#cancellable_by? has always allowed an admin to call off
|
|
28
|
+
# approved future leave, but no route reached it — the only cancel action
|
|
29
|
+
# was the employee's own, scoped to their own rows. A trip called off
|
|
30
|
+
# while the person was unreachable, or after they were offboarded, could
|
|
31
|
+
# not be released at all, and the quota stayed spent.
|
|
32
|
+
def cancel
|
|
33
|
+
request = LeaveRequest.find(params[:id])
|
|
34
|
+
if request.cancellable_by?(hr_current_user)
|
|
35
|
+
request.cancel!(actor: hr_current_user)
|
|
36
|
+
redirect_to admin_leave_requests_path, notice: "Leave cancelled — the balance is released."
|
|
37
|
+
else
|
|
38
|
+
redirect_to admin_leave_request_path(request),
|
|
39
|
+
alert: "Only pending leave, or approved leave that has not started, can be cancelled."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
27
43
|
def reject
|
|
28
44
|
request = LeaveRequest.find(params[:id])
|
|
29
45
|
note = params[:decision_note].to_s.strip
|
|
@@ -5,13 +5,25 @@ module HrLite
|
|
|
5
5
|
resignation = Resignation.find(params[:id])
|
|
6
6
|
resignation.accept!(
|
|
7
7
|
actor: hr_current_user,
|
|
8
|
-
|
|
8
|
+
# `.to_date` on a raw param raises Date::Error and 500s; the
|
|
9
|
+
# controller's own parser exists for exactly this.
|
|
10
|
+
last_day: params[:last_day].presence && parse_date_param(params[:last_day]),
|
|
9
11
|
note: params[:note]
|
|
10
12
|
)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
notice = if resignation.exit_date_recorded?
|
|
14
|
+
"Resignation accepted — exit date recorded on the profile."
|
|
15
|
+
else
|
|
16
|
+
"Resignation accepted. No employee profile exists for " \
|
|
17
|
+
"#{hr_display_name(resignation.user)}, so no exit date was recorded — " \
|
|
18
|
+
"create the profile to clip attendance and payroll."
|
|
19
|
+
end
|
|
20
|
+
redirect_to admin_employees_path, notice: notice
|
|
21
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
22
|
+
# A rejected exit date used to be reported as "not pending", which was
|
|
23
|
+
# simply the wrong reason: the transaction rolls back either way.
|
|
24
|
+
message = resignation.reload.pending? ? e.record.errors.full_messages.to_sentence
|
|
25
|
+
: "Only pending resignations can be accepted."
|
|
26
|
+
redirect_to admin_employees_path, alert: message
|
|
15
27
|
end
|
|
16
28
|
end
|
|
17
29
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
|
-
class SalarySlipsController <
|
|
3
|
+
class SalarySlipsController < SuperadminController
|
|
4
4
|
def show
|
|
5
5
|
@slip = SalarySlip.includes(:payroll_run).find(params[:id])
|
|
6
6
|
@profile = @slip.user_profile
|
|
@@ -20,11 +20,26 @@ module HrLite
|
|
|
20
20
|
tds_override = params.dig(:salary_slip, :tds_override).presence
|
|
21
21
|
profile = EmployeeProfile.find_by(user_id: slip.user_id)
|
|
22
22
|
structure = SalaryStructure.effective_for(slip.user, run.period_month)
|
|
23
|
+
if structure.nil?
|
|
24
|
+
return redirect_to admin_salary_slip_path(slip),
|
|
25
|
+
alert: "No salary structure is effective for #{run.label} — add one first."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
lop = lop_override && BigDecimal(lop_override)
|
|
29
|
+
tds = tds_override && BigDecimal(tds_override)
|
|
30
|
+
# Unbounded overrides are a money bug, not a typo: a negative LOP pays
|
|
31
|
+
# for days never worked, one above the month zeroes the person out.
|
|
32
|
+
if lop && !lop.between?(0, slip.days_in_month)
|
|
33
|
+
return redirect_to admin_salary_slip_path(slip),
|
|
34
|
+
alert: "LOP days must be between 0 and #{slip.days_in_month}."
|
|
35
|
+
end
|
|
36
|
+
if tds&.negative?
|
|
37
|
+
return redirect_to admin_salary_slip_path(slip), alert: "TDS cannot be negative."
|
|
38
|
+
end
|
|
23
39
|
|
|
24
40
|
attributes = SlipBuilder.call(
|
|
25
41
|
run: run, user: slip.user, structure: structure, profile: profile,
|
|
26
|
-
lop_override:
|
|
27
|
-
tds_override: tds_override && BigDecimal(tds_override)
|
|
42
|
+
lop_override: lop, tds_override: tds
|
|
28
43
|
)
|
|
29
44
|
slip.update!(attributes)
|
|
30
45
|
|
|
@@ -7,7 +7,7 @@ module HrLite
|
|
|
7
7
|
|
|
8
8
|
def update
|
|
9
9
|
@setting = Setting.instance
|
|
10
|
-
if @setting.update(params.require(:setting).permit(:weekend_policy))
|
|
10
|
+
if @setting.update(params.require(:setting).permit(:weekend_policy, :employee_code_prefix))
|
|
11
11
|
redirect_to edit_admin_setting_path, notice: "Settings saved."
|
|
12
12
|
else
|
|
13
13
|
render :edit, status: :unprocessable_entity
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Money tier: salary structures, payroll, slips, appraisals and
|
|
4
|
+
# promotions. Only config.superadmin_emails — ordinary leadership can
|
|
5
|
+
# govern policy and people but never sees another person's pay.
|
|
6
|
+
# The money tier stands on its own rather than layering on leadership: a
|
|
7
|
+
# payroll operator does not have to also govern people and policy.
|
|
8
|
+
# Inheriting the leadership check locked a configured superadmin who was
|
|
9
|
+
# absent from leadership_emails out of payroll entirely.
|
|
10
|
+
class SuperadminController < LeadershipController
|
|
11
|
+
skip_before_action :require_hr_leadership!
|
|
12
|
+
before_action :require_hr_superadmin!
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def require_hr_superadmin!
|
|
17
|
+
hr_access_denied unless hr_superadmin?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -10,7 +10,7 @@ module HrLite
|
|
|
10
10
|
before_action :hr_set_current_actor
|
|
11
11
|
around_action :hr_use_time_zone
|
|
12
12
|
|
|
13
|
-
helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_display_name
|
|
13
|
+
helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
@@ -26,6 +26,10 @@ module HrLite
|
|
|
26
26
|
HrLite.admin?(hr_current_user)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def hr_superadmin?
|
|
30
|
+
HrLite.superadmin?(hr_current_user)
|
|
31
|
+
end
|
|
32
|
+
|
|
29
33
|
def hr_leadership?
|
|
30
34
|
HrLite.leadership?(hr_current_user)
|
|
31
35
|
end
|
|
@@ -10,8 +10,13 @@ module HrLite
|
|
|
10
10
|
@by_user = profiles.index_by(&:user_id)
|
|
11
11
|
build_tree(profiles)
|
|
12
12
|
@own_profile = @by_user[hr_current_user.id]
|
|
13
|
+
# STOPS at the first exited manager rather than skipping past them. The
|
|
14
|
+
# L-labels are positional, so dropping a link from the middle promoted
|
|
15
|
+
# everyone above it — the card then called a skip-level manager your L1,
|
|
16
|
+
# contradicting the tree below, which drops exited managers entirely and
|
|
17
|
+
# renders their reports as roots.
|
|
13
18
|
exited = EmployeeProfile.where(date_of_exit: ...Date.current).pluck(:user_id).to_set
|
|
14
|
-
@own_chain = (@own_profile&.reporting_chain || []).
|
|
19
|
+
@own_chain = (@own_profile&.reporting_chain || []).take_while { |boss| !exited.include?(boss.id) }
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
private
|
|
@@ -4,7 +4,12 @@ module HrLite
|
|
|
4
4
|
class ResignationsController < ApplicationController
|
|
5
5
|
def show
|
|
6
6
|
@resignation = own.recent_first.first
|
|
7
|
-
|
|
7
|
+
# No form once one is pending OR already accepted: an agreed exit date is
|
|
8
|
+
# not something to re-file, and accepting a second one would overwrite
|
|
9
|
+
# the date payroll and attendance already clip to.
|
|
10
|
+
unless @resignation&.pending? || @resignation&.accepted?
|
|
11
|
+
@new_resignation = Resignation.new(proposed_last_day: Date.current + 30)
|
|
12
|
+
end
|
|
8
13
|
end
|
|
9
14
|
|
|
10
15
|
def create
|
|
@@ -20,11 +20,15 @@ module HrLite
|
|
|
20
20
|
|
|
21
21
|
LEADERSHIP_NAV_ITEMS = [
|
|
22
22
|
{ label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
|
|
23
|
-
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
|
|
24
23
|
{ label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
|
|
25
24
|
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] }
|
|
26
25
|
].freeze
|
|
27
26
|
|
|
27
|
+
# Money tier — only config.superadmin_emails see these.
|
|
28
|
+
SUPERADMIN_NAV_ITEMS = [
|
|
29
|
+
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] }
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
28
32
|
# Only items whose routes exist yet (the nav grows with each phase).
|
|
29
33
|
def hrl_nav_items(items)
|
|
30
34
|
items.select { |item| hr_lite_route?(item[:path]) }
|
|
@@ -67,19 +71,9 @@ module HrLite
|
|
|
67
71
|
render "hr_lite/shared/pagination"
|
|
68
72
|
end
|
|
69
73
|
|
|
74
|
+
# Delegates to the PORO so the host-rendered slip PDF formats identically.
|
|
70
75
|
def hrl_money(amount)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
whole, fraction = HrLite::Money.round2(amount).to_s("F").split(".")
|
|
74
|
-
sign = whole.start_with?("-") ? "-" : ""
|
|
75
|
-
whole = whole.delete_prefix("-")
|
|
76
|
-
# Indian digit grouping: last three digits together, then pairs
|
|
77
|
-
# (12,34,567 — not the western 1,234,567).
|
|
78
|
-
if whole.length > 3
|
|
79
|
-
head = whole[0..-4].reverse.scan(/\d{1,2}/).join(",").reverse
|
|
80
|
-
whole = "#{head},#{whole[-3..]}"
|
|
81
|
-
end
|
|
82
|
-
"#{sign}#{HrLite.config.currency_symbol}#{whole}.#{fraction.ljust(2, '0')}"
|
|
76
|
+
HrLite::Money.format(amount)
|
|
83
77
|
end
|
|
84
78
|
|
|
85
79
|
# Indian-system amount in words for the slip footer. Delegates to the
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
module HrLite
|
|
2
|
+
# Parent of the engine's jobs. It existed but nothing inherited from it, so
|
|
3
|
+
# no retry policy applied anywhere: a digest or a rollover that hit a
|
|
4
|
+
# dropped connection was simply lost for that day — or for that year.
|
|
2
5
|
class ApplicationJob < ActiveJob::Base
|
|
6
|
+
retry_on ActiveRecord::ConnectionNotEstablished, ActiveRecord::Deadlocked,
|
|
7
|
+
wait: :polynomially_longer, attempts: 5
|
|
3
8
|
end
|
|
4
9
|
end
|
|
@@ -4,7 +4,7 @@ module HrLite
|
|
|
4
4
|
# carry-forward into the new year's balance rows. Idempotent — a balance
|
|
5
5
|
# whose carry has already been written is never touched again (manual
|
|
6
6
|
# adjustments stay safe).
|
|
7
|
-
class LeaveYearRolloverJob <
|
|
7
|
+
class LeaveYearRolloverJob < ApplicationJob
|
|
8
8
|
queue_as :default
|
|
9
9
|
|
|
10
10
|
def perform(year: nil)
|
|
@@ -3,14 +3,17 @@ module HrLite
|
|
|
3
3
|
# payroll from attendance and the policy, then tell leadership it is
|
|
4
4
|
# waiting for review. Publishing stays a deliberate human action —
|
|
5
5
|
# the system prepares, people approve.
|
|
6
|
-
class PayrollAutoDraftJob <
|
|
6
|
+
class PayrollAutoDraftJob < ApplicationJob
|
|
7
7
|
queue_as :default
|
|
8
8
|
|
|
9
9
|
def perform(month: Date.current.prev_month.beginning_of_month)
|
|
10
10
|
return unless EmployeeProfile.active_for(month).exists?
|
|
11
11
|
|
|
12
12
|
run = PayrollRun.find_or_create_by!(period_month: month)
|
|
13
|
-
|
|
13
|
+
# Only ever drafts an UNTOUCHED run. Accepting `review` as well meant a
|
|
14
|
+
# re-run recomputed a month somebody was already reviewing and told
|
|
15
|
+
# leadership all over again that it was waiting for them.
|
|
16
|
+
return unless run.draft? && run.salary_slips.none?
|
|
14
17
|
|
|
15
18
|
run.compute!(actor: nil)
|
|
16
19
|
Notifications.publish(
|
|
@@ -9,10 +9,14 @@ module HrLite
|
|
|
9
9
|
SKIPPED_ATTRIBUTES = %w[updated_at created_at].freeze
|
|
10
10
|
REDACTED = "[changed]".freeze
|
|
11
11
|
|
|
12
|
+
# *_commit, not *_create/_update/_destroy: these callbacks write an audit
|
|
13
|
+
# row and email leadership a diff. Fired inside the transaction they
|
|
14
|
+
# announce changes that then roll back — a failed onboarding used to mail
|
|
15
|
+
# out a profile diff for a profile that was never saved.
|
|
12
16
|
included do
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
after_create_commit { hr_lite_audit!("create") }
|
|
18
|
+
after_update_commit { hr_lite_audit!("update") }
|
|
19
|
+
after_destroy_commit { hr_lite_audit!("destroy") }
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
private
|
|
@@ -63,7 +67,10 @@ module HrLite
|
|
|
63
67
|
"policy.changed",
|
|
64
68
|
title: "#{actor_name.presence || 'Someone'} #{log.action}d #{subject}",
|
|
65
69
|
body: nil,
|
|
66
|
-
|
|
70
|
+
# policy.changed fans out to leadership_emails, which is a WIDER list
|
|
71
|
+
# than the money tier. Leadership may know an appraisal changed; the
|
|
72
|
+
# ratings and review text inside it are not theirs to read.
|
|
73
|
+
diff: log.money_tier? ? nil : log.audited_changes
|
|
67
74
|
)
|
|
68
75
|
end
|
|
69
76
|
end
|
|
@@ -43,7 +43,15 @@ module HrLite
|
|
|
43
43
|
body: rating ? "Rating: #{rating}/5" : nil,
|
|
44
44
|
path: "/appraisals/#{id}",
|
|
45
45
|
bell_to: [ user ],
|
|
46
|
-
email_to: [ user ]
|
|
46
|
+
email_to: [ user ],
|
|
47
|
+
# Leadership gets the fact, named, pointing somewhere they can open —
|
|
48
|
+
# but not the rating: appraisal content is money-tier and this list is
|
|
49
|
+
# wider than that tier.
|
|
50
|
+
leadership: {
|
|
51
|
+
title: "#{HrLite.display_name(user)}'s appraisal for #{period_label} was shared",
|
|
52
|
+
body: nil,
|
|
53
|
+
path: "/admin/employees"
|
|
54
|
+
}
|
|
47
55
|
)
|
|
48
56
|
true
|
|
49
57
|
end
|
|
@@ -8,7 +8,18 @@ module HrLite
|
|
|
8
8
|
|
|
9
9
|
validates :action, :subject_type, :subject_id, presence: true
|
|
10
10
|
|
|
11
|
+
# Subjects whose CONTENTS belong to the money tier. Salary structures
|
|
12
|
+
# encrypt their amounts so the diff is already redacted, but appraisal
|
|
13
|
+
# ratings and review text are plain columns — they must not reach the
|
|
14
|
+
# leadership audit screen or the policy.changed email.
|
|
15
|
+
MONEY_TIER_TYPES = %w[HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure].freeze
|
|
16
|
+
|
|
11
17
|
scope :recent, -> { order(created_at: :desc) }
|
|
18
|
+
scope :outside_money_tier, -> { where.not(subject_type: MONEY_TIER_TYPES) }
|
|
19
|
+
|
|
20
|
+
def money_tier?
|
|
21
|
+
MONEY_TIER_TYPES.include?(subject_type)
|
|
22
|
+
end
|
|
12
23
|
|
|
13
24
|
def readonly?
|
|
14
25
|
persisted?
|
|
@@ -106,21 +106,11 @@ module HrLite
|
|
|
106
106
|
# balance. The balance row is locked for the increment; two admins
|
|
107
107
|
# approving two different requests for one person cannot lose an update.
|
|
108
108
|
def credit_balance!(type)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
rescue ActiveRecord::RecordNotUnique
|
|
115
|
-
balance = LeaveBalance.for(user, type, year)
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
balance.with_lock do
|
|
119
|
-
balance.adjustment += credit_days
|
|
120
|
-
balance.adjustment_note = [ balance.adjustment_note.presence,
|
|
121
|
-
"+#{credit_days.to_f} comp-off for #{date_worked} (request ##{id})" ].compact.join("; ")
|
|
122
|
-
balance.save!
|
|
123
|
-
end
|
|
109
|
+
LeaveBalance.adjust!(
|
|
110
|
+
user, type, LeaveYear.current_key,
|
|
111
|
+
delta: credit_days,
|
|
112
|
+
note: "+#{credit_days.to_f} comp-off for #{date_worked} (request ##{id})"
|
|
113
|
+
)
|
|
124
114
|
end
|
|
125
115
|
|
|
126
116
|
def transition!(new_status, actor, note)
|
|
@@ -27,7 +27,17 @@ module HrLite
|
|
|
27
27
|
self.from_designation ||= EmployeeProfile.find_by(user_id: user_id)&.designation
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Only the newest row owns the profile's designation. Backfilling an older
|
|
31
|
+
# promotion used to overwrite the current title and push the stale one to
|
|
32
|
+
# the host as well. (A future-dated promotion still applies on save — that
|
|
33
|
+
# is how sharing an appraisal announces the new role.)
|
|
34
|
+
def latest_by_effective_date?
|
|
35
|
+
self.class.where(user_id: user_id).timeline.first&.id == id
|
|
36
|
+
end
|
|
37
|
+
|
|
30
38
|
def apply_to_profile
|
|
39
|
+
return unless latest_by_effective_date?
|
|
40
|
+
|
|
31
41
|
profile = EmployeeProfile.find_by(user_id: user_id)
|
|
32
42
|
profile&.update!(designation: to_designation)
|
|
33
43
|
|
|
@@ -45,7 +55,14 @@ module HrLite
|
|
|
45
55
|
body: "Effective #{effective_date.strftime('%d %b %Y')}.",
|
|
46
56
|
path: "/career",
|
|
47
57
|
bell_to: [ user ],
|
|
48
|
-
email_to: [ user ]
|
|
58
|
+
email_to: [ user ],
|
|
59
|
+
# "/career" is the reader's OWN career page, so a leadership copy of
|
|
60
|
+
# this used to link them to their own record instead of the person the
|
|
61
|
+
# promotion is about.
|
|
62
|
+
leadership: {
|
|
63
|
+
title: "#{HrLite.display_name(user)} — new role: #{to_designation}",
|
|
64
|
+
path: "/admin/employees"
|
|
65
|
+
}
|
|
49
66
|
)
|
|
50
67
|
end
|
|
51
68
|
end
|
|
@@ -13,7 +13,11 @@ module HrLite
|
|
|
13
13
|
belongs_to :manager, class_name: HrLite.config.user_class, optional: true
|
|
14
14
|
|
|
15
15
|
encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc
|
|
16
|
-
|
|
16
|
+
# fy_opening_*: income already paid this financial year that this install
|
|
17
|
+
# did not run — a previous employer, or the months before payroll was
|
|
18
|
+
# switched on here. Feeds the TDS projection so a mid-year start does not
|
|
19
|
+
# read as zero income for the months it cannot see.
|
|
20
|
+
encrypted_money :declared_annual_deductions, :fy_opening_gross, :fy_opening_tds
|
|
17
21
|
|
|
18
22
|
TAX_REGIMES = %w[new old].freeze
|
|
19
23
|
|
|
@@ -21,6 +25,8 @@ module HrLite
|
|
|
21
25
|
# config.onboard_user; never persisted, never audited).
|
|
22
26
|
attr_accessor :new_user_name, :new_user_email, :new_user_password
|
|
23
27
|
|
|
28
|
+
before_validation :assign_employee_code, on: :create
|
|
29
|
+
|
|
24
30
|
validates :employee_code, presence: true, uniqueness: true
|
|
25
31
|
validates :user_id, uniqueness: true
|
|
26
32
|
validates :date_of_joining, presence: true
|
|
@@ -89,6 +95,25 @@ module HrLite
|
|
|
89
95
|
|
|
90
96
|
private
|
|
91
97
|
|
|
98
|
+
# Codes are system-assigned: Settings prefix + zero-padded next number
|
|
99
|
+
# (EMP001, EMP002, ...). Scans the highest existing suffix for the
|
|
100
|
+
# CURRENT prefix so changing the prefix restarts a fresh sequence
|
|
101
|
+
# without colliding with history.
|
|
102
|
+
def assign_employee_code
|
|
103
|
+
return if employee_code.present?
|
|
104
|
+
|
|
105
|
+
prefix = Setting.instance.employee_code_prefix
|
|
106
|
+
last = self.class.where("employee_code LIKE ?", "#{sanitize_sql_like(prefix)}%")
|
|
107
|
+
.pluck(:employee_code)
|
|
108
|
+
.filter_map { |code| code.delete_prefix(prefix)[/\A\d+\z/]&.to_i }
|
|
109
|
+
.max || 0
|
|
110
|
+
self.employee_code = format("%s%03d", prefix, last + 1)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def sanitize_sql_like(value)
|
|
114
|
+
self.class.sanitize_sql_like(value)
|
|
115
|
+
end
|
|
116
|
+
|
|
92
117
|
# A newly-assigned manager must be a real, still-employed staff member.
|
|
93
118
|
def manager_is_active_staff
|
|
94
119
|
return if manager_id.nil?
|