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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/README.md +312 -99
  4. data/app/assets/javascripts/hr_lite/confirm.js +31 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +28 -0
  6. data/app/controllers/hr_lite/admin/attendances_controller.rb +16 -1
  7. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +4 -1
  8. data/app/controllers/hr_lite/admin/employees_controller.rb +36 -12
  9. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +15 -8
  10. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +16 -0
  11. data/app/controllers/hr_lite/admin/resignations_controller.rb +17 -5
  12. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +17 -2
  13. data/app/controllers/hr_lite/admin/superadmin_controller.rb +5 -0
  14. data/app/controllers/hr_lite/org_controller.rb +6 -1
  15. data/app/controllers/hr_lite/resignations_controller.rb +6 -1
  16. data/app/helpers/hr_lite/application_helper.rb +2 -12
  17. data/app/jobs/hr_lite/application_job.rb +5 -0
  18. data/app/jobs/hr_lite/daily_digest_job.rb +1 -1
  19. data/app/jobs/hr_lite/leave_year_rollover_job.rb +1 -1
  20. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +5 -2
  21. data/app/models/concerns/hr_lite/audited.rb +11 -4
  22. data/app/models/hr_lite/appraisal.rb +9 -1
  23. data/app/models/hr_lite/audit_log.rb +11 -0
  24. data/app/models/hr_lite/comp_off_request.rb +5 -15
  25. data/app/models/hr_lite/designation_change.rb +18 -1
  26. data/app/models/hr_lite/employee_profile.rb +5 -1
  27. data/app/models/hr_lite/leave_balance.rb +51 -5
  28. data/app/models/hr_lite/leave_request.rb +9 -1
  29. data/app/models/hr_lite/leave_type.rb +13 -3
  30. data/app/models/hr_lite/payroll_run.rb +39 -10
  31. data/app/models/hr_lite/regularization_request.rb +8 -0
  32. data/app/models/hr_lite/resignation.rb +27 -0
  33. data/app/models/hr_lite/salary_slip.rb +7 -3
  34. data/app/services/hr_lite/attendance_puncher.rb +8 -1
  35. data/app/services/hr_lite/calculators/esi.rb +7 -4
  36. data/app/services/hr_lite/calculators/professional_tax.rb +5 -1
  37. data/app/services/hr_lite/calculators/tds.rb +8 -1
  38. data/app/services/hr_lite/leave_day_counter.rb +9 -4
  39. data/app/services/hr_lite/overview_query.rb +12 -1
  40. data/app/services/hr_lite/payroll_run_processor.rb +5 -0
  41. data/app/services/hr_lite/slip_builder.rb +54 -8
  42. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
  43. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +19 -5
  44. data/app/views/hr_lite/admin/employees/_form.html.erb +20 -2
  45. data/app/views/hr_lite/admin/employees/show.html.erb +25 -15
  46. data/app/views/hr_lite/admin/leave_requests/show.html.erb +37 -5
  47. data/app/views/hr_lite/admin/overview/index.html.erb +21 -0
  48. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +3 -1
  49. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +18 -4
  50. data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -2
  51. data/app/views/hr_lite/career/show.html.erb +7 -1
  52. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -1
  53. data/app/views/hr_lite/salary_slips/pdf.html.erb +5 -5
  54. data/app/views/layouts/hr_lite/application.html.erb +42 -4
  55. data/config/routes.rb +1 -1
  56. data/db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb +28 -0
  57. data/db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb +14 -0
  58. data/db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb +12 -0
  59. data/lib/hr_lite/amount_in_words.rb +15 -2
  60. data/lib/hr_lite/money.rb +18 -0
  61. data/lib/hr_lite/notifications.rb +23 -8
  62. data/lib/hr_lite/statutory_rate_card.rb +6 -1
  63. data/lib/hr_lite/version.rb +1 -1
  64. data/lib/hr_lite.rb +5 -1
  65. metadata +5 -1
