hr_lite 0.5.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 +157 -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/attendances_controller.rb +16 -1
- data/app/controllers/hr_lite/admin/audit_logs_controller.rb +4 -1
- data/app/controllers/hr_lite/admin/employees_controller.rb +36 -12
- 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/resignations_controller.rb +17 -5
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +17 -2
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +5 -0
- 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 +2 -12
- 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 +5 -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/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 +20 -2
- data/app/views/hr_lite/admin/employees/show.html.erb +25 -15
- 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/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 +42 -4
- data/config/routes.rb +1 -1
- 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/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 +5 -1
- metadata +5 -1
|
@@ -13,6 +13,8 @@ module HrLite
|
|
|
13
13
|
validates :period_month, presence: true, uniqueness: true
|
|
14
14
|
validates :status, inclusion: { in: STATUSES }
|
|
15
15
|
validate :period_is_first_of_month
|
|
16
|
+
# Create-only: an existing run must stay transitionable forever.
|
|
17
|
+
validate :period_is_a_closed_month, on: :create
|
|
16
18
|
before_destroy :draft_only_destroy
|
|
17
19
|
|
|
18
20
|
scope :recent_first, -> { order(period_month: :desc) }
|
|
@@ -27,16 +29,25 @@ module HrLite
|
|
|
27
29
|
period_month.strftime("%B %Y")
|
|
28
30
|
end
|
|
29
31
|
|
|
32
|
+
# `processing` is allowed back in so a run stranded there by a killed
|
|
33
|
+
# process (deploy roll, timeout) can be recomputed instead of blocking
|
|
34
|
+
# that month forever.
|
|
30
35
|
def compute!(actor:)
|
|
31
|
-
raise_unless %w[draft review]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
raise_unless %w[draft review processing]
|
|
37
|
+
previous = status
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
update!(status: "processing")
|
|
41
|
+
PayrollRunProcessor.call(self)
|
|
42
|
+
update!(status: "review", processed_at: Time.current)
|
|
43
|
+
true
|
|
44
|
+
rescue => e
|
|
45
|
+
# Restore what the run WAS. Hardcoding "draft" here used to demote a
|
|
46
|
+
# finalized or published run — which then exposed the delete-draft
|
|
47
|
+
# control, and that cascades over every salary slip.
|
|
48
|
+
update_columns(status: previous) # rubocop:disable Rails/SkipsModelValidations
|
|
49
|
+
raise e
|
|
50
|
+
end
|
|
40
51
|
end
|
|
41
52
|
|
|
42
53
|
def finalize!(actor:)
|
|
@@ -63,12 +74,20 @@ module HrLite
|
|
|
63
74
|
update!(status: "published", published_at: Time.current, published_by_id: actor.id)
|
|
64
75
|
|
|
65
76
|
slips = salary_slips.includes(:user).to_a
|
|
77
|
+
# Everyone is emailed — a final settlement matters most to the person who
|
|
78
|
+
# has left — but a bell is only useful to someone who can still sign in,
|
|
79
|
+
# and offboarding revokes that. Sending one pointed at a page they cannot
|
|
80
|
+
# open is just noise in a place they will never look.
|
|
81
|
+
exited = EmployeeProfile.where(user_id: slips.map(&:user_id))
|
|
82
|
+
.where(date_of_exit: ..Date.current)
|
|
83
|
+
.pluck(:user_id).to_set
|
|
84
|
+
|
|
66
85
|
Notifications.publish(
|
|
67
86
|
"payroll.published",
|
|
68
87
|
title: "Your salary slip for #{label} is ready",
|
|
69
88
|
body: "Open Earthly HR to view or download it.",
|
|
70
89
|
path: "/salary_slips",
|
|
71
|
-
bell_to: slips.map(&:user),
|
|
90
|
+
bell_to: slips.reject { |slip| exited.include?(slip.user_id) }.map(&:user),
|
|
72
91
|
email_to: slips.map(&:user)
|
|
73
92
|
)
|
|
74
93
|
true
|
|
@@ -102,6 +121,16 @@ module HrLite
|
|
|
102
121
|
errors.add(:period_month, "must be the 1st of a month")
|
|
103
122
|
end
|
|
104
123
|
|
|
124
|
+
# Days that have not happened yet score as `upcoming`, which payroll folds
|
|
125
|
+
# into payable — so computing a month before it ends pays for days nobody
|
|
126
|
+
# worked, and once finalized the slips are immutable.
|
|
127
|
+
def period_is_a_closed_month
|
|
128
|
+
return unless period_month
|
|
129
|
+
return if period_month.end_of_month < Date.current
|
|
130
|
+
|
|
131
|
+
errors.add(:period_month, "must be a month that has already ended")
|
|
132
|
+
end
|
|
133
|
+
|
|
105
134
|
def draft_only_destroy
|
|
106
135
|
return if draft?
|
|
107
136
|
|
|
@@ -49,6 +49,14 @@ module HrLite
|
|
|
49
49
|
# InvalidMerge with the real story so the admin knows what to fix.
|
|
50
50
|
def approve!(actor:, note: nil)
|
|
51
51
|
transition!("approved", actor, note) do
|
|
52
|
+
# Re-checked at approval, not only at create: leave can be approved for
|
|
53
|
+
# that day while the ticket waits in the queue, and DayStatus ranks
|
|
54
|
+
# full-day leave above any punch — so the "fix" would write a record
|
|
55
|
+
# nothing reads while telling everyone attendance had been corrected.
|
|
56
|
+
if LeaveRequest.active_on(date).where(user_id: user_id, half_day: false).exists?
|
|
57
|
+
raise InvalidMerge, "the day is now covered by approved leave — cancel the leave first"
|
|
58
|
+
end
|
|
59
|
+
|
|
52
60
|
record = AttendanceRecord.find_or_initialize_by(user_id: user_id, date: date)
|
|
53
61
|
record.check_in_at = check_in_at if check_in_at
|
|
54
62
|
record.check_out_at = check_out_at if check_out_at
|
|
@@ -29,9 +29,18 @@ module HrLite
|
|
|
29
29
|
update!(status: "accepted", decided_by_id: actor.id, decided_at: Time.current,
|
|
30
30
|
decision_note: note.presence, proposed_last_day: final_day)
|
|
31
31
|
profile = EmployeeProfile.find_by(user_id: user_id)
|
|
32
|
+
# Someone can resign before HR ever created their profile. That is not
|
|
33
|
+
# a reason to refuse the resignation — but the caller has to know
|
|
34
|
+
# nothing was stamped, because the screen used to claim it was.
|
|
35
|
+
@exit_date_recorded = profile.present?
|
|
32
36
|
profile&.update!(date_of_exit: final_day)
|
|
33
37
|
end
|
|
34
38
|
|
|
39
|
+
# Notice periods are the normal case, so access is revoked here only
|
|
40
|
+
# when the last day has already passed. Otherwise the person keeps
|
|
41
|
+
# working it out and leadership revokes from the employee page.
|
|
42
|
+
revoke_access! if final_day <= Date.current
|
|
43
|
+
|
|
35
44
|
Notifications.publish(
|
|
36
45
|
"resignation.accepted",
|
|
37
46
|
title: "Resignation accepted — last working day #{final_day.strftime('%d %b %Y')}",
|
|
@@ -56,8 +65,21 @@ module HrLite
|
|
|
56
65
|
true
|
|
57
66
|
end
|
|
58
67
|
|
|
68
|
+
# False when the person had no employee profile to stamp — see accept!.
|
|
69
|
+
def exit_date_recorded?
|
|
70
|
+
@exit_date_recorded
|
|
71
|
+
end
|
|
72
|
+
|
|
59
73
|
private
|
|
60
74
|
|
|
75
|
+
# Best-effort, like every other call of this hook: a host that cannot
|
|
76
|
+
# revoke must not roll back an accepted resignation.
|
|
77
|
+
def revoke_access!
|
|
78
|
+
HrLite.config.offboard_user.call(user)
|
|
79
|
+
rescue => e
|
|
80
|
+
Rails.logger.error("[hr_lite] offboard_user failed: #{e.class}: #{e.message}")
|
|
81
|
+
end
|
|
82
|
+
|
|
61
83
|
def notify_submitted
|
|
62
84
|
Notifications.publish(
|
|
63
85
|
"resignation.submitted",
|
|
@@ -78,6 +100,11 @@ module HrLite
|
|
|
78
100
|
def single_open_resignation
|
|
79
101
|
if self.class.pending.where(user_id: user_id).exists?
|
|
80
102
|
errors.add(:base, "You already have a pending resignation")
|
|
103
|
+
elsif self.class.where(user_id: user_id, status: "accepted").exists?
|
|
104
|
+
# Only `pending` used to block a second one, so someone whose exit was
|
|
105
|
+
# already agreed could resign again — and accepting the new one would
|
|
106
|
+
# overwrite the exit date payroll and attendance already clip to.
|
|
107
|
+
errors.add(:base, "Your resignation has already been accepted — talk to your manager to change the date")
|
|
81
108
|
end
|
|
82
109
|
end
|
|
83
110
|
end
|
|
@@ -18,6 +18,10 @@ 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 counts as already paid for tax-to-date. A finalized run is money
|
|
22
|
+
# already decided; waiting for publication made the next month's TDS
|
|
23
|
+
# projection read those earnings as zero.
|
|
24
|
+
scope :settled, -> { joins(:payroll_run).where(hr_lite_payroll_runs: { status: %w[finalized published] }) }
|
|
21
25
|
scope :recent_first, -> { order(period_month: :desc) }
|
|
22
26
|
|
|
23
27
|
def published?
|
|
@@ -33,13 +37,13 @@ module HrLite
|
|
|
33
37
|
lop_override || lop_days
|
|
34
38
|
end
|
|
35
39
|
|
|
36
|
-
# Indian FY (Apr 1) to-date sums of
|
|
40
|
+
# Indian FY (Apr 1) to-date sums of SETTLED slips before this period —
|
|
37
41
|
# feeds the TDS projector and the slip's YTD table.
|
|
38
42
|
def self.fy_to_date(user, period_month)
|
|
39
43
|
fy_start = period_month.month >= 4 ? Date.new(period_month.year, 4, 1)
|
|
40
44
|
: Date.new(period_month.year - 1, 4, 1)
|
|
41
|
-
slips =
|
|
42
|
-
|
|
45
|
+
slips = settled.where(user_id: user.id)
|
|
46
|
+
.where(period_month: fy_start...period_month)
|
|
43
47
|
{
|
|
44
48
|
gross: slips.sum(BigDecimal(0)) { |s| s.gross_earnings || BigDecimal(0) },
|
|
45
49
|
tds: slips.sum(BigDecimal(0)) { |s| Money.d(s.deduction_amount("tds")) },
|
|
@@ -4,6 +4,9 @@ module HrLite
|
|
|
4
4
|
# without GPS or outside every office radius is recorded AND flagged —
|
|
5
5
|
# denying GPS must not stop anyone from working.
|
|
6
6
|
class AttendancePuncher
|
|
7
|
+
# What the browser can legitimately report (geo_punch.js).
|
|
8
|
+
GEO_STATUSES = %w[ok denied unavailable timeout insecure].freeze
|
|
9
|
+
|
|
7
10
|
Result = Struct.new(:record, :error, keyword_init: true) do
|
|
8
11
|
def ok? = error.nil?
|
|
9
12
|
end
|
|
@@ -18,7 +21,11 @@ module HrLite
|
|
|
18
21
|
@lat = lat.presence
|
|
19
22
|
@lng = lng.presence
|
|
20
23
|
@accuracy_m = accuracy_m.presence
|
|
21
|
-
|
|
24
|
+
# Client-supplied and rendered straight into the admin-facing flag note.
|
|
25
|
+
# Anything outside the browser's real permission states is not evidence:
|
|
26
|
+
# a blank one (JS never ran) produced "without GPS ()", and a crafted one
|
|
27
|
+
# let an employee write reassuring text onto their own flag.
|
|
28
|
+
@geo_status = GEO_STATUSES.include?(geo_status.to_s) ? geo_status.to_s : "unavailable"
|
|
22
29
|
end
|
|
23
30
|
|
|
24
31
|
def call
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Calculators
|
|
3
|
-
# ESI: eligibility is decided on the FULL structure monthly gross
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
3
|
+
# ESI: eligibility is decided on the FULL structure monthly gross (a
|
|
4
|
+
# low-attendance month must not pull someone into ESI), and specifically
|
|
5
|
+
# on the gross in force at the START of the ESIC contribution period —
|
|
6
|
+
# April–September or October–March. A rise past the ceiling mid-period
|
|
7
|
+
# does not end coverage; the caller resolves that period-start gross and
|
|
8
|
+
# passes it here. Contributions apply to the EARNED gross and round up to
|
|
9
|
+
# the next rupee (ESIC rule).
|
|
7
10
|
module Esi
|
|
8
11
|
Result = Struct.new(:applicable, :employee, :employer, keyword_init: true) do
|
|
9
12
|
def applicable? = applicable
|
|
@@ -8,7 +8,11 @@ module HrLite
|
|
|
8
8
|
slabs = rates[state.to_s] || []
|
|
9
9
|
earned = Money.d(gross_earned)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# `from:` is an INCLUSIVE lower bound — ₹25,000 is taxed, ₹24,999.50 is
|
|
12
|
+
# not. The older exclusive `above:` form still works so a host's own
|
|
13
|
+
# slab table keeps functioning, but it cannot express that boundary on
|
|
14
|
+
# a decimal amount: `above: 24999` taxed a prorated ₹24,999.50.
|
|
15
|
+
slab = slabs.reverse.find { |row| row[:from] ? earned >= row[:from] : earned > row[:above] }
|
|
12
16
|
return BigDecimal(0) unless slab
|
|
13
17
|
|
|
14
18
|
amount = slab[:monthly]
|
|
@@ -35,7 +35,14 @@ module HrLite
|
|
|
35
35
|
taxable = [ projected - deductions, BigDecimal(0) ].max
|
|
36
36
|
|
|
37
37
|
slab_tax = slab_tax_for(taxable, table[:slabs])
|
|
38
|
-
|
|
38
|
+
if taxable <= table[:rebate_cap]
|
|
39
|
+
slab_tax = BigDecimal(0) # §87A full rebate
|
|
40
|
+
elsif table[:marginal_relief]
|
|
41
|
+
# Just past the cliff the tax may not exceed the income earned above
|
|
42
|
+
# it — without this, one rupee over ₹12,00,000 costs ~₹60,000 of tax.
|
|
43
|
+
# Old regime carries no such relief, hence the per-regime flag.
|
|
44
|
+
slab_tax = [ slab_tax, taxable - table[:rebate_cap] ].min
|
|
45
|
+
end
|
|
39
46
|
annual = Money.round_to_10(slab_tax * (1 + table[:cess_rate]))
|
|
40
47
|
|
|
41
48
|
monthly = Money.round_rupee((annual - Money.d(fy_tds_paid)) / months_remaining)
|
|
@@ -3,19 +3,24 @@ module HrLite
|
|
|
3
3
|
# company-wide holidays inside the range are free; a half-day (single-day
|
|
4
4
|
# requests only) consumes 0.5.
|
|
5
5
|
module LeaveDayCounter
|
|
6
|
-
|
|
6
|
+
# `calendar:` lets a caller counting many requests over the same span
|
|
7
|
+
# build ONE calendar and reuse it. Each one costs a Holiday query and a
|
|
8
|
+
# Setting query, and the balances grid counts every approved request of
|
|
9
|
+
# every employee for every leave type.
|
|
10
|
+
def self.count(leave_request, calendar: nil)
|
|
7
11
|
count_range(
|
|
8
12
|
start_date: leave_request.start_date,
|
|
9
13
|
end_date: leave_request.end_date,
|
|
10
|
-
half_day: leave_request.half_day
|
|
14
|
+
half_day: leave_request.half_day,
|
|
15
|
+
calendar: calendar
|
|
11
16
|
)
|
|
12
17
|
end
|
|
13
18
|
|
|
14
|
-
def self.count_range(start_date:, end_date:, half_day: false)
|
|
19
|
+
def self.count_range(start_date:, end_date:, half_day: false, calendar: nil)
|
|
15
20
|
return 0 if start_date.nil? || end_date.nil? || end_date < start_date
|
|
16
21
|
|
|
17
22
|
range = start_date..end_date
|
|
18
|
-
working = WorkingCalendar.new(range).working_days_in(range)
|
|
23
|
+
working = (calendar || WorkingCalendar.new(range)).working_days_in(range)
|
|
19
24
|
return BigDecimal("0.5") if half_day && working.positive?
|
|
20
25
|
|
|
21
26
|
BigDecimal(working)
|
|
@@ -15,6 +15,17 @@ module HrLite
|
|
|
15
15
|
LeaveRequest.active_on(@date).includes(:leave_type, :user).order(:start_date)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Comp-off and regularization are approvals too. Counting leave alone made
|
|
19
|
+
# the board announce "All present and accounted for" while both queues
|
|
20
|
+
# still had work in them.
|
|
21
|
+
def pending_comp_offs
|
|
22
|
+
CompOffRequest.pending.includes(:user).recent_first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def pending_regularizations
|
|
26
|
+
RegularizationRequest.pending.includes(:user).recent_first
|
|
27
|
+
end
|
|
28
|
+
|
|
18
29
|
def flagged_today
|
|
19
30
|
AttendanceRecord.for_date(@date).flagged.includes(:user)
|
|
20
31
|
end
|
|
@@ -25,7 +36,7 @@ module HrLite
|
|
|
25
36
|
|
|
26
37
|
def kpis
|
|
27
38
|
{
|
|
28
|
-
pending: pending_requests.count,
|
|
39
|
+
pending: pending_requests.count + pending_comp_offs.count + pending_regularizations.count,
|
|
29
40
|
on_leave: on_leave_today.count,
|
|
30
41
|
flagged: flagged_today.count,
|
|
31
42
|
missing_checkout: missing_checkout_yesterday.count
|
|
@@ -31,6 +31,11 @@ module HrLite
|
|
|
31
31
|
run: @run, user: user, structure: structure, profile: profile,
|
|
32
32
|
lop_override: slip.lop_override, tds_override: slip.tds_override
|
|
33
33
|
)
|
|
34
|
+
if attributes[:total_deductions] > attributes[:gross_earnings]
|
|
35
|
+
warnings << "Deductions exceed earnings for #{HrLite.display_name(user)} — " \
|
|
36
|
+
"net pay floored at zero, review the overrides"
|
|
37
|
+
end
|
|
38
|
+
|
|
34
39
|
slip.assign_attributes(attributes)
|
|
35
40
|
slip.save!
|
|
36
41
|
keep_ids << slip.id
|
|
@@ -21,8 +21,14 @@ module HrLite
|
|
|
21
21
|
def call
|
|
22
22
|
summary = AttendanceSummary.for(user: @user, month: @run.period_month)
|
|
23
23
|
days_in_month = summary[:days_in_month]
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
# Clamped defensively: a stored negative override (written before the
|
|
25
|
+
# controller bounded them) would otherwise pay more days than the month has.
|
|
26
|
+
lop = [ Money.d(@lop_override || summary[:lop_days]), BigDecimal(0) ].max
|
|
27
|
+
# `upcoming` is normally zero — PayrollRun refuses a month that has not
|
|
28
|
+
# ended — but subtracting it means a legacy run for an open month cannot
|
|
29
|
+
# pay for days nobody has worked yet.
|
|
30
|
+
payable = [ Money.d(days_in_month) - Money.d(summary[:out_of_window]) -
|
|
31
|
+
Money.d(summary[:upcoming]) - lop, Money.d(0) ].max
|
|
26
32
|
|
|
27
33
|
earnings = Calculators::Proration.call(
|
|
28
34
|
structure: @structure, payable_days: payable, days_in_month: days_in_month
|
|
@@ -44,7 +50,7 @@ module HrLite
|
|
|
44
50
|
)
|
|
45
51
|
end
|
|
46
52
|
|
|
47
|
-
esi = Calculators::Esi.call(monthly_gross:
|
|
53
|
+
esi = Calculators::Esi.call(monthly_gross: esi_reference_gross, gross_earned: gross_earned,
|
|
48
54
|
applicable: @structure.esi_applicable, rates: @rates[:esi])
|
|
49
55
|
if esi.applicable?
|
|
50
56
|
deductions << { code: "esi_employee", label: "ESI", amount: esi.employee }
|
|
@@ -60,8 +66,12 @@ module HrLite
|
|
|
60
66
|
regime: @profile.tax_regime,
|
|
61
67
|
structure_monthly_gross: @structure.monthly_gross,
|
|
62
68
|
gross_earned_this_month: gross_earned,
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
# Plus anything paid this FY that this install never ran — a previous
|
|
70
|
+
# employer, or the months before payroll was switched on. Without it a
|
|
71
|
+
# mid-year start projects those months as zero income and can land the
|
|
72
|
+
# whole year under the rebate cap.
|
|
73
|
+
fy_gross_paid: fy[:gross] + Money.d(@profile.fy_opening_gross),
|
|
74
|
+
fy_tds_paid: fy[:tds] + Money.d(@profile.fy_opening_tds),
|
|
65
75
|
months_remaining: months_remaining_in_fy,
|
|
66
76
|
declared_annual_deductions: @profile.declared_annual_deductions,
|
|
67
77
|
rates: @rates[:income_tax],
|
|
@@ -75,6 +85,9 @@ module HrLite
|
|
|
75
85
|
period_month: @run.period_month,
|
|
76
86
|
days_in_month: days_in_month,
|
|
77
87
|
payable_days: payable,
|
|
88
|
+
# Recorded so the slip can say WHY a mid-month joiner's payable days
|
|
89
|
+
# are short, instead of looking like days went missing.
|
|
90
|
+
out_of_window_days: Money.d(summary[:out_of_window]),
|
|
78
91
|
lop_days: Money.d(summary[:lop_days]),
|
|
79
92
|
lop_override: @lop_override,
|
|
80
93
|
tds_override: @tds_override,
|
|
@@ -84,17 +97,50 @@ module HrLite
|
|
|
84
97
|
tax_details: tds.details.to_json,
|
|
85
98
|
gross_earnings: gross_earned,
|
|
86
99
|
total_deductions: total_deductions,
|
|
87
|
-
|
|
100
|
+
# A heavy-LOP month can leave TDS (projected from the full structure)
|
|
101
|
+
# larger than what was actually earned. Nobody is paid a negative
|
|
102
|
+
# salary; PayrollRunProcessor warns when this floor bites.
|
|
103
|
+
net_pay: [ gross_earned - total_deductions, BigDecimal(0) ].max,
|
|
88
104
|
computed_at: Time.current
|
|
89
105
|
}
|
|
90
106
|
end
|
|
91
107
|
|
|
92
108
|
private
|
|
93
109
|
|
|
94
|
-
# Months left in the Indian FY including the run month itself
|
|
110
|
+
# Months left in the Indian FY (Apr–Mar) including the run month itself:
|
|
111
|
+
# April = 12, December = 4, January = 3, March = 1. Capped at the exit
|
|
112
|
+
# month for a leaver, so a final settlement is not spread over months
|
|
113
|
+
# that will never be paid.
|
|
95
114
|
def months_remaining_in_fy
|
|
96
115
|
month = @run.period_month.month
|
|
97
|
-
month >= 4 ? (
|
|
116
|
+
calendar = month >= 4 ? (16 - month) : (4 - month)
|
|
117
|
+
exit_date = @profile&.date_of_exit
|
|
118
|
+
return calendar if exit_date.nil? || exit_date < @run.period_month
|
|
119
|
+
|
|
120
|
+
[ calendar, months_between(@run.period_month, exit_date) ].min
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def months_between(from, to)
|
|
124
|
+
(to.year * 12 + to.month) - (from.year * 12 + from.month) + 1
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ESIC contribution periods run April–September and October–March.
|
|
128
|
+
# Eligibility is fixed for the whole period, so it is decided on the
|
|
129
|
+
# salary in force on its first day — re-deciding it every month dropped
|
|
130
|
+
# someone out of ESI the moment a mid-period raise crossed the ceiling.
|
|
131
|
+
def esi_reference_gross
|
|
132
|
+
month = @run.period_month
|
|
133
|
+
start = if month.month.between?(4, 9)
|
|
134
|
+
Date.new(month.year, 4, 1)
|
|
135
|
+
elsif month.month >= 10
|
|
136
|
+
Date.new(month.year, 10, 1)
|
|
137
|
+
else
|
|
138
|
+
Date.new(month.year - 1, 10, 1)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# No structure that far back (a mid-period joiner) — their own is the
|
|
142
|
+
# only salary this period has ever had.
|
|
143
|
+
(SalaryStructure.effective_for(@user, start) || @structure).monthly_gross
|
|
98
144
|
end
|
|
99
145
|
|
|
100
146
|
def serialize_rows(rows)
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
<div class="hrl-kpis">
|
|
16
16
|
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @records.values.count { |r| r.check_in_at } %></div><div class="hrl-kpi__label">Checked in</div></div>
|
|
17
|
-
|
|
17
|
+
<%# Derived from the same predicate as "Checked in". Counting rows instead
|
|
18
|
+
dropped a record with no check-in out of BOTH tiles, so the numbers
|
|
19
|
+
quietly stopped adding up to the headcount. %>
|
|
20
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @employees.size - @records.values.count { |r| r.check_in_at } %></div><div class="hrl-kpi__label">No punch</div></div>
|
|
18
21
|
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @flagged_count %></div><div class="hrl-kpi__label">Flagged</div></div>
|
|
19
22
|
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @records.values.count { |r| r.check_in_at && r.check_out_at.nil? } %></div><div class="hrl-kpi__label">Still in</div></div>
|
|
20
23
|
</div>
|
|
@@ -29,18 +29,32 @@
|
|
|
29
29
|
</section>
|
|
30
30
|
|
|
31
31
|
<% if @request.pending? %>
|
|
32
|
+
<%# Approve and reject are two separate forms on purpose. Rails binds the
|
|
33
|
+
CSRF token to the form's OWN action, so one form retargeted with
|
|
34
|
+
`formaction:` can never pass verification on the second endpoint. %>
|
|
32
35
|
<section class="hrl-card">
|
|
33
|
-
<h2 class="hrl-card__title">
|
|
36
|
+
<h2 class="hrl-card__title">Approve</h2>
|
|
34
37
|
<%= form_with url: hr_lite.approve_admin_comp_off_request_path(@request), method: :post, local: true do |f| %>
|
|
35
38
|
<div class="hrl-field">
|
|
36
|
-
<%= f.label :decision_note, "Note (
|
|
37
|
-
<%= f.text_area :decision_note %>
|
|
39
|
+
<%= f.label :decision_note, "Note (optional)", for: "approve_note" %>
|
|
40
|
+
<%= f.text_area :decision_note, id: "approve_note" %>
|
|
38
41
|
</div>
|
|
39
42
|
<div class="hrl-form-actions">
|
|
40
43
|
<%= f.button "Approve — credit #{@request.credit_days.to_f} day",
|
|
41
44
|
type: :submit, class: "hrl-btn hrl-btn--primary" %>
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
</section>
|
|
48
|
+
|
|
49
|
+
<section class="hrl-card">
|
|
50
|
+
<h2 class="hrl-card__title">Reject</h2>
|
|
51
|
+
<%= form_with url: hr_lite.reject_admin_comp_off_request_path(@request), method: :post, local: true do |f| %>
|
|
52
|
+
<div class="hrl-field">
|
|
53
|
+
<%= f.label :decision_note, "Reason (required)", for: "reject_note" %>
|
|
54
|
+
<%= f.text_area :decision_note, id: "reject_note" %>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="hrl-form-actions">
|
|
57
|
+
<%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger" %>
|
|
44
58
|
</div>
|
|
45
59
|
<% end %>
|
|
46
60
|
</section>
|
|
@@ -41,9 +41,16 @@
|
|
|
41
41
|
<div class="hrl-field"><%= f.label :designation %><%= f.text_field :designation %></div>
|
|
42
42
|
<div class="hrl-field">
|
|
43
43
|
<%= f.label :manager_id, "Reports to (their L1)" %>
|
|
44
|
+
<%# The current manager stays listed even after they exit. Dropping them
|
|
45
|
+
left the select with no matching option, so the browser preselected
|
|
46
|
+
the blank one and saving anything silently wiped the reporting line
|
|
47
|
+
of everyone whose manager had left. %>
|
|
48
|
+
<% candidates = HrLite.active_employees.reject { |u| u.id == profile.user_id } %>
|
|
49
|
+
<% if profile.manager && candidates.none? { |u| u.id == profile.manager_id } %>
|
|
50
|
+
<% candidates += [ profile.manager ] %>
|
|
51
|
+
<% end %>
|
|
44
52
|
<%= f.select :manager_id,
|
|
45
|
-
|
|
46
|
-
.map { |u| [ HrLite.display_name(u), u.id ] },
|
|
53
|
+
candidates.map { |u| [ HrLite.display_name(u), u.id ] },
|
|
47
54
|
include_blank: "— nobody (top of the org) —" %>
|
|
48
55
|
</div>
|
|
49
56
|
<div class="hrl-field"><%= f.label :department %><%= f.text_field :department %></div>
|
|
@@ -68,6 +75,17 @@
|
|
|
68
75
|
<%= f.label :declared_annual_deductions, "Declared annual deductions (old regime only)" %>
|
|
69
76
|
<%= f.number_field :declared_annual_deductions, step: 0.01, value: profile.declared_annual_deductions&.to_s("F") %>
|
|
70
77
|
</div>
|
|
78
|
+
<%# Only what THIS install has paid is visible to the tax projection, so a
|
|
79
|
+
mid-year start (or a joiner from another employer) needs the earlier
|
|
80
|
+
months stated here or the year under-deducts. %>
|
|
81
|
+
<div class="hrl-field">
|
|
82
|
+
<%= f.label :fy_opening_gross, "Gross already paid this FY elsewhere (previous employer, or before payroll started here)" %>
|
|
83
|
+
<%= f.number_field :fy_opening_gross, step: 0.01, value: profile.fy_opening_gross&.to_s("F") %>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="hrl-field">
|
|
86
|
+
<%= f.label :fy_opening_tds, "TDS already deducted this FY elsewhere" %>
|
|
87
|
+
<%= f.number_field :fy_opening_tds, step: 0.01, value: profile.fy_opening_tds&.to_s("F") %>
|
|
88
|
+
</div>
|
|
71
89
|
<div class="hrl-form-actions">
|
|
72
90
|
<%= f.submit "Save profile", class: "hrl-btn hrl-btn--primary" %>
|
|
73
91
|
<a class="hrl-btn" href="<%= hr_lite.admin_employees_path %>">Cancel</a>
|
|
@@ -35,23 +35,33 @@
|
|
|
35
35
|
</section>
|
|
36
36
|
<% end %>
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
<%# Stays on the page after an exit date is stamped. Accepting a resignation
|
|
39
|
+
stamps one, and hiding this card at that moment removed the only control
|
|
40
|
+
that revokes sign-in — during the notice period, when it is needed. %>
|
|
41
|
+
<section class="hrl-card">
|
|
42
|
+
<h2 class="hrl-card__title"><%= @profile.date_of_exit ? "Revoke access" : "Offboard" %></h2>
|
|
43
|
+
<% if @profile.date_of_exit %>
|
|
44
|
+
<p class="hrl-small hrl-muted">Last day is <%= @profile.date_of_exit.strftime("%d %b %Y") %> —
|
|
45
|
+
attendance and payroll already clip to it. Run this on or after that day to revoke
|
|
46
|
+
sign-in. Records are never deleted.</p>
|
|
47
|
+
<% else %>
|
|
41
48
|
<p class="hrl-small hrl-muted">Stamps the last day (attendance and payroll clip to it) and revokes sign-in.
|
|
42
49
|
Records are never deleted.</p>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
<% end %>
|
|
51
|
+
<%= form_with url: hr_lite.offboard_admin_employee_path(@profile), method: :post, local: true,
|
|
52
|
+
html: { data: { turbo_confirm: "Offboard #{hr_display_name(@profile.user)}?" } } do %>
|
|
53
|
+
<div class="hrl-field">
|
|
54
|
+
<label for="off_date">Last working day</label>
|
|
55
|
+
<input type="date" id="off_date" name="date_of_exit" class="hrl-input"
|
|
56
|
+
value="<%= @profile.date_of_exit || Date.current %>">
|
|
57
|
+
</div>
|
|
58
|
+
<div class="hrl-form-actions">
|
|
59
|
+
<button class="hrl-btn hrl-btn--danger" type="submit">
|
|
60
|
+
<%= @profile.date_of_exit ? "Revoke access now" : "Offboard employee" %>
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
<% end %>
|
|
64
|
+
</section>
|
|
55
65
|
|
|
56
66
|
<div class="hrl-grid hrl-grid--2">
|
|
57
67
|
<section class="hrl-card">
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
<dl class="hrl-deflist">
|
|
11
11
|
<dt>Type</dt><dd><%= @request.leave_type.name %> (<%= @request.paid? ? "paid" : "unpaid" %>)</dd>
|
|
12
12
|
<dt>Dates</dt><dd><%= @request.date_range_label %></dd>
|
|
13
|
-
|
|
13
|
+
<%# Counted live, not from the create-time cache: a holiday added after
|
|
14
|
+
the request was filed changes what the balance is actually debited. %>
|
|
15
|
+
<dt>Working days</dt><dd><%= HrLite::LeaveDayCounter.count(@request).to_f %></dd>
|
|
14
16
|
<dt>Status</dt><dd><span class="hrl-badge"><%= @request.status.humanize %></span></dd>
|
|
15
17
|
<% if @request.reason.present? %><dt>Reason</dt><dd><%= @request.reason %></dd><% end %>
|
|
16
18
|
<% unless @request.leave_type.unlimited? %>
|
|
@@ -21,18 +23,48 @@
|
|
|
21
23
|
</dl>
|
|
22
24
|
</section>
|
|
23
25
|
|
|
26
|
+
<%# An approved leave that has not started yet can still be called off — the
|
|
27
|
+
balance is recomputed live, so cancelling gives the days straight back. %>
|
|
28
|
+
<% if @request.cancellable_by?(hr_current_user) && !@request.pending? %>
|
|
29
|
+
<section class="hrl-card">
|
|
30
|
+
<h2 class="hrl-card__title">Cancel</h2>
|
|
31
|
+
<p class="hrl-small hrl-muted">Releases the days back to their balance. Only possible
|
|
32
|
+
before the leave starts.</p>
|
|
33
|
+
<%= form_with url: hr_lite.cancel_admin_leave_request_path(@request), method: :post, local: true,
|
|
34
|
+
html: { data: { turbo_confirm: "Cancel this approved leave?" } } do %>
|
|
35
|
+
<div class="hrl-form-actions">
|
|
36
|
+
<button class="hrl-btn hrl-btn--danger" type="submit">Cancel this leave</button>
|
|
37
|
+
</div>
|
|
38
|
+
<% end %>
|
|
39
|
+
</section>
|
|
40
|
+
<% end %>
|
|
41
|
+
|
|
24
42
|
<% if @request.pending? %>
|
|
25
43
|
<section class="hrl-card">
|
|
26
44
|
<h2 class="hrl-card__title">Decide</h2>
|
|
45
|
+
<%# Approve and reject are two separate forms on purpose. Rails binds the
|
|
46
|
+
CSRF token to the form's OWN action, so one form retargeted with
|
|
47
|
+
`formaction:` can never pass verification on the second endpoint. %>
|
|
27
48
|
<%= form_with url: hr_lite.approve_admin_leave_request_path(@request), method: :post, local: true do %>
|
|
28
49
|
<div class="hrl-field">
|
|
29
|
-
<label for="
|
|
30
|
-
<textarea name="decision_note" id="
|
|
50
|
+
<label for="approve_note">Note (optional)</label>
|
|
51
|
+
<textarea name="decision_note" id="approve_note" class="hrl-input"></textarea>
|
|
31
52
|
</div>
|
|
32
53
|
<div class="hrl-form-actions">
|
|
33
54
|
<button class="hrl-btn hrl-btn--primary" type="submit">Approve</button>
|
|
34
|
-
|
|
35
|
-
|
|
55
|
+
</div>
|
|
56
|
+
<% end %>
|
|
57
|
+
</section>
|
|
58
|
+
|
|
59
|
+
<section class="hrl-card">
|
|
60
|
+
<h2 class="hrl-card__title">Reject</h2>
|
|
61
|
+
<%= form_with url: hr_lite.reject_admin_leave_request_path(@request), method: :post, local: true do %>
|
|
62
|
+
<div class="hrl-field">
|
|
63
|
+
<label for="reject_note">Reason (required)</label>
|
|
64
|
+
<textarea name="decision_note" id="reject_note" class="hrl-input"></textarea>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="hrl-form-actions">
|
|
67
|
+
<button class="hrl-btn hrl-btn--danger" type="submit">Reject</button>
|
|
36
68
|
</div>
|
|
37
69
|
<% end %>
|
|
38
70
|
</section>
|