hr_lite 0.53.0 → 0.55.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +5 -2
  4. data/app/assets/javascripts/hr_lite/confirm.js +9 -5
  5. data/app/assets/javascripts/hr_lite/countdown.js +9 -2
  6. data/app/assets/javascripts/hr_lite/emoji_pick.js +4 -4
  7. data/app/assets/javascripts/hr_lite/search.js +1 -1
  8. data/app/assets/stylesheets/hr_lite/hr_lite.css +24 -14
  9. data/app/controllers/hr_lite/admin/attendances_controller.rb +12 -3
  10. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +1 -0
  11. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +1 -1
  12. data/app/controllers/hr_lite/admin/documents_controller.rb +1 -1
  13. data/app/controllers/hr_lite/admin/employees_controller.rb +11 -4
  14. data/app/controllers/hr_lite/admin/expenses_controller.rb +17 -1
  15. data/app/controllers/hr_lite/admin/hr_requests_controller.rb +4 -0
  16. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +5 -2
  17. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +9 -5
  18. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +1 -1
  19. data/app/controllers/hr_lite/admin/reports_controller.rb +3 -2
  20. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +2 -0
  21. data/app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb +2 -1
  22. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +15 -4
  23. data/app/controllers/hr_lite/admin/work_locations_controller.rb +1 -1
  24. data/app/controllers/hr_lite/approvals_controller.rb +1 -0
  25. data/app/controllers/hr_lite/home_controller.rb +2 -11
  26. data/app/controllers/hr_lite/loans_controller.rb +1 -1
  27. data/app/controllers/hr_lite/regularization_requests_controller.rb +4 -4
  28. data/app/controllers/hr_lite/tax_declarations_controller.rb +9 -5
  29. data/app/helpers/hr_lite/application_helper.rb +34 -9
  30. data/app/jobs/hr_lite/attendance_close_job.rb +1 -1
  31. data/app/jobs/hr_lite/payroll_compute_job.rb +12 -0
  32. data/app/models/concerns/hr_lite/audited.rb +3 -1
  33. data/app/models/concerns/hr_lite/encrypted_money.rb +8 -5
  34. data/app/models/hr_lite/attendance_record.rb +21 -4
  35. data/app/models/hr_lite/comp_off_request.rb +2 -0
  36. data/app/models/hr_lite/document.rb +1 -1
  37. data/app/models/hr_lite/expense.rb +3 -0
  38. data/app/models/hr_lite/hr_request.rb +2 -0
  39. data/app/models/hr_lite/kudo.rb +2 -2
  40. data/app/models/hr_lite/leave_request.rb +25 -6
  41. data/app/models/hr_lite/payroll_line_item.rb +8 -0
  42. data/app/models/hr_lite/payroll_run.rb +15 -6
  43. data/app/models/hr_lite/regularization_request.rb +2 -2
  44. data/app/models/hr_lite/resignation.rb +5 -1
  45. data/app/models/hr_lite/role.rb +5 -0
  46. data/app/models/hr_lite/salary_slip.rb +2 -1
  47. data/app/models/hr_lite/statutory_rate_card_record.rb +6 -0
  48. data/app/models/hr_lite/tax_declaration.rb +5 -0
  49. data/app/services/hr_lite/attendance_puncher.rb +4 -0
  50. data/app/services/hr_lite/overview_query.rb +0 -12
  51. data/app/services/hr_lite/work_status.rb +5 -2
  52. data/app/views/hr_lite/admin/assets/index.html.erb +9 -2
  53. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -4
  54. data/app/views/hr_lite/admin/attendances/show.html.erb +4 -2
  55. data/app/views/hr_lite/admin/attendances/summary.html.erb +5 -2
  56. data/app/views/hr_lite/admin/audit_logs/index.html.erb +6 -4
  57. data/app/views/hr_lite/admin/benefits/index.html.erb +10 -4
  58. data/app/views/hr_lite/admin/ca_reviews/index.html.erb +3 -3
  59. data/app/views/hr_lite/admin/checklists/index.html.erb +3 -3
  60. data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +1 -2
  61. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +6 -6
  62. data/app/views/hr_lite/admin/designation_changes/new.html.erb +4 -3
  63. data/app/views/hr_lite/admin/documents/index.html.erb +9 -4
  64. data/app/views/hr_lite/admin/documents/show.html.erb +1 -0
  65. data/app/views/hr_lite/admin/employees/_form.html.erb +12 -5
  66. data/app/views/hr_lite/admin/employees/show.html.erb +40 -33
  67. data/app/views/hr_lite/admin/expense_categories/index.html.erb +1 -1
  68. data/app/views/hr_lite/admin/expenses/index.html.erb +7 -4
  69. data/app/views/hr_lite/admin/holidays/index.html.erb +7 -5
  70. data/app/views/hr_lite/admin/hr_requests/index.html.erb +3 -3
  71. data/app/views/hr_lite/admin/hr_requests/show.html.erb +5 -4
  72. data/app/views/hr_lite/admin/leave_balances/index.html.erb +6 -4
  73. data/app/views/hr_lite/admin/leave_requests/index.html.erb +1 -2
  74. data/app/views/hr_lite/admin/leave_requests/show.html.erb +6 -4
  75. data/app/views/hr_lite/admin/leave_types/edit.html.erb +4 -4
  76. data/app/views/hr_lite/admin/leave_types/index.html.erb +5 -5
  77. data/app/views/hr_lite/admin/loans/index.html.erb +1 -1
  78. data/app/views/hr_lite/admin/loans/new.html.erb +1 -1
  79. data/app/views/hr_lite/admin/loans/show.html.erb +8 -12
  80. data/app/views/hr_lite/admin/office_locations/edit.html.erb +4 -4
  81. data/app/views/hr_lite/admin/office_locations/index.html.erb +1 -1
  82. data/app/views/hr_lite/admin/overview/index.html.erb +10 -31
  83. data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +2 -1
  84. data/app/views/hr_lite/admin/payroll_runs/index.html.erb +3 -3
  85. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +15 -10
  86. data/app/views/hr_lite/admin/performance_plans/_form.html.erb +2 -1
  87. data/app/views/hr_lite/admin/policies/index.html.erb +2 -2
  88. data/app/views/hr_lite/admin/policies/new.html.erb +4 -3
  89. data/app/views/hr_lite/admin/regularization_requests/index.html.erb +3 -4
  90. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +5 -6
  91. data/app/views/hr_lite/admin/reports/index.html.erb +3 -6
  92. data/app/views/hr_lite/admin/reports/show.html.erb +16 -2
  93. data/app/views/hr_lite/admin/roles/_form.html.erb +8 -6
  94. data/app/views/hr_lite/admin/roles/edit.html.erb +4 -2
  95. data/app/views/hr_lite/admin/roles/index.html.erb +1 -1
  96. data/app/views/hr_lite/admin/salary_slips/show.html.erb +3 -2
  97. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +1 -0
  98. data/app/views/hr_lite/admin/settings/edit.html.erb +5 -1
  99. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +14 -10
  100. data/app/views/hr_lite/admin/statutory_rate_cards/index.html.erb +3 -1
  101. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +1 -1
  102. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +8 -4
  103. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +17 -16
  104. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +3 -5
  105. data/app/views/hr_lite/admin/work_locations/index.html.erb +1 -1
  106. data/app/views/hr_lite/admin/work_modes/index.html.erb +1 -1
  107. data/app/views/hr_lite/approvals/index.html.erb +5 -4
  108. data/app/views/hr_lite/attendance/_custom_status.html.erb +7 -9
  109. data/app/views/hr_lite/attendance/_month_grid.html.erb +9 -9
  110. data/app/views/hr_lite/attendance/_penalties.html.erb +2 -2
  111. data/app/views/hr_lite/attendance/_punch_card.html.erb +10 -4
  112. data/app/views/hr_lite/attendance/_slot.html.erb +9 -6
  113. data/app/views/hr_lite/attendance/_trail.html.erb +1 -1
  114. data/app/views/hr_lite/attendance/_work_mode.html.erb +1 -1
  115. data/app/views/hr_lite/attendance/show.html.erb +7 -5
  116. data/app/views/hr_lite/calendar/show.html.erb +2 -2
  117. data/app/views/hr_lite/comp_off_requests/index.html.erb +1 -1
  118. data/app/views/hr_lite/comp_off_requests/new.html.erb +1 -1
  119. data/app/views/hr_lite/documents/_upload_form.html.erb +1 -1
  120. data/app/views/hr_lite/employee_profiles/show.html.erb +4 -2
  121. data/app/views/hr_lite/home/index.html.erb +6 -5
  122. data/app/views/hr_lite/id_cards/show.html.erb +5 -1
  123. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +3 -2
  124. data/app/views/hr_lite/leave_requests/new.html.erb +17 -8
  125. data/app/views/hr_lite/leave_requests/show.html.erb +2 -0
  126. data/app/views/hr_lite/loans/index.html.erb +1 -1
  127. data/app/views/hr_lite/org/show.html.erb +1 -1
  128. data/app/views/hr_lite/regularization_requests/index.html.erb +7 -7
  129. data/app/views/hr_lite/regularization_requests/new.html.erb +7 -5
  130. data/app/views/hr_lite/resignations/show.html.erb +2 -1
  131. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +7 -7
  132. data/app/views/hr_lite/salary_slips/pdf.html.erb +2 -1
  133. data/app/views/hr_lite/tax/_computation.html.erb +1 -1
  134. data/app/views/hr_lite/tax_declarations/show.html.erb +4 -4
  135. data/app/views/hr_lite/team/show.html.erb +8 -7
  136. data/app/views/hr_lite/timelines/show.html.erb +1 -0
  137. data/app/views/hr_lite/wfh_requests/index.html.erb +3 -3
  138. data/app/views/hr_lite/work_time/_bars.html.erb +5 -4
  139. data/app/views/hr_lite/work_time/_donut.html.erb +1 -1
  140. data/app/views/hr_lite/work_time/_report.html.erb +14 -8
  141. data/app/views/layouts/hr_lite/application.html.erb +1 -0
  142. data/app/views/layouts/hr_lite/mailer.html.erb +10 -2
  143. data/app/views/layouts/hr_lite/mailer.text.erb +4 -1
  144. data/config/routes.rb +1 -1
  145. data/lib/hr_lite/amount_in_words.rb +7 -5
  146. data/lib/hr_lite/notifications.rb +5 -2
  147. data/lib/hr_lite/statutory_seeds.rb +1 -1
  148. data/lib/hr_lite/version.rb +1 -1
  149. metadata +2 -1
