hr_lite 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +5 -2
  4. data/app/assets/javascripts/hr_lite/emoji_pick.js +4 -4
  5. data/app/assets/javascripts/hr_lite/vendor/turbo.min.js +28 -0
  6. data/app/assets/stylesheets/hr_lite/hr_lite.css +7 -6
  7. data/app/controllers/hr_lite/admin/attendances_controller.rb +2 -0
  8. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +1 -1
  9. data/app/controllers/hr_lite/admin/documents_controller.rb +1 -1
  10. data/app/controllers/hr_lite/admin/expenses_controller.rb +13 -1
  11. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +1 -1
  12. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +1 -1
  13. data/app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb +2 -1
  14. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +2 -1
  15. data/app/controllers/hr_lite/admin/work_locations_controller.rb +1 -1
  16. data/app/controllers/hr_lite/home_controller.rb +0 -9
  17. data/app/controllers/hr_lite/loans_controller.rb +1 -1
  18. data/app/helpers/hr_lite/application_helper.rb +21 -2
  19. data/app/models/hr_lite/attendance_record.rb +7 -4
  20. data/app/models/hr_lite/salary_slip.rb +2 -1
  21. data/app/models/hr_lite/statutory_rate_card_record.rb +6 -0
  22. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -4
  23. data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
  24. data/app/views/hr_lite/admin/benefits/index.html.erb +10 -4
  25. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +1 -1
  26. data/app/views/hr_lite/admin/documents/index.html.erb +2 -2
  27. data/app/views/hr_lite/admin/employees/show.html.erb +2 -1
  28. data/app/views/hr_lite/admin/expense_categories/index.html.erb +1 -1
  29. data/app/views/hr_lite/admin/expenses/index.html.erb +4 -2
  30. data/app/views/hr_lite/admin/holidays/index.html.erb +2 -1
  31. data/app/views/hr_lite/admin/hr_requests/index.html.erb +2 -2
  32. data/app/views/hr_lite/admin/hr_requests/show.html.erb +1 -1
  33. data/app/views/hr_lite/admin/leave_requests/show.html.erb +1 -1
  34. data/app/views/hr_lite/admin/leave_types/index.html.erb +2 -2
  35. data/app/views/hr_lite/admin/loans/new.html.erb +1 -1
  36. data/app/views/hr_lite/admin/office_locations/index.html.erb +1 -1
  37. data/app/views/hr_lite/admin/overview/index.html.erb +10 -31
  38. data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +2 -1
  39. data/app/views/hr_lite/admin/payroll_runs/index.html.erb +1 -1
  40. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +6 -4
  41. data/app/views/hr_lite/admin/performance_plans/_form.html.erb +2 -1
  42. data/app/views/hr_lite/admin/policies/index.html.erb +1 -1
  43. data/app/views/hr_lite/admin/policies/new.html.erb +2 -1
  44. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +1 -1
  45. data/app/views/hr_lite/admin/roles/_form.html.erb +2 -1
  46. data/app/views/hr_lite/admin/roles/edit.html.erb +4 -2
  47. data/app/views/hr_lite/admin/roles/index.html.erb +1 -1
  48. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +11 -7
  49. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +6 -4
  50. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +1 -1
  51. data/app/views/hr_lite/approvals/index.html.erb +4 -3
  52. data/app/views/hr_lite/attendance/_custom_status.html.erb +7 -9
  53. data/app/views/hr_lite/attendance/_punch_card.html.erb +8 -0
  54. data/app/views/hr_lite/attendance/show.html.erb +5 -3
  55. data/app/views/hr_lite/home/index.html.erb +4 -3
  56. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +2 -1
  57. data/app/views/hr_lite/loans/index.html.erb +1 -1
  58. data/app/views/hr_lite/resignations/show.html.erb +2 -1
  59. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -5
  60. data/app/views/hr_lite/salary_slips/pdf.html.erb +1 -0
  61. data/app/views/hr_lite/work_time/_bars.html.erb +4 -3
  62. data/app/views/layouts/hr_lite/application.html.erb +7 -0
  63. data/app/views/layouts/hr_lite/mailer.html.erb +10 -2
  64. data/app/views/layouts/hr_lite/mailer.text.erb +4 -1
  65. data/config/routes.rb +1 -1
  66. data/lib/hr_lite/amount_in_words.rb +7 -5
  67. data/lib/hr_lite/configuration.rb +4 -0
  68. data/lib/hr_lite/statutory_seeds.rb +1 -1
  69. data/lib/hr_lite/version.rb +1 -1
  70. metadata +2 -1
@@ -44,7 +44,7 @@ module HrLite
44
44
  { label: "Attendance fixes", path: :admin_regularization_requests_path, match: [ "/admin/regularization_requests" ], if: OPS,
45
45
  queue: -> { hrl_queue(HrLite::RegularizationRequest.pending, "attendance.manage") } },