@@ -25,6 +25,27 @@
25
25
  <a class="hrl-btn" href="<%= hr_lite.admin_leave_request_path(request) %>">Decide</a>
26
26
  </div>
27
27
  <% end %>
28
+ <%# Comp-off and regularization are approvals too. Listing leave alone
29
+ left both queues invisible on the board that exists to surface work. %>
30
+ <% @query.pending_comp_offs.limit(HrLite::Admin::OverviewController::SECTION_CAP).each do |request| %>
31
+ <div class="hrl-feed__item">
32
+ <div class="hrl-feed__body">
33
+ <strong><%= hr_display_name(request.user) %></strong>
34
+ — comp-off for <%= request.date_worked.strftime("%d %b") %>
35
+ (<%= request.credit_days.to_f %> d)
36
+ </div>
37
+ <a class="hrl-btn" href="<%= hr_lite.admin_comp_off_request_path(request) %>">Decide</a>
38
+ </div>
39
+ <% end %>
40
+ <% @query.pending_regularizations.limit(HrLite::Admin::OverviewController::SECTION_CAP).each do |request| %>
41
+ <div class="hrl-feed__item">
42
+ <div class="hrl-feed__body">
43
+ <strong><%= hr_display_name(request.user) %></strong>
44
+ — attendance fix for <%= request.date.strftime("%d %b") %>
45
+ </div>
46
+ <a class="hrl-btn" href="<%= hr_lite.admin_regularization_request_path(request) %>">Decide</a>
47
+ </div>
48
+ <% end %>
28
49
  <a class="hrl-small" href="<%= hr_lite.admin_leave_requests_path %>">All requests</a>
29
50
  </section>
30
51
  <% end %>
@@ -23,7 +23,9 @@
23
23
  <section class="hrl-card">
24
24
  <div class="hrl-row">
25
25
  <span class="hrl-badge"><%= @run.status.humanize %></span>
26
- <% if @run.draft? || @run.review? %>
26
+ <%# `processing` is offered too: a run stranded there by a killed process
27
+ would otherwise have no control at all and block that month forever. %>
28
+ <% if @run.draft? || @run.review? || @run.processing? %>
27
29
  <%= button_to @run.draft? ? "Compute" : "Recompute", hr_lite.compute_admin_payroll_run_path(@run),
28
30
  method: :post, class: "hrl-btn hrl-btn--primary" %>
29
31
  <% end %>
@@ -35,15 +35,29 @@
35
35
  <h2 class="hrl-card__title">Decide</h2>
36
36
  <p class="hrl-hint">Approving writes the proposed times onto the day's attendance record
37
37
  (only the provided times overwrite) with the full regularization trail.</p>
38
+ <%# Approve and reject are two separate forms on purpose. Rails binds the
39
+ CSRF token to the form's OWN action, so one form retargeted with
40
+ `formaction:` can never pass verification on the second endpoint. %>
38
41
  <%= form_with url: hr_lite.approve_admin_regularization_request_path(@request), method: :post, local: true do |f| %>
39
42
  <div class="hrl-field">
40
- <%= f.label :decision_note, "Note (required to reject, optional to approve)" %>
41
- <%= f.text_area :decision_note %>
43
+ <%= f.label :decision_note, "Note (optional)", for: "approve_note" %>
44
+ <%= f.text_area :decision_note, id: "approve_note" %>
42
45
  </div>
43
46
  <div class="hrl-form-actions">
44
47
  <%= f.button "Approve — fix attendance", type: :submit, class: "hrl-btn hrl-btn--primary" %>