@@ -135,7 +135,7 @@ module HrLite
135
135
  transition!("cancelled", actor, nil)
136
136
  Notifications.publish(
137
137
  "regularization.cancelled",
138
- title: "#{HrLite.display_name(user)} cancelled a regularization ticket (#{date.strftime('%d %b')})",
138
+ title: "#{HrLite.display_name(user)} cancelled a day fix request (#{date.strftime('%d %b')})",
139
139
  path: "/admin/regularization_requests",
140
140
  bell_to: HrLite.admin_users
141
141
  )
@@ -220,7 +220,7 @@ module HrLite
220
220
  def notify_requested
221
221
  Notifications.publish(
222
222
  "regularization.requested",
223
- title: "#{HrLite.display_name(user)} raised a regularization ticket for #{date.strftime('%d %b')} " \
223
+ title: "#{HrLite.display_name(user)} asked to fix #{date.strftime('%d %b')} " \
224
224
  "(#{times_label})",
225
225
  body: reason.presence,
226
226
  path: "/admin/regularization_requests/#{id}",
@@ -20,6 +20,7 @@ module HrLite
20
20
  STATUSES.each { |s| define_method("#{s}?") { status == s } }
21
21
 
22
22
  after_create :notify_submitted
23
+ after_create { audit!("submitted", user) }
23
24
 
24
25
  def accept!(actor:, last_day: nil, note: nil)
25
26
  raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
@@ -34,6 +35,7 @@ module HrLite
34
35
  # nothing was stamped, because the screen used to claim it was.
35
36
  @exit_date_recorded = profile.present?
36
37
  profile&.update!(date_of_exit: final_day)
38
+ audit!("accepted", actor)
37
39
  end
38
40
 
39
41
  # Notice periods are the normal case, so access is revoked here only
@@ -55,7 +57,7 @@ module HrLite
55
57
  def withdraw!(actor:)
56
58
  raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending? && actor.id == user_id
57
59
 
58
- update!(status: "withdrawn", decided_at: Time.current)
60
+ transaction { update!(status: "withdrawn", decided_at: Time.current) && audit!("withdrawn", actor) }
59
61
  Notifications.publish(
60
62
  "resignation.withdrawn",
61
63
  title: "#{HrLite.display_name(user)} withdrew their resignation",
@@ -72,6 +74,8 @@ module HrLite
72
74
 
73
75
  private
74
76
 
77
+ def audit!(action, actor) = AuditLog.record!(action: "resignation.#{action}", subject: self, actor: actor, changes: { "last_day" => proposed_last_day.to_s })
78
+
75
79
  # Best-effort, like every other call of this hook: a host that cannot
76
80
  # revoke must not roll back an accepted resignation.
77
81
  def revoke_access!
@@ -36,12 +36,17 @@ module HrLite
36
36
  # permission being taken away, not one left alone.
37
37
  def replace_grants!(grants)
38
38
  transaction do
39
+ before = grant_map
39
40
  role_grants.destroy_all
40
41
  grants.each do |key, scope|
41
42
  next if scope.blank? || scope.to_s == "none"
42
43
 
43
44
  role_grants.create!(permission_key: Permissions.validate!(key), scope: scope.to_s)
44
45
  end
46
+ role_grants.reload
47
+ after = grant_map
48
+ changed = (before.keys | after.keys).reject { |key| before[key] == after[key] }
49
+ AuditLog.record!(action: "role.grants_changed", subject: self, changes: changed.to_h { |key| [ key, [ before[key], after[key] ] ] }) if changed.any?
45
50
  end
46
51
  reload
47
52
  end
@@ -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_v3", 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.
@@ -86,6 +86,11 @@ module HrLite
86
86
 
87
87
  def verify!(actor:, note: nil)
88
88
  raise ActiveRecord::RecordInvalid.new(self), "not submitted" unless submitted?
89
+ # A blank verified amount would count as ₹0 allowed once verified.
90
+ if tax_declaration_items.any? { |item| item.verified_amount.nil? }
91
+ errors.add(:base, "Every line needs a verified amount — enter 0 where the proof supports nothing")
92
+ raise ActiveRecord::RecordInvalid, self
93
+ end
89
94
 
90
95
  update!(status: "verified", verified_by_id: actor.id, verified_at: Time.current,
91
96
  note: note.presence)
@@ -247,6 +247,10 @@ module HrLite
247
247
  # Late ladder against slot + buffer; after 11:00 also +1h on the next two
248
248
  # working days; after 11:30 the day is a half day. The buffer shifts all three.
249
249
  def apply_slot_rules(record)
250
+ # No slot to be late for on a weekly off, a holiday, or leave that starts the day (full or first half).
251
+ return unless WorkingCalendar.for_user(@user.id, record.date..record.date).working_day?(record.date)
252
+ return if LeaveRequest.active_on(record.date).where(user_id: @user.id, half_day_part: [ nil, "", "first" ]).exists?
253
+
250
254
  local = record.check_in_at.in_time_zone(HrLite.config.time_zone)
251
255
  arrived = local.hour * 60 + local.min - EmployeeProfile.where(user_id: @user.id).pick(:buffer_minutes).to_i
252
256
  late = AttendanceSlot.penalty_minutes(arrived - AttendanceSlot.start_minute_for(@user.id, record.date))
@@ -17,17 +17,6 @@ module HrLite
17
17
  reach(LeaveRequest.active_on(@date).includes(:leave_type, :user).order(:start_date))
18
18
  end
19
19
 
20
- # Comp-off and regularization are approvals too. Counting leave alone made
21
- # the board announce "All present and accounted for" while both queues
22
- # still had work in them.
23
- def pending_comp_offs
24
- reach(CompOffRequest.pending.includes(:user).recent_first)
25
- end
26
-
27
- def pending_regularizations
28
- reach(RegularizationRequest.pending.includes(:user).recent_first)
29
- end
30
-
31
20
  def flagged_today
32
21
  reach(AttendanceRecord.for_date(@date).flagged.includes(:user))
33
22
  end
@@ -41,7 +30,6 @@ module HrLite
41
30
 
42
31
  def kpis
43
32
  {
44
- pending: pending_requests.count + pending_comp_offs.count + pending_regularizations.count,
45
33
  on_leave: on_leave_today.count,
46
34
  flagged: flagged_today.count,
47
35
  missing_checkout: missing_checkout_yesterday.count
@@ -16,8 +16,11 @@ module HrLite
16
16
  def self.for(user_id, at: Time.current) = for_users([ user_id ], at: at)[user_id]
17
17
 
18
18
  # When an open Busy or Meeting ends, or nil when the person is reachable.
19
- def self.do_not_disturb_until(user_id, at: Time.current)
20
- WorkModeEvent.open.overlay.where(user_id: user_id, mode: %w[busy meeting]).where("ends_at > ?", at).maximum(:ends_at)
19
+ def self.do_not_disturb_until(user_id, at: Time.current) = do_not_disturb_for([ user_id ], at: at)[user_id]
20
+
21
+ # The same for many people in one query: { user_id => ends_at }, reachable people absent.
22
+ def self.do_not_disturb_for(user_ids, at: Time.current)
23
+ WorkModeEvent.open.overlay.where(user_id: user_ids, mode: %w[busy meeting]).where("ends_at > ?", at).group(:user_id).maximum(:ends_at)
21
24
  end
22
25
 
23
26
  def self.for_users(user_ids, at: Time.current)
@@ -22,6 +22,9 @@
22
22
  <% end %>
23
23
 
24
24
  <section class="hrl-card">
25
+ <% if @assets.empty? %>
26
+ <p class="hrl-muted">No assets recorded yet. Add the first one above.</p>
27
+ <% else %>
25
28
  <div class="hrl-table-wrap">
26
29
  <table class="hrl-table hrl-table--stack">
27
30
  <thead><tr><th>Asset</th><th>Status</th><th>With</th><th></th></tr></thead>
@@ -39,13 +42,16 @@
39
42
  <td data-label="">
40
43
  <% if asset.assigned? %>
41
44
  <%= form_with url: hr_lite.take_back_admin_asset_path(asset), method: :post do %>
42
- <%= text_field_tag :condition_note, nil, placeholder: "Condition", class: "hrl-input" %>
45
+ <%= label_tag "asset-condition-#{asset.id}", "Condition on return", class: "hrl-sr-only" %>
46
+ <%= text_field_tag :condition_note, nil, id: "asset-condition-#{asset.id}", placeholder: "Condition on return", class: "hrl-input" %>
43
47
  <%= submit_tag "Take back", class: "hrl-btn" %>
44
48
  <% end %>
45
49
  <% elsif asset.available? %>
46
50
  <%= form_with url: hr_lite.assign_admin_asset_path(asset), method: :post do %>
51
+ <%= label_tag "asset-holder-#{asset.id}", "Hand over to", class: "hrl-sr-only" %>
47
52
  <%= select_tag :user_id,
48
- options_for_select(HrLite.employees.map { |u| [ hr_display_name(u), u.id ] }), class: "hrl-input",
53
+ options_for_select(HrLite.employees.map { |u| [ hr_display_name(u), u.id ] }), id: "asset-holder-#{asset.id}",
54
+ include_blank: "Choose…", required: true, class: "hrl-input",
49
55
  data: { hrl_filter: "Type a name to filter" } %>
50
56
  <%= submit_tag "Hand over", class: "hrl-btn" %>
51
57
  <% end %>
@@ -56,5 +62,6 @@
56
62
  </tbody>
57
63
  </table>
58
64
  </div>
65
+ <% end %>
59
66
  <%= hrl_pagination %>
60
67
  </section>
@@ -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 %>
@@ -1,5 +1,6 @@
1
1
  <% content_for(:page_title) { "Attendance — #{hr_display_name(@employee)}" } %>
2
2
  <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @employee } %>
3
+ <% content_for(:back, "Team") %>
3
4
  <div class="hrl-page__head">
4
5
  <h1 class="hrl-page__title"><%= hr_display_name(@employee) %></h1>
5
6
  <div class="hrl-page__actions">
@@ -33,6 +34,7 @@
33
34
  <% if @punches.any? %>
34
35
  <section class="hrl-card">
35
36
  <h2 class="hrl-card__title">Where they punched</h2>
37
+ <% all_punches = HrLite::AttendancePunch.where(user_id: @employee.id, date: @punches.map(&:date)).order(:at, :id).group_by(&:date) # one query, not one a day %>
36
38
  <% @punches.each do |record| %>
37
39
  <div class="hrl-feed__item hrl-small">
38
40
  <div class="hrl-feed__body">
@@ -47,7 +49,7 @@
47
49
  <% end %>
48
50
  </div>
49
51
  <% end %>
50
- <% if record.check_in_place %><%= render "hr_lite/attendance/trail", record: record %><% end %>
52
+ <% if record.check_in_place %><%= render "hr_lite/attendance/trail", record: record, punches: all_punches.fetch(record.date, []) %><% end %>
51
53
  <% if (gap = record.punch_gap_m) %>
52
54
  <div class="<%= gap > HrLite::AttendanceRecord::PUNCH_GAP_ALERT_M ? "hrl-badge hrl-badge--warn" : "hrl-muted" %>"><%= (gap / 1000.0).round(1) %> km apart</div>
53
55
  <% end %>
@@ -80,7 +82,7 @@
80
82
  </div>
81
83
  <div class="hrl-field">
82
84
  <%= f.label :regularization_note, "Why is this being fixed? (required)" %>
83
- <%= f.text_area :regularization_note %>
85
+ <%= f.text_area :regularization_note, required: true %>
84
86
  </div>
85
87
  <div class="hrl-form-actions">
86
88
  <%= f.submit "Save fix", class: "hrl-btn hrl-btn--primary" %>
@@ -25,14 +25,17 @@
25
25
  <% end %>
26
26
  </section>
27
27
 
28
+ <% fixes_pending = HrLite::RegularizationRequest.pending.where(user_id: @employee.id, date: @period.from..@period.to).pluck(:date) %>
28
29
  <section class="hrl-card">
29
30
  <h2 class="hrl-card__title">Days</h2>
30
31
  <% (@period.from..@period.to).each do |date| %>
31
- <% day = @days.for(date); rec = day.record; punches = @punches[date].to_a %>
32
+ <% day = @days.for(date); rec = day.record; punches = @punches[date].to_a
33
+ not_in_yet = date == Date.current && day.kind == :absent # the day is not over %>
32
34
  <div class="hrl-feed__item hrl-row">
33
35
  <div class="hrl-feed__body hrl-small">
34
36
  <strong><%= date.strftime("%a %-d %b") %></strong>
35
- <span class="hrl-badge <%= tones[day.kind] %>"><%= HrLite::ApplicationHelper::DAY_STATES[day.kind][1] %></span>
37
+ <span class="hrl-badge <%= not_in_yet ? "hrl-badge--muted" : tones[day.kind] %>"><%= not_in_yet ? "Not in yet" : HrLite::ApplicationHelper::DAY_STATES[day.kind][1] %></span>
38
+ <% if fixes_pending.include?(date) %><span class="hrl-badge hrl-badge--warn">Fix pending</span><% end %>
36
39
  <% if @profile&.hours_allowance_minutes && (length = HrLite::EmployeeProfile.day_minutes_for(@employee.id, on: date)) < HrLite::Setting.instance.day_minutes %>
37
40
  <span class="hrl-badge hrl-badge--muted">Day <%= hrl_duration(length * 60) %></span>
38
41
  <% end %>
@@ -1,6 +1,8 @@
1
+ <% content_for(:page_title) { "Audit trail" } %>
1
2
  <div class="hrl-page__head">
2
3
  <h1 class="hrl-page__title">Audit trail</h1>
3
- <p class="hrl-page__sub">Every policy, settings and payroll change — append-only.</p>
4
+ <p class="hrl-page__sub">Every policy, settings and payroll change — append-only.
5
+ <% if params[:views] %><a href="<%= hr_lite.admin_audit_logs_path %>">Hide page views</a><% else %><a href="<%= hr_lite.admin_audit_logs_path(views: 1) %>">Show page views too</a><% end %></p>
4
6
  </div>
5
7
 
6
8
  <% if @audit_logs.any? %>
@@ -13,15 +15,15 @@
13
15
  <% @audit_logs.each do |log| %>
14
16
  <tr>
15
17
  <td data-label="When"><%= log.created_at.strftime("%d %b %Y, %H:%M") %></td>
16
- <td data-label="Who"><%= hr_display_name(log.actor) %></td>
17
- <td data-label="Action"><span class="hrl-badge hrl-badge--muted"><%= log.action %></span></td>
18
+ <td data-label="Who"><%= log.actor ? hr_display_name(log.actor) : "System" %></td>
19
+ <td data-label="Action"><span class="hrl-badge hrl-badge--muted"><%= log.action.tr("._", " ").humanize %></span></td>
18
20
  <td data-label="Subject"><%= log.subject_type.demodulize.underscore.humanize %> #<%= log.subject_id %></td>
19
21
  <td data-label="Changes">
20
22
  <% log.audited_changes.each do |attr, change| %>
21
23
  <div class="hrl-small">
22
24
  <span class="hrl-muted"><%= attr.humanize %>:</span>
23
25
  <% if change.is_a?(Array) %>
24
- <%= change[0].inspect %> → <%= change[1].inspect %>
26
+ <%= change[0].to_s.presence || "blank" %> → <%= change[1].to_s.presence || "blank" %>
25
27
  <% else %>
26
28
  <%= change %>
27
29
  <% end %>
@@ -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>
@@ -22,12 +22,12 @@
22
22
  </div>
23
23
 
24
24
  <div class="hrl-row hrl-row--mb" role="tablist">
25
- <a class="hrl-btn <%= "hrl-btn--primary" if @tab == "slips" %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(tab: "slips", status: @status)) %>" aria-current="<%= @tab == "slips" ? "page" : nil %>">Salary slips</a>
26
- <a class="hrl-btn <%= "hrl-btn--primary" if @tab == "declarations" %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(tab: "declarations", status: @status)) %>" aria-current="<%= @tab == "declarations" ? "page" : nil %>">Tax declarations</a>
25
+ <a class="hrl-btn" <%= "aria-current=true" if @tab == "slips" %> href="<%= hr_lite.admin_ca_reviews_path(keep.(tab: "slips", status: @status)) %>">Salary slips</a>
26
+ <a class="hrl-btn" <%= "aria-current=true" if @tab == "declarations" %> href="<%= hr_lite.admin_ca_reviews_path(keep.(tab: "declarations", status: @status)) %>">Tax declarations</a>
27
27
  </div>
28
28
  <div class="hrl-row hrl-row--mb">
29
29
  <% [ [ nil, "All" ], [ "unreviewed", "To review" ], [ "flagged", "Flagged" ], [ "verified", "Verified" ], [ "resolved", "Resolved" ] ].each do |value, label| %>
30
- <a class="hrl-btn <%= "hrl-btn--primary" if @status == value %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(status: value)) %>"><%= label %></a>
30
+ <a class="hrl-btn" <%= "aria-current=true" if @status == value %> href="<%= hr_lite.admin_ca_reviews_path(keep.(status: value)) %>"><%= label %></a>
31
31
  <% end %>
32
32
  </div>
33
33
 
@@ -4,8 +4,7 @@
4
4
  <section class="hrl-card">
5
5
  <div class="hrl-row">
6
6
  <% HrLite::ChecklistTemplate::KINDS.each do |kind| %>
7
- <a class="hrl-btn <%= "hrl-btn--primary" if kind == @kind %>"
8
- href="<%= hr_lite.admin_checklists_path(kind: kind) %>"><%= kind.humanize %></a>
7
+ <%= link_to kind.humanize, hr_lite.admin_checklists_path(kind: kind), class: "hrl-btn", aria: { current: ("true" if kind == @kind) } %>
9
8
  <% end %>
10
9
  </div>
11
10
  </section>
@@ -37,7 +36,8 @@
37
36
  <td data-label="Due"><%= item.due_on&.strftime("%d %b %Y") || "—" %></td>
38
37
  <td data-label="">
39
38
  <%= form_with url: hr_lite.complete_admin_checklist_path(item), method: :post do %>
40
- <%= text_field_tag :note, nil, placeholder: "Note", class: "hrl-input" %>
39
+ <%= label_tag "checklist-note-#{item.id}", "Note (optional)", class: "hrl-sr-only" %>
40
+ <%= text_field_tag :note, nil, id: "checklist-note-#{item.id}", placeholder: "Note (optional)", class: "hrl-input" %>
41
41
  <%= submit_tag "Done", class: "hrl-btn hrl-btn--primary" %>
42
42
  <% end %>
43
43
  </td>
@@ -6,8 +6,7 @@
6
6
  <div class="hrl-row hrl-row--mb">
7
7
  <span class="hrl-small hrl-muted">Status</span>
8
8
  <% HrLite::CompOffRequest::STATUSES.each do |status| %>
9
- <a class="hrl-btn <%= 'hrl-btn--primary' if @status == status %>"
10
- href="<%= hr_lite.admin_comp_off_requests_path(status: status) %>"><%= status.humanize %></a>
9
+ <%= link_to status.humanize, hr_lite.admin_comp_off_requests_path(status: status), class: "hrl-btn", aria: { current: ("true" if @status == status) } %>
11
10
  <% end %>
12
11
  </div>
13
12
 
@@ -1,9 +1,7 @@
1
1
  <% content_for(:page_title) { "Comp-off — #{hr_display_name(@request.user)}" } %>
2
+ <a class="hrl-backlink" href="<%= hr_lite.admin_comp_off_requests_path %>"><span aria-hidden="true">‹</span> Approvals</a>
2
3
  <div class="hrl-page__head">
3
4
  <h1 class="hrl-page__title">Comp-off — <%= hr_display_name(@request.user) %></h1>
4
- <div class="hrl-page__actions">
5
- <a class="hrl-btn" href="<%= hr_lite.admin_comp_off_requests_path %>">All requests</a>
6
- </div>
7
5
  </div>
8
6
 
9
7
  <section class="hrl-card">
@@ -34,7 +32,8 @@
34
32
  `formaction:` can never pass verification on the second endpoint. %>
35
33
  <section class="hrl-card">
36
34
  <h2 class="hrl-card__title">Approve</h2>
37
- <%= form_with url: hr_lite.approve_admin_comp_off_request_path(@request), method: :post, local: true do |f| %>
35
+ <%= form_with url: hr_lite.approve_admin_comp_off_request_path(@request), method: :post, local: true,
36
+ html: { data: { turbo_confirm: "Credit #{hrl_days(@request.credit_days)} day of comp-off to #{hr_display_name(@request.user)}?" } } do |f| %>
38
37
  <div class="hrl-field">
39
38
  <%= f.label :decision_note, "Note (optional)", for: "approve_note" %>
40
39
  <%= f.text_area :decision_note, id: "approve_note" %>
@@ -48,10 +47,11 @@
48
47
 
49
48
  <section class="hrl-card">
50
49
  <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| %>
50
+ <%= form_with url: hr_lite.reject_admin_comp_off_request_path(@request), method: :post, local: true,
51
+ html: { data: { turbo_confirm: "Reject #{hr_display_name(@request.user)}'s comp-off for #{@request.date_worked.strftime('%d %b %Y')}?" } } 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" %>
@@ -1,6 +1,6 @@
1
- <% content_for(:page_title) { "Role change" } %>
1
+ <% content_for(:page_title) { "Designation change" } %>
2
2
  <div class="hrl-page__head">
3
- <h1 class="hrl-page__title">Role change — <%= hr_display_name(@profile.user) %></h1>
3
+ <h1 class="hrl-page__title">Designation change — <%= hr_display_name(@profile.user) %></h1>
4
4
  </div>
5
5
 
6
6
  <section class="hrl-card">
@@ -12,7 +12,8 @@
12
12
  <div class="hrl-field"><%= f.label :effective_date %><%= f.date_field :effective_date %></div>
13
13
  <div class="hrl-field"><%= f.label :note, "Note (optional)" %><%= f.text_area :note %></div>
14
14
  <div class="hrl-form-actions">
15
- <%= f.submit "Record change", class: "hrl-btn hrl-btn--primary" %>
15
+ <%= f.submit "Record change", class: "hrl-btn hrl-btn--primary",
16
+ data: { turbo_confirm: "Record this designation change? #{hr_display_name(@profile.user)} is told at once." } %>
16
17
  <a class="hrl-btn" href="<%= hr_lite.admin_employee_path(@profile) %>">Cancel</a>
17
18
  </div>
18
19
  <% end %>
@@ -8,10 +8,10 @@
8
8
  visibility allows.
9
9
  </p>
10
10
  <div class="hrl-row">
11
- <a class="hrl-btn <%= "hrl-btn--primary" if @category.nil? %>"
11
+ <a class="hrl-btn" <%= "aria-current=true" if @category.nil? %>
12
12
  href="<%= hr_lite.admin_documents_path %>">All</a>
13
13
  <% @categories.each do |category| %>
14
- <a class="hrl-btn <%= "hrl-btn--primary" if @category == category %>"
14
+ <a class="hrl-btn" <%= "aria-current=true" if @category == category %>
15
15
  href="<%= hr_lite.admin_documents_path(category: category) %>"><%= category.humanize %></a>
16
16
  <% end %>
17
17
  </div>
@@ -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,8 @@
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
+ <%= label_tag "reject-doc-#{doc.id}", "What is wrong with it", class: "hrl-sr-only" %>
48
+ <%= text_field_tag :note, nil, id: "reject-doc-#{doc.id}", placeholder: "What is wrong with it", class: "hrl-input", required: true %>
48
49
  <%= submit_tag "Reject", class: "hrl-btn" %>
49
50
  <% end %>
50
51
  </td>
@@ -58,6 +59,9 @@
58
59
 
59
60
  <section class="hrl-card">
60
61
  <h2 class="hrl-card__title">Everything on file</h2>
62
+ <% if @documents.empty? %>
63
+ <p class="hrl-muted">No documents on file yet. People upload them from Me › Documents; you can upload for someone from their page.</p>
64
+ <% else %>
61
65
  <div class="hrl-table-wrap">
62
66
  <table class="hrl-table hrl-table--stack">
63
67
  <thead><tr><th>Person</th><th>Document</th><th>Expires</th><th>Checked</th></tr></thead>
@@ -85,5 +89,6 @@
85
89
  </tbody>
86
90
  </table>
87
91
  </div>
92
+ <% end %>
88
93
  <%= hrl_pagination %>
89
94
  </section>
@@ -1,5 +1,6 @@
1
1
  <% content_for(:page_title) { hr_display_name(@employee) } %>
2
2
  <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @employee } %>
3
+ <% content_for(:back, "Documents to verify") %>
3
4
  <div class="hrl-page__head">
4
5
  <h1 class="hrl-page__title"><%= hr_display_name(@employee) %> — documents</h1>
5
6
  </div>
@@ -1,6 +1,7 @@
1
1
  <section class="hrl-card">
2
2
  <%= form_with model: profile, url: url, method: method, scope: :employee_profile, local: true do |f| %>
3
3
  <%= render "hr_lite/shared/form_errors", record: profile %>
4
+ <h2 class="hrl-card__title">Identity</h2>
4
5
  <% if profile.new_record? && profile.user_id.blank? %>
5
6
  <div class="hrl-field">
6
7
  <%= f.label :user_id, "Existing staff member" %>
@@ -22,7 +23,7 @@
22
23
  </div>
23
24
  <div class="hrl-field">
24
25
  <%= f.label :new_user_password, "Starting password (optional)" %>
25
- <%= f.text_field :new_user_password %>
26
+ <%= f.password_field :new_user_password, autocomplete: "new-password" %>
26
27
  <p class="hrl-hint">Leave blank (recommended): they get an email invite and set their own
27
28
  password. Fill it only if you want to hand a password over yourself.</p>
28
29
  </div>
@@ -40,6 +41,7 @@
40
41
  <p class="hrl-hint">The employee code is assigned automatically
41
42
  (<%= HrLite::Setting.instance.employee_code_prefix %> + next number).</p>
42
43
  <% end %>
44
+ <h2 class="hrl-card__title hrl-mt">Work</h2>
43
45
  <div class="hrl-field"><%= f.label :designation %><%= f.text_field :designation %></div>
44
46
  <div class="hrl-field">
45
47
  <%= f.label :manager_id, "Reports to (their L1)" %>
@@ -64,7 +66,7 @@
64
66
  <%= f.select :office_location_id, offices, { include_blank: "None — only all-office holidays" }, data: { hrl_filter: ("Type an office" if offices.size > 5) } %></div>
65
67
  <% end %>
66
68
  <div class="hrl-field"><%= f.label :date_of_birth %><%= f.date_field :date_of_birth %></div>
67
- <div class="hrl-field"><%= f.label :date_of_joining %><%= f.date_field :date_of_joining %></div>
69
+ <div class="hrl-field"><%= f.label :date_of_joining %><%= f.date_field :date_of_joining, required: true %></div>
68
70
  <div class="hrl-field">
69
71
  <%= f.label :blood_group, id: "hrl-blood-group", for: nil %>
70
72
  <div class="hrl-chiprow" role="radiogroup" aria-labelledby="hrl-blood-group">
@@ -79,6 +81,7 @@
79
81
  <%= f.label :probation_until, "On probation until (blank = not on probation)" %>
80
82
  <%= f.date_field :probation_until %>
81
83
  </div>
84
+ <h2 class="hrl-card__title hrl-mt">Attendance rules</h2>
82
85
  <% if HrLite.config.attendance_slots %>
83
86
  <div class="hrl-field">
84
87
  <%= f.label :buffer_minutes, "Late buffer (delays late penalties; colleagues see the reason category)" %>
@@ -99,12 +102,16 @@
99
102
  <%= f.label :date_of_exit, "Date of exit (leave blank while employed)" %>
100
103
  <%= f.date_field :date_of_exit %>
101
104
  </div>
102
- <div class="hrl-field"><%= f.label :pan_number, "PAN" %><%= f.text_field :pan_number %></div>
103
- <div class="hrl-field"><%= f.label :pf_uan, "PF UAN" %><%= f.text_field :pf_uan %></div>
105
+ <h2 class="hrl-card__title hrl-mt">Pay &amp; statutory</h2>
106
+ <div class="hrl-field"><%= f.label :pan_number, "PAN" %><%= f.text_field :pan_number %>
107
+ <p class="hrl-hint">10 characters in capitals, like ABCDE1234F.</p></div>
108
+ <div class="hrl-field"><%= f.label :pf_uan, "PF UAN" %><%= f.text_field :pf_uan %>
109
+ <p class="hrl-hint">12 digits.</p></div>
104
110
  <div class="hrl-field"><%= f.label :esi_number, "ESI number" %><%= f.text_field :esi_number %></div>
105
111
  <div class="hrl-field"><%= f.label :bank_name %><%= f.text_field :bank_name %></div>
106
112
  <div class="hrl-field"><%= f.label :bank_account_number %><%= f.text_field :bank_account_number %></div>
107
- <div class="hrl-field"><%= f.label :bank_ifsc, "IFSC" %><%= f.text_field :bank_ifsc %></div>
113
+ <div class="hrl-field"><%= f.label :bank_ifsc, "IFSC" %><%= f.text_field :bank_ifsc %>
114
+ <p class="hrl-hint">11 characters in capitals, fifth is a zero, like HDFC0001234.</p></div>
108
115
  <div class="hrl-field">
109
116
  <%= f.label :tax_regime %>
110
117
  <%= f.select :tax_regime, HrLite::EmployeeProfile::TAX_REGIMES.map { |r| [ "#{r.humanize} regime", r ] } %>