46
46
  { label: "WFH", path: :admin_wfh_requests_path, match: [ "/admin/wfh_requests" ], if: OPS,
47
- queue: -> { hrl_queue(HrLite::WfhRequest.pending, "attendance.manage") } },
47
+ queue: -> { hrl_queue(HrLite::WfhRequest.pending.overlapping(Date.current, Date.current + HrLite::WfhRequest::MAX_DAYS_AHEAD), "attendance.manage") } },
48
48
  { label: "Home locations", path: :admin_work_locations_path, match: [ "/admin/work_locations" ], if: OPS,
49
49
  queue: -> { hrl_queue(HrLite::WorkLocation.pending, "attendance.manage") } },
50
50
  { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ], if: -> { hr_can?("expense.approve", scope: :team) },
@@ -91,7 +91,8 @@ module HrLite
91
91
  { label: "Manage policies", path: :admin_policies_path, match: [ "/admin/policies" ], if: -> { hr_can?("policy.manage", scope: :all) } }
92
92
  ] },
93
93
  { label: "Admin", divider: true, tabs: [
94
- { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting", "/admin/expense_categories" ], if: LEAD },
94
+ { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/expense_categories" ], if: LEAD },
95
+ { label: "HR settings", path: :edit_admin_setting_path, match: [ "/admin/setting" ], if: LEAD },
95
96
  { label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ], if: -> { hr_can?("role.manage", scope: :all) } },
96
97
  { label: "Statutory rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ], if: SUPER },
97
98
  { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ], if: -> { hr_can?("expense.approve", scope: :all) || %w[leave.view attendance.view profile.view].any? { |key| hr_can?(key, scope: :team) } } },
@@ -151,6 +152,24 @@ module HrLite
151
152
  # What a queue page lets this viewer decide: their reach, never their own requests.
152
153
  def hrl_queue(relation, key) = hr_access.scope_relation(relation, key).where.not(user_id: hr_current_user.id)
153
154
 
155
+ # The Inbox badge's number; Home and the overview board show the same one.
156
+ def hrl_inbox_count
157
+ inbox = hrl_sections.find { |s| s[:label] == "Inbox" }
158
+ inbox ? hrl_waiting(inbox).to_i : 0
159
+ end
160
+
161
+ # One queued request in words, and where it is decided.
162
+ def hrl_inbox_item(tab, record)
163
+ case record
164
+ when HrLite::LeaveRequest
165
+ [ "#{record.leave_type.name}, #{record.date_range_label} · #{pluralize(hrl_days(record.days_count), 'day')}", hr_lite.admin_leave_request_path(record) ]
166
+ when HrLite::CompOffRequest then [ "Comp-off for #{record.date_worked.strftime('%a %-d %b')}", hr_lite.admin_comp_off_request_path(record) ]
167
+ when HrLite::RegularizationRequest then [ "Attendance fix, #{record.date.strftime('%a %-d %b')}", hr_lite.admin_regularization_request_path(record) ]
168
+ when HrLite::WfhRequest then [ "WFH, #{record.dates_label}", hr_lite.admin_wfh_request_path(record) ]
169
+ else [ tab[:label], hr_lite.public_send(tab[:path]) ]
170
+ end
171
+ end
172
+
154
173
  # Everything waiting in this viewer's queues, oldest first, as [tab, record].
155
174
  def hrl_inbox_rows(limit = 50)
156
175
  inbox = hrl_sections.find { |s| s[:label] == "Inbox" } or return []
@@ -57,9 +57,11 @@ module HrLite
57
57
  EmployeeProfile.day_minutes_for(user_id, on: date) unless HrLite.config.work_hours.nil? || date.saturday?
58
58
  end
59
59
 
60
- # Hours worked, not the clock: an early start counts.
60
+ # Hours worked, not the clock: an early start counts. The day's penalty
61
+ # minutes count too, the same rule as required_check_out_at, so the card
62
+ # never says "day done" above "short by 20m".
61
63
  def short?
62
- worked_under?(required_minutes)
64
+ worked_under?(required_minutes && required_minutes + penalty_minutes)
63
65
  end
64
66
 
65
67
  def below_half_day?
@@ -70,10 +72,11 @@ module HrLite
70
72
  def required_check_out_at
71
73
  return unless HrLite.config.attendance_slots && check_in_at
72
74
 
73
- extra = AttendancePenalty.active.where(user_id: user_id, date: date).sum(:minutes)
74
- check_in_at + (EmployeeProfile.day_minutes_for(user_id, on: date) + extra).minutes
75
+ check_in_at + (EmployeeProfile.day_minutes_for(user_id, on: date) + penalty_minutes).minutes
75
76
  end
76
77
 
78
+ def penalty_minutes = AttendancePenalty.active.where(user_id: user_id, date: date).sum(:minutes)
79
+
77
80
  def worked_under?(minutes) = !!(minutes && worked_duration && worked_duration < minutes.minutes)
78
81
 
79
82
  # Check in again after checking out: once a day, inside the hours HR sets in
@@ -61,8 +61,9 @@ module HrLite
61
61
  row ? Money.d(row["amount"]) : BigDecimal(0)
62
62
  end
63
63
 
64
+ # Bump the version when the PDF template changes, or the old layout serves until the cache expires.
64
65
  def pdf_cache_key
65
- [ "hr_lite_slip", id, updated_at.to_i, payroll_run.status,
66
+ [ "hr_lite_slip_v2", id, updated_at.to_i, payroll_run.status,
66
67
  user_profile&.updated_at.to_i ].join("/")
67
68
  end
68
69
 
@@ -7,6 +7,9 @@ module HrLite
7
7
 
8
8
  self.table_name = "hr_lite_statutory_rate_cards"
9
9
 
10
+ # Shipped cards count as verified so payroll can auto-approve, but no person checked them.
11
+ SHIPPED_SIGN_OFF = "Shipped defaults, not checked by a person".freeze
12
+
10
13
  validates :effective_from, presence: true, uniqueness: true
11
14
  validate :effective_from_opens_a_financial_year
12
15
  validate :carries_every_figure_payroll_reads
@@ -22,6 +25,9 @@ module HrLite
22
25
 
23
26
  def verified? = verified_by.present? && verified_on.present?
24
27
 
28
+ # Cards seeded before this read "hr_lite 0.52.0 (official source …)": a version is not a sign-off.
29
+ def verified_by = super&.start_with?("hr_lite ") ? SHIPPED_SIGN_OFF : super
30
+
25
31
  # The shape SlipBuilder and the calculators expect: symbol keys and
26
32
  # BigDecimal everywhere, because JSON gives back strings and floats and
27
33
  # a float has no business in a PF calculation.
@@ -15,10 +15,8 @@
15
15
 
16
16
  <div class="hrl-kpis">
17
17
  <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>
18
- <%# Derived from the same predicate as "Checked in". Counting rows instead
19
- dropped a record with no check-in out of BOTH tiles, so the numbers
20
- quietly stopped adding up to the headcount. %>
21
- <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
+ <%# Expected in and not checked in: people on leave, a holiday or a weekly off are not counted. %>
19
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @employees.count { |u| @days[u.id]&.kind == :absent } %></div><div class="hrl-kpi__label">No punch</div></div>
22
20
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @flagged_count %></div><div class="hrl-kpi__label">Flagged</div></div>
23
21
  <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>
24
22
  </div>
@@ -71,6 +69,8 @@
71
69
  <span class="hrl-badge hrl-badge--bad">Flagged</span> <span class="hrl-small"><%= record.flag_note %></span>
72
70
  <% elsif record&.check_in_at %>
73
71
  <span class="hrl-badge hrl-badge--ok"><%= record.status.humanize %><%= " (#{record.half_day_part} half)" if record.half_day_part %></span>
72
+ <% elsif @days[employee.id] %>
73
+ <%= hrl_team_status(@days[employee.id]) %>
74
74
  <% else %>
75
75
  <span class="hrl-badge hrl-badge--muted">No punch</span>
76
76
  <% end %>
@@ -80,7 +80,7 @@
80
80
  </div>
81
81
  <div class="hrl-field">
82
82
  <%= f.label :regularization_note, "Why is this being fixed? (required)" %>
83
- <%= f.text_area :regularization_note %>
83
+ <%= f.text_area :regularization_note, required: true %>
84
84
  </div>
85
85
  <div class="hrl-form-actions">
86
86
  <%= f.submit "Save fix", class: "hrl-btn hrl-btn--primary" %>
@@ -20,7 +20,8 @@
20
20
  <%= form_with url: hr_lite.enrol_admin_benefit_path(benefit), method: :post, local: true do %>
21
21
  <div class="hrl-field">
22
22
  <%= label_tag "enrol_user_#{benefit.id}", "Employee" %>
23
- <%= select_tag :user_id, options_for_select(people), id: "enrol_user_#{benefit.id}" %>
23
+ <%= select_tag :user_id, options_for_select(people), id: "enrol_user_#{benefit.id}", include_blank: "Choose…", required: true,
24
+ data: { hrl_filter: "Type a name to filter" } %>
24
25
  </div>
25
26
  <div class="hrl-field">
26
27
  <%= label_tag "enrol_on_#{benefit.id}", "Covered from" %>
@@ -49,10 +50,15 @@
49
50
  <td data-label="Since"><%= enrolment.enrolled_on.strftime("%d %b %Y") %></td>
50
51
  <td data-label="Dependants" class="hrl-num"><%= enrolment.dependants %></td>
51
52
  <td data-label="">
52
- <%= form_with url: hr_lite.unenrol_admin_benefit_path(enrolment.benefit), method: :post do %>
53
+ <% if enrolment.ended_on %>
54
+ <p class="hrl-small">Cover ends <%= enrolment.ended_on.strftime("%d %b %Y") %></p>
55
+ <% end %>
56
+ <%= form_with url: hr_lite.unenrol_admin_benefit_path(enrolment.benefit), method: :post, class: "hrl-row",
57
+ data: { turbo_confirm: "End #{enrolment.benefit.name} for #{hr_display_name(enrolment.user)} on the date picked?" } do %>
53
58
  <%= hidden_field_tag :user_id, enrolment.user_id %>
54
- <%= date_field_tag :ended_on, Date.current, class: "hrl-input", "aria-label": "Last day of cover" %>
55
- <%= submit_tag "End cover", class: "hrl-btn" %>
59
+ <%= label_tag "ended-on-#{enrolment.id}", "Last day of cover", class: "hrl-small" %>
60
+ <%= date_field_tag :ended_on, enrolment.ended_on || Date.current, id: "ended-on-#{enrolment.id}", class: "hrl-input" %>
61
+ <%= submit_tag enrolment.ended_on ? "Change end" : "End cover", class: "hrl-btn" %>
56
62
  <% end %>
57
63
  </td>
58
64
  </tr>
@@ -51,7 +51,7 @@
51
51
  <%= form_with url: hr_lite.reject_admin_comp_off_request_path(@request), method: :post, local: true do |f| %>
52
52
  <div class="hrl-field">
53
53
  <%= f.label :decision_note, "Reason (required)", for: "reject_note" %>
54
- <%= f.text_area :decision_note, id: "reject_note" %>
54
+ <%= f.text_area :decision_note, id: "reject_note", required: true %>
55
55
  </div>
56
56
  <div class="hrl-form-actions">
57
57
  <%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger" %>
@@ -35,7 +35,7 @@
35
35
  </td>
36
36
  <td data-label="File">
37
37
  <% if doc.readable_by?(hr_current_user) && doc.file.attached? %>
38
- <a class="hrl-small" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
38
+ <a class="hrl-btn" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
39
39
  <% else %>
40
40
  <span class="hrl-small hrl-muted">Not yours to open</span>
41
41
  <% end %>
@@ -44,7 +44,7 @@
44
44
  <%= button_to "Verify", hr_lite.verify_admin_document_path(doc),
45
45
  method: :post, class: "hrl-btn hrl-btn--primary" %>
46
46
  <%= form_with url: hr_lite.reject_admin_document_path(doc), method: :post, local: true do %>
47
- <%= text_field_tag :note, nil, placeholder: "What is wrong with it", class: "hrl-input" %>
47
+ <%= text_field_tag :note, nil, placeholder: "What is wrong with it", class: "hrl-input", required: true %>
48
48
  <%= submit_tag "Reject", class: "hrl-btn" %>
49
49
  <% end %>
50
50
  </td>
@@ -30,7 +30,8 @@
30
30
  <dt>Proposed last day</dt><dd><%= @pending_resignation.proposed_last_day.strftime("%d %B %Y") %></dd>
31
31
  <% if @pending_resignation.reason.present? %><dt>Reason</dt><dd><%= @pending_resignation.reason %></dd><% end %>
32
32
  </dl>
33
- <%= form_with url: hr_lite.accept_admin_resignation_path(@pending_resignation), method: :post, local: true do %>
33
+ <%= form_with url: hr_lite.accept_admin_resignation_path(@pending_resignation), method: :post, local: true,
34
+ data: { turbo_confirm: "Accept #{hr_display_name(@profile.user)}'s resignation? Payroll and attendance end on the last day you set." } do %>
34
35
  <div class="hrl-field">
35
36
  <label for="res_last_day">Confirm last working day</label>
36
37
  <input type="date" id="res_last_day" name="last_day" class="hrl-input"
@@ -18,7 +18,7 @@
18
18
  <td data-label="Monthly cap" class="hrl-num"><%= category.uncapped? ? "No cap" : hrl_money(category.monthly_cap) %></td>
19
19
  <td data-label="Receipt"><%= category.receipt_required ? "Required" : "Optional" %></td>
20
20
  <td data-label="Active"><%= category.active ? "Yes" : "No" %></td>
21
- <td data-label=""><a class="hrl-small" href="<%= hr_lite.edit_admin_expense_category_path(category) %>">Edit</a></td>
21
+ <td data-label=""><a class="hrl-btn" href="<%= hr_lite.edit_admin_expense_category_path(category) %>">Edit</a></td>
22
22
  </tr>
23
23
  <% end %>
24
24
  </tbody>
@@ -26,6 +26,7 @@
26
26
  <div class="hrl-small hrl-muted">
27
27
  <%= expense.description %> · <%= expense.spent_on.strftime("%d %b %Y") %>
28
28
  </div>
29
+ <% if expense.receipt.attached? %><a class="hrl-card__link" href="<%= hr_lite.receipt_admin_expense_path(expense) %>">Open receipt</a><% end %>
29
30
  </td>
30
31
  <td data-label="Amount" class="hrl-num"><%= hrl_money(expense.amount) %></td>
31
32
  <td data-label="">
@@ -33,8 +34,9 @@
33
34
  <%= form_with url: hr_lite.approve_admin_expense_path(expense), method: :post do %>
34
35
  <%= submit_tag "Approve", class: "hrl-btn hrl-btn--primary" %>
35
36
  <% end %>
36
- <%= form_with url: hr_lite.reject_admin_expense_path(expense), method: :post do %>
37
- <%= text_field_tag :decision_note, nil, placeholder: "Why not", class: "hrl-input" %>
37
+ <%= form_with url: hr_lite.reject_admin_expense_path(expense), method: :post, class: "hrl-row hrl-mt" do %>
38
+ <%= label_tag "reject-expense-#{expense.id}", "Reason to reject", class: "hrl-sr-only" %>
39
+ <%= text_field_tag :decision_note, nil, id: "reject-expense-#{expense.id}", placeholder: "Reason to reject", required: true, class: "hrl-input" %>
38
40
  <%= submit_tag "Reject", class: "hrl-btn" %>
39
41
  <% end %>
40
42
  <% elsif expense.approved? %>
@@ -58,7 +58,8 @@
58
58
  <%= select_tag :office_location_id, options_for_select(@offices), include_blank: "All offices", id: "bulk-office", data: { hrl_filter: ("Type an office" if @offices.size > 5) } %></div>
59
59
  <% end %>
60
60
  <div class="hrl-field">
61
- <textarea name="lines" class="hrl-input" rows="6" placeholder="2026-11-09, Diwali&#10;2026-03-04, Holi"></textarea>
61
+ <label for="bulk-lines">One holiday a line: date, name</label>
62
+ <textarea name="lines" id="bulk-lines" class="hrl-input" rows="6" placeholder="2026-11-09, Diwali&#10;2026-03-04, Holi"></textarea>
62
63
  </div>
63
64
  <div class="hrl-form-actions">
64
65
  <button type="submit" class="hrl-btn hrl-btn--primary">Add all</button>
@@ -21,7 +21,7 @@
21
21
  <% @requests.each do |request| %>
22
22
  <tr>
23
23
  <td data-label="Request">
24
- <a href="<%= hr_lite.admin_hr_request_path(request) %>"><%= request.subject %></a>
24
+ <a class="hrl-card__link" href="<%= hr_lite.admin_hr_request_path(request) %>"><%= request.subject %></a>
25
25
  <div class="hrl-small hrl-muted"><%= request.category_label %></div>
26
26
  </td>
27
27
  <td data-label="Who"><%= hr_display_name(request.user) %></td>
@@ -29,7 +29,7 @@
29
29
  <%= request.assigned_to ? hr_display_name(request.assigned_to) : "—" %>
30
30
  </td>
31
31
  <td data-label="">
32
- <a class="hrl-small" href="<%= hr_lite.admin_hr_request_path(request) %>">Open</a>
32
+ <a class="hrl-btn" href="<%= hr_lite.admin_hr_request_path(request) %>">Open</a>
33
33
  </td>
34
34
  </tr>
35
35
  <% end %>
@@ -21,7 +21,7 @@
21
21
  <%= form_with url: hr_lite.resolve_admin_hr_request_path(@request), method: :post do %>
22
22
  <div class="hrl-field">
23
23
  <%= label_tag :resolution, "What you did" %>
24
- <%= text_area_tag :resolution, nil, rows: 4 %>
24
+ <%= text_area_tag :resolution, nil, rows: 4, required: true %>
25
25
  </div>
26
26
  <div class="hrl-form-actions">
27
27
  <%= submit_tag "Send answer", class: "hrl-btn hrl-btn--primary" %>
@@ -76,7 +76,7 @@
76
76
  <%= form_with url: hr_lite.reject_admin_leave_request_path(@request), method: :post, local: true do %>
77
77
  <div class="hrl-field">
78
78
  <label for="reject_note">Reason (required)</label>
79
- <textarea name="decision_note" id="reject_note" class="hrl-input"></textarea>
79
+ <textarea name="decision_note" id="reject_note" class="hrl-input" required></textarea>
80
80
  </div>
81
81
  <div class="hrl-form-actions">
82
82
  <button class="hrl-btn hrl-btn--danger" type="submit">Reject</button>
@@ -23,7 +23,7 @@
23
23
  <td data-label="Carry cap" class="hrl-num"><%= type.carry_forward_cap.to_f %></td>
24
24
  <td data-label="Paid"><%= type.paid ? "Yes" : "No" %></td>
25
25
  <td data-label="">
26
- <a class="hrl-small" href="<%= hr_lite.edit_admin_leave_type_path(type) %>">Edit</a>
26
+ <a class="hrl-btn" href="<%= hr_lite.edit_admin_leave_type_path(type) %>">Edit</a>
27
27
  </td>
28
28
  </tr>
29
29
  <% end %>
@@ -36,5 +36,5 @@
36
36
  <a class="hrl-btn" href="<%= hr_lite.admin_office_locations_path %>">Office locations</a>
37
37
  <a class="hrl-btn" href="<%= hr_lite.admin_holidays_path %>">Holidays</a>
38
38
  <a class="hrl-btn" href="<%= hr_lite.admin_expense_categories_path %>">Expense categories</a>
39
- <a class="hrl-btn" href="<%= hr_lite.edit_admin_setting_path %>">Weekend policy</a>
39
+ <a class="hrl-btn" href="<%= hr_lite.edit_admin_setting_path %>">HR settings</a>
40
40
  </div>
@@ -13,7 +13,7 @@
13
13
  <%= f.label :user_id, "Employee" %>
14
14
  <%= f.select :user_id, options_for_select(HrLite.employees.map { |u| [ hr_display_name(u), u.id ] },
15
15
  @loan.user_id),
16
- {}, data: { hrl_filter: "Type a name to filter" } %>
16
+ { include_blank: "Choose…" }, required: true, data: { hrl_filter: "Type a name to filter" } %>
17
17
  </div>
18
18
  <div class="hrl-field">
19
19
  <%= f.label :principal, "Amount lent (₹)" %>
@@ -18,7 +18,7 @@
18
18
  <td data-label="Coordinates"><%= office.lat %>, <%= office.lng %></td>
19
19
  <td data-label="Radius" class="hrl-num"><%= office.radius_m %> m</td>
20
20
  <td data-label="Active"><%= office.active ? "Yes" : "No" %></td>
21
- <td data-label=""><a class="hrl-small" href="<%= hr_lite.edit_admin_office_location_path(office) %>">Edit</a></td>
21
+ <td data-label=""><a class="hrl-btn" href="<%= hr_lite.edit_admin_office_location_path(office) %>">Edit</a></td>
22
22
  </tr>
23
23
  <% end %>
24
24
  </tbody>
@@ -3,50 +3,29 @@
3
3
  <h1 class="hrl-page__title">HR overview</h1>
4
4
  </div>
5
5
 
6
+ <%# The Inbox badge's count and rows, so the board never counts what the Inbox hides. %>
7
+ <% pending = hrl_inbox_count %>
6
8
  <div class="hrl-kpis">
7
- <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:pending] %></div><div class="hrl-kpi__label">Pending approvals</div></div>
9
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= pending %></div><div class="hrl-kpi__label">Pending approvals</div></div>
8
10
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:on_leave] %></div><div class="hrl-kpi__label">On leave today</div></div>
9
11
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:flagged] %></div><div class="hrl-kpi__label">Flagged punches</div></div>
10
12
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:missing_checkout] %></div><div class="hrl-kpi__label">Missing checkouts</div></div>
11
13
  </div>