45
- <%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger",
46
- formaction: hr_lite.reject_admin_regularization_request_path(@request) %>
48
+ </div>
49
+ <% end %>
50
+ </section>
51
+
52
+ <section class="hrl-card">
53
+ <h2 class="hrl-card__title">Reject</h2>
54
+ <%= form_with url: hr_lite.reject_admin_regularization_request_path(@request), method: :post, local: true do |f| %>
55
+ <div class="hrl-field">
56
+ <%= f.label :decision_note, "Reason (required)", for: "reject_note" %>
57
+ <%= f.text_area :decision_note, id: "reject_note" %>
58
+ </div>
59
+ <div class="hrl-form-actions">
60
+ <%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger" %>
47
61
  </div>
48
62
  <% end %>
49
63
  </section>
@@ -1,4 +1,11 @@
1
1
  <% record = HrLite::AttendanceRecord.find_by(user_id: hr_current_user.id, date: Date.current) %>
2
+ <%# A shift that runs past midnight leaves yesterday's record open.
3
+ AttendancePuncher already closes it, but this card looked only at today,
4
+ so after midnight it offered "Check in" and the night's punch never
5
+ closed — no worked duration, no hours on the team board. %>
6
+ <% open_yesterday = record ? nil : HrLite::AttendanceRecord.for_date(Date.current - 1)
7
+ .missing_checkout
8
+ .find_by(user_id: hr_current_user.id) %>
2
9
  <section class="hrl-card">
3
10
  <h2 class="hrl-card__title">Today, <%= Date.current.strftime("%d %b") %></h2>
4
11
 
@@ -12,12 +19,25 @@
12
19
  <% if record&.flagged? %>
13
20
  <span class="hrl-badge hrl-badge--bad" title="<%= record.flag_note %>">Flagged</span>
14
21
  <% end %>
15
- <% if record.nil? %>
22
+ <% if open_yesterday %>
23
+ <span class="hrl-badge hrl-badge--warn">Yesterday still open —
24
+ in <%= open_yesterday.check_in_at.strftime("%H:%M") %></span>
25
+ <% elsif record.nil? %>
16
26
  <span class="hrl-muted">Not checked in yet.</span>
17
27
  <% end %>
18
28
  </div>
19
29
 
20
- <% if record&.check_in_at.blank? %>
30
+ <% if open_yesterday %>
31
+ <%= form_with url: hr_lite.check_out_attendance_path, method: :post, local: true,
32
+ html: { data: { "hrl-geo-punch": true } } do %>
33
+ <input type="hidden" name="lat"><input type="hidden" name="lng">
34
+ <input type="hidden" name="accuracy_m"><input type="hidden" name="geo_status">
35
+ <button type="submit" class="hrl-btn hrl-btn--big hrl-btn--block">
36
+ Check out yesterday's shift
37
+ </button>
38
+ <p class="hrl-small hrl-muted" data-hrl-geo-status hidden></p>
39
+ <% end %>
40
+ <% elsif record&.check_in_at.blank? %>
21
41
  <%= form_with url: hr_lite.check_in_attendance_path, method: :post, local: true,
22
42
  html: { data: { "hrl-geo-punch": true } } do %>
23
43
  <input type="hidden" name="lat"><input type="hidden" name="lng">
@@ -6,7 +6,13 @@
6
6
  <section class="hrl-card">
7
7
  <h2 class="hrl-card__title">Current role</h2>
8
8
  <p><strong><%= @profile&.designation.presence || "Not set yet" %></strong>
9
- <% if @profile %><span class="hrl-muted hrl-small">since <%= (@changes.first&.effective_date || @profile.date_of_joining).strftime("%b %Y") %></span><% end %>
9
+ <%# The most recent change IN FORCE, not simply the most recent row — a
10
+ promotion recorded for next quarter would otherwise date the current
11
+ role from a month that has not arrived yet. %>
12
+ <% if @profile %>
13
+ <% in_force = @changes.find { |change| change.effective_date <= Date.current } %>
14
+ <span class="hrl-muted hrl-small">since <%= (in_force&.effective_date || @profile.date_of_joining).strftime("%b %Y") %></span>
15
+ <% end %>
10
16
  </p>
