hr_lite 0.5.0 → 0.5.3

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +218 -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 +19 -5
  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 +19 -13
  10. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +16 -0
  11. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
  12. data/app/controllers/hr_lite/admin/resignations_controller.rb +17 -5
  13. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +22 -8
  14. data/app/controllers/hr_lite/admin/superadmin_controller.rb +5 -0
  15. data/app/controllers/hr_lite/org_controller.rb +6 -1
  16. data/app/controllers/hr_lite/resignations_controller.rb +6 -1
  17. data/app/helpers/hr_lite/application_helper.rb +2 -12
  18. data/app/jobs/hr_lite/application_job.rb +5 -0
  19. data/app/jobs/hr_lite/daily_digest_job.rb +1 -1
  20. data/app/jobs/hr_lite/leave_year_rollover_job.rb +1 -1
  21. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +5 -2
  22. data/app/models/concerns/hr_lite/audited.rb +11 -4
  23. data/app/models/hr_lite/appraisal.rb +9 -1
  24. data/app/models/hr_lite/audit_log.rb +36 -0
  25. data/app/models/hr_lite/comp_off_request.rb +8 -16
  26. data/app/models/hr_lite/designation_change.rb +18 -1
  27. data/app/models/hr_lite/employee_profile.rb +5 -1
  28. data/app/models/hr_lite/leave_balance.rb +51 -5
  29. data/app/models/hr_lite/leave_request.rb +36 -5
  30. data/app/models/hr_lite/leave_type.rb +13 -3
  31. data/app/models/hr_lite/payroll_run.rb +68 -13
  32. data/app/models/hr_lite/regularization_request.rb +11 -4
  33. data/app/models/hr_lite/resignation.rb +27 -0
  34. data/app/models/hr_lite/salary_slip.rb +8 -5
  35. data/app/services/hr_lite/attendance_puncher.rb +8 -1
  36. data/app/services/hr_lite/calculators/esi.rb +7 -4
  37. data/app/services/hr_lite/calculators/professional_tax.rb +5 -1
  38. data/app/services/hr_lite/calculators/tds.rb +8 -1
  39. data/app/services/hr_lite/leave_day_counter.rb +9 -4
  40. data/app/services/hr_lite/overview_query.rb +12 -1
  41. data/app/services/hr_lite/payroll_run_processor.rb +9 -1
  42. data/app/services/hr_lite/slip_builder.rb +54 -8
  43. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
  44. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +19 -5
  45. data/app/views/hr_lite/admin/employees/_form.html.erb +20 -2
  46. data/app/views/hr_lite/admin/employees/show.html.erb +25 -15
  47. data/app/views/hr_lite/admin/leave_requests/show.html.erb +37 -5
  48. data/app/views/hr_lite/admin/overview/index.html.erb +21 -0
  49. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +3 -1
  50. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +18 -4
  51. data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -2
  52. data/app/views/hr_lite/career/show.html.erb +7 -1
  53. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -1
  54. data/app/views/hr_lite/salary_slips/pdf.html.erb +5 -5
  55. data/app/views/layouts/hr_lite/application.html.erb +42 -4
  56. data/config/routes.rb +1 -1
  57. data/db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb +28 -0
  58. data/db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb +14 -0
  59. data/db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb +12 -0
  60. data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
  61. data/lib/hr_lite/amount_in_words.rb +15 -2
  62. data/lib/hr_lite/configuration.rb +3 -6
  63. data/lib/hr_lite/financial_year.rb +26 -0
  64. data/lib/hr_lite/money.rb +18 -0
  65. data/lib/hr_lite/notifications.rb +23 -8
  66. data/lib/hr_lite/statutory_rate_card.rb +51 -6
  67. data/lib/hr_lite/version.rb +1 -1
  68. data/lib/hr_lite.rb +23 -2
  69. metadata +7 -1
@@ -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
- lop = Money.d(@lop_override || summary[:lop_days])
25
- payable = [ Money.d(days_in_month) - Money.d(summary[:out_of_window]) - lop, Money.d(0) ].max
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: @structure.monthly_gross, gross_earned: gross_earned,
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
- fy_gross_paid: fy[:gross],
64
- fy_tds_paid: fy[:tds],
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
- net_pay: gross_earned - total_deductions,
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 ? (15 - month) : (3 - month + 1)
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
- <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @employees.size - @records.size %></div><div class="hrl-kpi__label">No punch</div></div>
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">Decide</h2>
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 (required to reject, optional to approve)" %>
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
- <%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger",
43
- formaction: hr_lite.reject_admin_comp_off_request_path(@request) %>
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
- HrLite.active_employees.reject { |u| u.id == profile.user_id }
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
- <% if @profile.date_of_exit.nil? %>
39
- <section class="hrl-card">
40
- <h2 class="hrl-card__title">Offboard</h2>
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
- <%= form_with url: hr_lite.offboard_admin_employee_path(@profile), method: :post, local: true,
44
- html: { data: { turbo_confirm: "Offboard #{hr_display_name(@profile.user)}?" } } do %>
45
- <div class="hrl-field">
46
- <label for="off_date">Last working day</label>
47
- <input type="date" id="off_date" name="date_of_exit" class="hrl-input" value="<%= Date.current %>">
48
- </div>
49
- <div class="hrl-form-actions">
50
- <button class="hrl-btn hrl-btn--danger" type="submit">Offboard employee</button>
51
- </div>
52
- <% end %>
53
- </section>
54
- <% end %>
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
- <dt>Working days</dt><dd><%= @request.days_count.to_f %></dd>
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="decision_note">Note (required to reject)</label>
30
- <textarea name="decision_note" id="decision_note" class="hrl-input"></textarea>
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
- <button class="hrl-btn hrl-btn--danger" type="submit"
35
- formaction="<%= hr_lite.reject_admin_leave_request_path(@request) %>">Reject</button>
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>
@@ -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