12
14
 
13
- <% if @query.empty? %>
15
+ <% if pending.zero? && @kpis.except(:pending).values.all?(&:zero?) %>
14
16
  <p class="hrl-card hrl-card--empty">All present and accounted for.</p>
15
17
  <% else %>
16
- <% if @kpis[:pending].positive? %>
18
+ <% if pending.positive? %>
17
19
  <section class="hrl-card">
18
20
  <h2 class="hrl-card__title">Pending approvals</h2>
19
- <% @query.pending_requests.limit(HrLite::Admin::OverviewController::SECTION_CAP).each do |request| %>
21
+ <% hrl_inbox_rows(HrLite::Admin::OverviewController::SECTION_CAP).each do |tab, request| %>
22
+ <% what, href = hrl_inbox_item(tab, request) %>
20
23
  <div class="hrl-feed__item">
21
- <div class="hrl-feed__body">
22
- <strong><%= hr_display_name(request.user) %></strong>
23
- — <%= request.leave_type.name %>, <%= request.date_range_label %> (<%= hrl_days(request.days_count) %> d)
24
- </div>
25
- <a class="hrl-btn" href="<%= hr_lite.admin_leave_request_path(request) %>">Decide</a>
26
- </div>
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
- (<%= hrl_days(request.credit_days) %> 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>
24
+ <div class="hrl-feed__body"><strong><%= hr_display_name(request.user) %></strong> — <%= what %></div>
25
+ <a class="hrl-btn" href="<%= href %>">Decide</a>
47
26
  </div>
48
27
  <% end %>
49
- <a class="hrl-card__link" href="<%= hr_lite.admin_leave_requests_path %>">All requests</a>
28
+ <a class="hrl-card__link" href="<%= hr_lite.approvals_path %>">All requests</a>
50
29
  </section>
51
30
  <% end %>
52
31
 
@@ -13,7 +13,8 @@
13
13
  <div class="hrl-field">
14
14
  <%= f.label :user_id, "Employee" %>
15
15
  <%= f.select :user_id, options_for_select(HrLite.employees.map { |u| [ hr_display_name(u), u.id ] },
16
- @item.user_id) %>
16
+ @item.user_id),
17
+ { include_blank: "Choose…" }, required: true, data: { hrl_filter: "Type a name to filter" } %>
17
18
  </div>