11
17
  </section>
12
18
 
@@ -37,7 +37,11 @@
37
37
  <section class="hrl-card">
38
38
  <dl class="hrl-deflist">
39
39
  <dt>Net pay</dt><dd><strong><%= hrl_money(slip.net_pay) %></strong> (<%= hrl_amount_in_words(slip.net_pay) %>)</dd>
40
- <dt>Days</dt><dd><%= slip.payable_days %> payable / <%= slip.effective_lop_days %> LOP of <%= slip.days_in_month %></dd>
40
+ <%# Out-of-window days are named rather than silently dropped: without them
41
+ a mid-month joiner's row does not add up to the month. %>
42
+ <dt>Days</dt>
43
+ <dd><%= slip.payable_days %> payable / <%= slip.effective_lop_days %> LOP<% if slip.out_of_window_days.to_f.positive? %>
44
+ / <%= slip.out_of_window_days %> outside employment<% end %> of <%= slip.days_in_month %></dd>
41
45
  <% if profile %>
42
46
  <dt>Employee code</dt><dd><%= profile.employee_code %></dd>
43
47
  <% if profile.masked_uan %><dt>PF UAN</dt><dd><%= profile.masked_uan %></dd><% end %>
@@ -44,9 +44,9 @@
44
44
  <thead><tr><th>Earnings</th><th class="num">Full</th><th class="num">Earned</th></tr></thead>
45
45
  <tbody>
46
46
  <% @slip.earnings_rows.each do |row| %>
47
- <tr><td><%= row["label"] %></td><td class="num"><%= row["full_amount"] %></td><td class="num"><%= row["amount"] %></td></tr>
47
+ <tr><td><%= row["label"] %></td><td class="num"><%= HrLite::Money.format(row["full_amount"]) %></td><td class="num"><%= HrLite::Money.format(row["amount"]) %></td></tr>
48
48
  <% end %>
49
- <tr class="total"><td>Gross</td><td></td><td class="num"><%= @slip.gross_earnings.to_s("F") %></td></tr>
49
+ <tr class="total"><td>Gross</td><td></td><td class="num"><%= HrLite::Money.format(@slip.gross_earnings) %></td></tr>
50
50
  </tbody>
51
51
  </table>
52
52
  </div>
@@ -55,9 +55,9 @@
55
55
  <thead><tr><th>Deductions</th><th class="num">Amount</th></tr></thead>
56
56
  <tbody>
57
57
  <% @slip.deductions_rows.each do |row| %>
58
- <tr><td><%= row["label"] %></td><td class="num"><%= row["amount"] %></td></tr>
58
+ <tr><td><%= row["label"] %></td><td class="num"><%= HrLite::Money.format(row["amount"]) %></td></tr>
59
59
  <% end %>
60
- <tr class="total"><td>Total deductions</td><td class="num"><%= @slip.total_deductions.to_s("F") %></td></tr>
60
+ <tr class="total"><td>Total deductions</td><td class="num"><%= HrLite::Money.format(@slip.total_deductions) %></td></tr>
61
61
  </tbody>
62
62
  </table>
63
63
  </div>
@@ -66,7 +66,7 @@
66
66
  <div class="pdf-net">
67
67
  <%# PORO, not a helper — this template is rendered by HOST code via
68
68
  config.render_pdf, where engine helper modules are not in scope. %>
69
- Net pay: <%= HrLite.config.currency_symbol %><%= @slip.net_pay.to_s("F") %>
69
+ Net pay: <%= HrLite::Money.format(@slip.net_pay) %>
70
70
  (<%= HrLite::AmountInWords.words(@slip.net_pay) %>)
71
71
  </div>
72
72
 
@@ -37,11 +37,15 @@
37
37
  <% end %>
38
38
  <% end %>
39
39
 
40
- <% if hr_leadership? %>
40
+ <% if hr_leadership? || hr_superadmin? %>
41
41
  <span class="hrl-side__group">Organisation</span>