18
19
  <div class="hrl-field">
19
20
  <%= f.label :component_id, "Item" %>
@@ -2,7 +2,7 @@
2
2
  <div class="hrl-page__head">
3
3
  <h1 class="hrl-page__title">Payroll runs</h1>
4
4
  <div class="hrl-page__actions">
5
- <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_payroll_run_path %>">New run</a>
5
+ <% if hr_can?("payroll.manage", scope: :all) %><a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_payroll_run_path %>">New run</a><% end %>
6
6
  </div>
7
7
  </div>
8
8
 
@@ -7,11 +7,11 @@
7
7
  </div>
8
8
  </div>
9
9
 
10
- <div class="hrl-kpis">
10
+ <div class="hrl-kpis hrl-kpis--money">
11
11
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @slips.size %></div><div class="hrl-kpi__label">Slips</div></div>
12
12
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_money(@run.total_gross) %></div><div class="hrl-kpi__label">Gross</div></div>
13
13
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_money(@run.total_net) %></div><div class="hrl-kpi__label">Net payout</div></div>
14
- <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_money(@run.total_employer_cost) %></div><div class="hrl-kpi__label">Employer cost</div></div>
14
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_money(@run.total_employer_cost) %></div><div class="hrl-kpi__label">Employer contributions</div></div>
15
15
  </div>
16
16
 
17
17
  <% if @run.warnings.any? %>
@@ -29,12 +29,14 @@
29
29
  <% manager = hr_can?("payroll.manage", scope: :all) %>
30
30
  <% if manager && (@run.draft? || @run.review? || @run.processing?) %>
31
31
  <%= button_to @run.draft? ? "Compute" : "Recompute", hr_lite.compute_admin_payroll_run_path(@run),
32
- method: :post, class: "hrl-btn" %>
32
+ method: :post, class: "hrl-btn",
33
+ form: { data: { turbo_confirm: ("Recompute #{@run.label}? This clears #{hr_display_name(@run.first_approved_by)}'s approval." if @run.first_approved_by) } } %>
33
34
  <% end %>
34
35
  <% if @run.review? && hr_can?("payroll.approve", scope: :all) && @run.first_approved_by_id != hr_current_user.id %>
35
36
  <%= button_to @run.first_approved_by_id ? "Approve and publish" : "Approve", hr_lite.approve_admin_payroll_run_path(@run),
36
37
  method: :post, class: "hrl-btn hrl-btn--primary",
37
- form: { data: { turbo_confirm: @run.first_approved_by_id ? "Approve #{@run.label}? This finalizes the run and publishes the slips." : "Approve #{@run.label}?" } } %>
38
+ form: { data: { turbo_confirm: [ @run.first_approved_by_id ? "Approve #{@run.label}? This finalizes the run and publishes the slips." : "Approve #{@run.label}?",
39
+ *("Blocking warnings: #{@run.blocking_warnings.join('; ')}" if @run.blocking_warnings.any?) ].join("\n\n") } } %>
38
40
  <% end %>