42
- <% hrl_nav_items(HrLite::ApplicationHelper::LEADERSHIP_NAV_ITEMS).each do |item| %>
43
- <%= hrl_nav_link(item) %>
42
+ <% if hr_leadership? %>
43
+ <% hrl_nav_items(HrLite::ApplicationHelper::LEADERSHIP_NAV_ITEMS).each do |item| %>
44
+ <%= hrl_nav_link(item) %>
45
+ <% end %>
44
46
  <% end %>
47
+ <%# Payroll hangs off the money tier alone — a superadmin who is not
48
+ in leadership_emails still needs to reach it. %>
45
49
  <% if hr_superadmin? %>
46
50
  <% hrl_nav_items(HrLite::ApplicationHelper::SUPERADMIN_NAV_ITEMS).each do |item| %>
47
51
  <%= hrl_nav_link(item) %>
@@ -78,12 +82,46 @@
78
82
  </div>
79
83
  </div>
80
84
 
85
+ <%# The left rail is display:none below 768px, so before the More sheet
86
+ existed the phone — the surface this is installed on — could reach only
87
+ the first five items. Org, Kudos, Slips, Career and every admin,
88
+ leadership and payroll screen were unreachable. <details> keeps it
89
+ dependency-free; the engine ships no Turbo or Stimulus. %>
81
90
  <nav class="hrl-tabbar" aria-label="HR sections">
82
- <% hrl_nav_items(HrLite::ApplicationHelper::NAV_ITEMS).first(5).each do |item| %>
91
+ <% primary, rest = hrl_nav_items(HrLite::ApplicationHelper::NAV_ITEMS).partition.with_index { |_, i| i < 4 } %>
92
+ <% primary.each do |item| %>
83
93
  <%= hrl_nav_link(item) %>
84
94
  <% end %>
95
+ <details class="hrl-tabbar__more">
96
+ <summary class="hrl-nav__link">More</summary>
97
+ <div class="hrl-sheet">
98
+ <% rest.each do |item| %>
99
+ <%= hrl_nav_link(item) %>
100
+ <% end %>
101
+ <% if hr_admin? || hr_leadership? %>
102
+ <span class="hrl-side__group">Manage</span>
103
+ <% hrl_nav_items(HrLite::ApplicationHelper::ADMIN_NAV_ITEMS).each do |item| %>
104
+ <%= hrl_nav_link(item) %>
105
+ <% end %>
106
+ <% end %>
107
+ <% if hr_leadership? || hr_superadmin? %>
108
+ <span class="hrl-side__group">Organisation</span>
109
+ <% if hr_leadership? %>
110
+ <% hrl_nav_items(HrLite::ApplicationHelper::LEADERSHIP_NAV_ITEMS).each do |item| %>
111
+ <%= hrl_nav_link(item) %>
112
+ <% end %>
113
+ <% end %>
114
+ <% if hr_superadmin? %>
115
+ <% hrl_nav_items(HrLite::ApplicationHelper::SUPERADMIN_NAV_ITEMS).each do |item| %>
116
+ <%= hrl_nav_link(item) %>
117
+ <% end %>
118
+ <% end %>
119
+ <% end %>
120
+ </div>
121
+ </details>
85
122
  </nav>
86
123
 
124
+ <%= javascript_include_tag "hr_lite/confirm", defer: true %>
87
125
  <%= javascript_include_tag "hr_lite/mention", defer: true %>
88
126
  <%= yield :scripts %>
89
127
  </body>
data/config/routes.rb CHANGED
@@ -35,7 +35,7 @@ HrLite::Engine.routes.draw do
35
35
  get "overview", to: "overview#index"
36
36
  resources :attendances, only: %i[index show update], param: :user_id
37
37
  resources :leave_requests, only: %i[index show] do
38
- member { post :approve; post :reject }
38
+ member { post :approve; post :reject; post :cancel }
39
39
  end
40
40
  resources :leave_balances, only: :index do
41
41
  collection { post :adjust }
@@ -0,0 +1,28 @@
1
+ class AddLiveUniquenessIndexesToHrLite < ActiveRecord::Migration[8.0]
2
+ # The models already validate each of these, but a validation is a
3
+ # read-then-write: two concurrent requests both see nothing and both insert.
4
+ # Comp-off requests already had this partial index; these three did not.
5
+ #
6
+ # Every table here holds a handful of rows per employee, so the index builds
7
+ # are instant and a plain (non-concurrent) build is fine.
8
+ def change
9
+ # One open resignation per person — Resignation#single_open_resignation.
10
+ add_index :hr_lite_resignations, :user_id,
11
+ unique: true,
12
+ where: "status = 'pending'",
13
+ name: "index_hr_lite_resignations_one_pending_per_user"
14
+
15
+ # One ticket per person per day — RegularizationRequest#no_duplicate_pending.
16
+ add_index :hr_lite_regularization_requests, %i[user_id date],
17
+ unique: true,
18
+ where: "status = 'pending'",
19
+ name: "index_hr_lite_regularizations_one_pending_per_day"
20
+
21
+ # Exactly one leave type carries the comp-off flag — approvals credit into
22
+ # THE comp-off type, and two would make the target position-order roulette.
23
+ add_index :hr_lite_leave_types, :comp_off,
24
+ unique: true,
25
+ where: "comp_off",
26
+ name: "index_hr_lite_leave_types_single_comp_off"
27
+ end
28
+ end
@@ -0,0 +1,14 @@
1
+ class AddFyOpeningToHrLiteEmployeeProfiles < ActiveRecord::Migration[8.0]
2
+ # Income already paid this financial year that this install did not run:
3
+ # a previous employer, or the months before payroll was switched on. TDS
4
+ # projects the annual total from slips it can see, so without these the
5
+ # first year after a mid-year start under-projects — often far enough
6
+ # under the rebate cap to deduct nothing at all.
7
+ #
8
+ # Text, because they are encrypted at rest like every other money column
9
+ # (see HrLite::EncryptedMoney).
10
+ def change
11
+ add_column :hr_lite_employee_profiles, :fy_opening_gross, :text
12
+ add_column :hr_lite_employee_profiles, :fy_opening_tds, :text
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ class AddOutOfWindowDaysToHrLiteSalarySlips < ActiveRecord::Migration[8.0]
2
+ # Days outside the employment window — before joining, after the last day.
3
+ # SlipBuilder already subtracts them from payable days, but never recorded
4
+ # them, so a mid-month joiner's payslip read "15 payable, 0 LOP" of a
5
+ # 30-day month and looked as though days had gone missing.
6
+ #
7
+ # Not encrypted: a day count is not money.
8
+ def change
9
+ add_column :hr_lite_salary_slips, :out_of_window_days, :decimal, precision: 4, scale: 1,
10
+ default: "0.0", null: false
11
+ end
12
+ end
@@ -14,15 +14,28 @@ module HrLite
14
14
  rupees = Money.round_rupee(amount).to_i
15
15
  return "Zero rupees" if rupees.zero?
16
16
 
17
+ # A negative amount rendered as bare " rupees": no group was >= its
18
+ # divisor, so nothing was ever appended.
19
+ sentence = groups(rupees.abs).join(" ")
20
+ sentence = "minus #{sentence}" if rupees.negative?
21
+ "#{sentence.capitalize} rupees"
22
+ end
23
+
24
+ def groups(rupees)
17
25
  parts = []
18
26
  SCALES.each do |divisor, label|
19
27
  next unless rupees >= divisor
20
28
 