39
41
  <% if manager && @run.finalized? %>
40
42
  <%= button_to "Unlock", hr_lite.unlock_admin_payroll_run_path(@run), method: :post, class: "hrl-btn" %>
@@ -22,7 +22,8 @@
22
22
  <div class="hrl-field"><%= f.label :outcome_note, "Outcome note" %><%= f.text_area :outcome_note, rows: 3 %></div>
23
23
  <% end %>
24
24
  <div class="hrl-form-actions">
25
- <%= f.submit plan.persisted? ? "Save" : "Start PIP", class: "hrl-btn hrl-btn--primary" %>
25
+ <%= f.submit plan.persisted? ? "Save" : "Start PIP", class: "hrl-btn hrl-btn--primary",
26
+ data: { turbo_confirm: ("Save? Passed or failed closes the plan for good and tells the employee." if plan.persisted?) } %>
26
27
  <a class="hrl-btn" href="<%= hr_lite.admin_performance_plans_path %>">Cancel</a>
27
28
  </div>
28
29
  <% end %>
@@ -14,7 +14,7 @@
14
14
  <tbody>
15
15
  <% @policies.each do |policy| %>
16
16
  <tr>
17
- <td data-label="Title"><%= policy.title %> <span class="hrl-badge hrl-badge--muted">v<%= policy.version %></span></td>
17
+ <td data-label="Title"><a class="hrl-card__link" href="<%= hr_lite.policy_path(policy) %>"><%= policy.title %></a> <span class="hrl-badge hrl-badge--muted">v<%= policy.version %></span></td>
18
18
  <td data-label="Kind"><%= policy.announcement? ? "Announcement" : "Policy" %></td>
19
19
  <td data-label="In force"><%= policy.effective_from.strftime("%d %b %Y") %></td>
20
20
  <td data-label="Still to acknowledge"><%= policy.announcement? ? "—" : policy.outstanding_for.count %></td>
@@ -15,7 +15,8 @@
15
15
  <%= f.label :acknowledgement_required, "Policy — everyone must acknowledge it (untick for an announcement)" %>
16
16
  </div>
17
17
  <div class="hrl-form-actions">
18
- <%= f.submit "Publish", class: "hrl-btn hrl-btn--primary" %>
18
+ <%= f.submit "Publish", class: "hrl-btn hrl-btn--primary",
19
+ data: { turbo_confirm: "Publish and notify #{pluralize(HrLite.active_employees.size, 'person')} by email?" } %>
19
20
  <a class="hrl-btn" href="<%= hr_lite.admin_policies_path %>">Cancel</a>
20
21
  </div>
21
22
  <% end %>
@@ -54,7 +54,7 @@
54
54
  <%= form_with url: hr_lite.reject_admin_regularization_request_path(@request), method: :post, local: true do |f| %>
55
55
  <div class="hrl-field">
56
56
  <%= f.label :decision_note, "Reason (required)", for: "reject_note" %>
57
- <%= f.text_area :decision_note, id: "reject_note" %>
57
+ <%= f.text_area :decision_note, id: "reject_note", required: true %>
58
58
  </div>
59
59
  <div class="hrl-form-actions">
60
60
  <%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger" %>
@@ -40,7 +40,8 @@
40
40
  <% end %>
41
41
 
42
42
  <div class="hrl-form-actions">
43
- <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
43
+ <%= f.submit "Save", class: "hrl-btn hrl-btn--primary",
44
+ data: { turbo_confirm: ("Save? Everyone holding #{role.name} gets these permissions at once." if role.persisted?) } %>
44
45
  <a class="hrl-btn" href="<%= hr_lite.admin_roles_path %>">Cancel</a>
45
46
  </div>
46
47
  <% end %>
@@ -38,13 +38,15 @@
38
38
  </div>
39
39
  <% end %>