21
- parts << "#{two_digit(rupees / divisor)} #{label}"
29
+ # The crore group is unbounded — every later group is reduced below
30
+ # 100 by the preceding modulo, but this one is not, and two_digit
31
+ # only knows 0..99. Recurse for anything above a hundred crore.
32
+ count = rupees / divisor
33
+ spelled = count > 99 ? groups(count).join(" ") : two_digit(count)
34
+ parts << "#{spelled} #{label}"
22
35
  rupees %= divisor
23
36
  end
24
37
  parts << two_digit(rupees) if rupees.positive?
25
- "#{parts.join(' ').capitalize} rupees"
38
+ parts
26
39
  end
27
40
 
28
41
  def two_digit(number)
data/lib/hr_lite/money.rb CHANGED
@@ -29,5 +29,23 @@ module HrLite
29
29
  def round_to_10(value)
30
30
  (d(value) / 10).round(0, BigDecimal::ROUND_HALF_UP) * 10
31
31
  end
32
+
33
+ # Indian digit grouping: 12,34,567 — not the western 1,234,567. It lives
34
+ # here rather than only in the view helper because the slip PDF is
35
+ # rendered by HOST code (config.render_pdf), where engine helpers are out
36
+ # of scope — which is why the PDF printed a bare "75000.0" next to the
37
+ # web slip's "₹75,000.00".
38
+ def format(amount)
39
+ return "—" if amount.nil?
40
+
41
+ whole, fraction = round2(amount).to_s("F").split(".")
42
+ sign = whole.start_with?("-") ? "-" : ""
43
+ whole = whole.delete_prefix("-")
44
+ if whole.length > 3
45
+ head = whole[0..-4].reverse.scan(/\d{1,2}/).join(",").reverse
46
+ whole = "#{head},#{whole[-3..]}"
47
+ end
48
+ "#{sign}#{HrLite.config.currency_symbol}#{whole}.#{fraction.ljust(2, '0')}"
49
+ end
32
50
  end
33
51
  end
@@ -49,17 +49,27 @@ module HrLite
49
49
  DEFAULT_MATRIX.merge(HrLite.config.notification_matrix || {})
50
50
  end
51
51
 
52
- def publish(event, title:, body: nil, path: nil, bell_to: [], email_to: [], lines: [], diff: nil, link_url: nil)
52
+ # `leadership:` overrides title/body/path for the leadership copies of an
53
+ # event. Some events are written in the second person and point at an
54
+ # employee-scoped path ("Your appraisal has been shared", /appraisals/7):
55
+ # fanned out verbatim, leadership read them as being about themselves and
56
+ # followed a link their own scope 404s or resolves to their own record.
57
+ def publish(event, title:, body: nil, path: nil, bell_to: [], email_to: [], lines: [],
58
+ diff: nil, link_url: nil, leadership: {})
53
59
  row = matrix[event.to_s]
54
60
  unless row
55
61
  Rails.logger.warn("[hr_lite] unknown notification event #{event}")
56
62
  return
57
63
  end
58
64
 
65
+ lead_title = leadership[:title] || title
66
+ lead_body = leadership.key?(:body) ? leadership[:body] : body
67
+ lead_path = leadership[:path] || path
68
+
59
69
  deliver_bells(event, row, bell_to, title, body, path)
60
70
  deliver_emails(row, email_to, title, body, path, lines, link_url)
61
- deliver_leadership_email(event, row, title, body, path, lines, diff)
62
- deliver_leadership_bells(event, row, bell_to, title, body, path)
71
+ deliver_leadership_email(event, row, lead_title, lead_body, lead_path, lines, diff)
72
+ deliver_leadership_bells(event, row, bell_to, lead_title, lead_body, lead_path)
63
73
  nil
64
74
  end
65
75
 
@@ -76,15 +86,20 @@ module HrLite
76
86
  def deliver_emails(row, email_to, title, body, path, lines, link_url = nil)
77
87
  return unless row[:email]
78
88
 