40
40
 
41
- <%= form_with url: hr_lite.admin_role_assignments_path(@role), method: :post, local: true do %>
41
+ <%= form_with url: hr_lite.admin_role_assignments_path(@role), method: :post, local: true,
42
+ data: { turbo_confirm: "Give the #{@role.name} role to the person you picked? Their access changes at once." } do %>
42
43
  <div class="hrl-field">
43
44
  <%= label_tag :user_id, "Add somebody to this role" %>
44
45
  <% held = @role.role_assignments.map(&:user_id) %>
45
46
  <%= select_tag :user_id,
46
47
  options_for_select(HrLite.employees.reject { |u| held.include?(u.id) }
47
- .map { |u| [ hr_display_name(u), u.id ] }) %>
48
+ .map { |u| [ hr_display_name(u), u.id ] }),
49
+ include_blank: "Choose…", required: true, data: { hrl_filter: "Type a name to filter" } %>
48
50
  </div>
49
51
  <div class="hrl-form-actions">
50
52
  <%= submit_tag "Add", class: "hrl-btn hrl-btn--primary" %>
@@ -39,7 +39,7 @@
39
39
  <% end %>
40
40
  </td>
41
41
  <td data-label="">
42
- <a class="hrl-small" href="<%= hr_lite.edit_admin_role_path(role) %>">Edit</a>
42
+ <a class="hrl-btn" href="<%= hr_lite.edit_admin_role_path(role) %>">Edit</a>
43
43
  </td>
44
44
  </tr>
45
45
  <% end %>
@@ -68,16 +68,20 @@
68
68
  the open-ended top rate.
69
69
  </p>
70
70
  <% slabs = Array(table["slabs"]) %>
71
- <% (slabs + [ nil, nil ]).each_with_index do |row, index| %>
72
- <div class="hrl-row">
71
+ <% num = ->(v) { v && number_with_precision(v, precision: 4, strip_insignificant_zeros: true) } %>
72
+ <div class="hrl-slabs">
73
+ <span class="hrl-small" aria-hidden="true">From ₹</span>
74
+ <span class="hrl-small" aria-hidden="true">Up to ₹</span>
75
+ <span class="hrl-small" aria-hidden="true">Rate (0.05 = 5%)</span>
76
+ <% (slabs + [ nil, nil ]).each_with_index do |row, index| %>
73
77
  <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][from]",
74
- row&.first, placeholder: "From ₹", inputmode: "decimal" %>
78
+ num.(row&.first), class: "hrl-input", inputmode: "decimal", "aria-label": "Band #{index + 1} from ₹" %>
75
79
  <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][to]",
76
- row&.at(1), placeholder: "Up to ₹ (blank = top)", inputmode: "decimal" %>
80
+ num.(row&.at(1)), class: "hrl-input", inputmode: "decimal", "aria-label": "Band #{index + 1} up to ₹ (blank = top)" %>
77
81
  <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][rate]",
78
- row&.last, placeholder: "Rate (0.05)", inputmode: "decimal" %>
79
- </div>
80
- <% end %>
82
+ num.(row&.last), class: "hrl-input", inputmode: "decimal", "aria-label": "Band #{index + 1} rate" %>
83
+ <% end %>
84
+ </div>
81
85
  </fieldset>
82
86
  <% end %>
83
87
 
@@ -48,6 +48,7 @@
48
48
  <% end %>
49
49
  <div class="hrl-form-actions">
50
50
  <%= f.submit "Record amounts", class: "hrl-btn" %>
51
+ <%= f.submit "Record and verify", name: "verify", class: "hrl-btn" %>
51
52
  </div>
52
53
  </section>
53
54
  <% end %>
@@ -55,16 +56,17 @@
55
56
  <section class="hrl-card">
56
57
  <h2 class="hrl-card__title">Decide</h2>
57
58
  <div class="hrl-row">
59
+ <% unproven = @declaration.tax_declaration_items.reject { |item| item.proofs.attached? }.map { |item| item.section_label.split(" — ").first } %>
58
60
  <%= button_to "Accept all and verify", hr_lite.accept_all_admin_tax_declaration_path(@declaration),
59
- method: :post, class: "hrl-btn hrl-btn--primary" %>
60
- <%= button_to "Verify the amounts recorded above", hr_lite.verify_admin_tax_declaration_path(@declaration),
61
- method: :post, class: "hrl-btn" %>
61
+ method: :post, class: "hrl-btn hrl-btn--primary",
62
+ form: { data: { turbo_confirm: [ "Accept every claimed amount and verify? The employee can no longer edit it.",
63
+ ("No proof attached: #{unproven.to_sentence}." if unproven.any?) ].compact.join(" ") } } %>
62
64
  </div>
63
65
  <%= form_with url: hr_lite.reject_admin_tax_declaration_path(@declaration),
64
66
  method: :post, local: true do %>
65
67
  <div class="hrl-field">
66
68
  <%= label_tag :note, "What is missing (required to send back)" %>
67
- <%= text_field_tag :note %>
69
+ <%= text_field_tag :note, nil, class: "hrl-input" %>
68
70
  </div>
69
71
  <%= submit_tag "Send back", class: "hrl-btn" %>
70
72
  <% end %>