89
+ # Rescued PER RECIPIENT. Wrapping the whole loop meant one bad address
90
+ # or a single enqueue failure silently dropped everyone after it — on
91
+ # a team-wide leave notice, most of the team.
79
92
  Array(email_to).compact.uniq.each do |user|
80
93
  next if user.email.blank?
81
94
 
82
- EventMailer.event(to: user.email, subject: title, heading: title,
83
- body: body, lines: lines, path: path,
84
- link_url: link_url).deliver_later
95
+ begin
96
+ EventMailer.event(to: user.email, subject: title, heading: title,
97
+ body: body, lines: lines, path: path,
98
+ link_url: link_url).deliver_later
99
+ rescue => e
100
+ Rails.logger.error("[hr_lite] event email failed: #{e.class}: #{e.message}")
101
+ end
85
102
  end
86
- rescue => e
87
- Rails.logger.error("[hr_lite] event email failed: #{e.class}: #{e.message}")
88
103
  end
89
104
 
90
105
  def deliver_leadership_email(event, row, title, body, path, lines, diff)
@@ -24,11 +24,16 @@ module HrLite
24
24
  "none" => [],
25
25
  "uttar_pradesh" => [],
26
26
  "uttarakhand" => [],
27
- "karnataka" => [ { above: r("24999"), monthly: r("200") } ]
27
+ # Inclusive lower bounds. `above: 24999` with a strict `>` taxed a
28
+ # prorated gross of ₹24,999.50, which is below the real threshold.
29
+ "karnataka" => [ { from: r("25000"), monthly: r("200") } ]
28
30
  },
29
31
  income_tax: {
30
32
  "new" => {
31
33
  standard_deduction: r("75000"), rebate_cap: r("1200000"), cess_rate: r("0.04"),
34
+ # §115BAC carries marginal relief just above the rebate cap; the
35
+ # old regime does not, so this is a per-regime flag.
36
+ marginal_relief: true,
32
37
  slabs: [
33
38
  [ r("0"), r("400000"), r("0") ],
34
39
  [ r("400000"), r("800000"), r("0.05") ],
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.5.0"
2
+ VERSION = "0.5.2"
3
3
  end
data/lib/hr_lite.rb CHANGED
@@ -52,8 +52,12 @@ module HrLite
52
52
  user_klass.where("LOWER(#{user_klass.table_name}.email) IN (?)", emails)
53
53
  end
54
54
 
55
+ # Every domain event that bells the admins calls this. Loading and
56
+ # instantiating the entire users table to run admin_check per row is fine
57
+ # at ten staff and silly at a thousand — so it starts from employees_scope
58
+ # (which a host narrows to real staff) rather than every row that exists.
55
59
  def admin_users
56
- user_klass.all.select { |u| admin?(u) }
60
+ employees.select { |u| admin?(u) }
57
61
  end
58
62
 
59
63
  # Everyone HR tracks (host-overridable to exclude bots/test accounts),
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -50,6 +50,7 @@ files:
50
50
  - MIT-LICENSE
51
51
  - README.md
52
52
  - Rakefile
53
+ - app/assets/javascripts/hr_lite/confirm.js
53
54
  - app/assets/javascripts/hr_lite/geo_punch.js
54
55
  - app/assets/javascripts/hr_lite/mention.js
55
56
  - app/assets/stylesheets/hr_lite/application.css
@@ -238,6 +239,9 @@ files:
238
239
  - db/migrate/20260719104317_create_hr_lite_regularization_requests.rb
239
240
  - db/migrate/20260719162154_add_manager_to_hr_lite_employee_profiles.rb
240
241
  - db/migrate/20260720094454_add_employee_code_prefix_to_hr_lite_settings.rb
242
+ - db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb
243
+ - db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb
244
+ - db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb
241
245
  - lib/generators/hr_lite/install/install_generator.rb
242
246
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
243
247
  - lib/generators/hr_lite/install/templates/initializer.rb