hr_lite 0.54.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/app/assets/javascripts/hr_lite/confirm.js +9 -5
  4. data/app/assets/javascripts/hr_lite/countdown.js +9 -2
  5. data/app/assets/javascripts/hr_lite/search.js +1 -1
  6. data/app/assets/stylesheets/hr_lite/hr_lite.css +18 -9
  7. data/app/controllers/hr_lite/admin/attendances_controller.rb +10 -3
  8. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +1 -0
  9. data/app/controllers/hr_lite/admin/employees_controller.rb +11 -4
  10. data/app/controllers/hr_lite/admin/expenses_controller.rb +4 -0
  11. data/app/controllers/hr_lite/admin/hr_requests_controller.rb +4 -0
  12. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +4 -1
  13. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +9 -5
  14. data/app/controllers/hr_lite/admin/reports_controller.rb +3 -2
  15. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +2 -0
  16. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +13 -3
  17. data/app/controllers/hr_lite/approvals_controller.rb +1 -0
  18. data/app/controllers/hr_lite/home_controller.rb +2 -2
  19. data/app/controllers/hr_lite/regularization_requests_controller.rb +4 -4
  20. data/app/controllers/hr_lite/tax_declarations_controller.rb +9 -5
  21. data/app/helpers/hr_lite/application_helper.rb +14 -8
  22. data/app/jobs/hr_lite/attendance_close_job.rb +1 -1
  23. data/app/jobs/hr_lite/payroll_compute_job.rb +12 -0
  24. data/app/models/concerns/hr_lite/audited.rb +3 -1
  25. data/app/models/concerns/hr_lite/encrypted_money.rb +8 -5
  26. data/app/models/hr_lite/attendance_record.rb +14 -0
  27. data/app/models/hr_lite/comp_off_request.rb +2 -0
  28. data/app/models/hr_lite/document.rb +1 -1
  29. data/app/models/hr_lite/expense.rb +3 -0
  30. data/app/models/hr_lite/hr_request.rb +2 -0
  31. data/app/models/hr_lite/kudo.rb +2 -2
  32. data/app/models/hr_lite/leave_request.rb +25 -6
  33. data/app/models/hr_lite/payroll_line_item.rb +8 -0
  34. data/app/models/hr_lite/payroll_run.rb +15 -6
  35. data/app/models/hr_lite/regularization_request.rb +2 -2
  36. data/app/models/hr_lite/resignation.rb +5 -1
  37. data/app/models/hr_lite/role.rb +5 -0
  38. data/app/models/hr_lite/salary_slip.rb +1 -1
  39. data/app/models/hr_lite/tax_declaration.rb +5 -0
  40. data/app/services/hr_lite/attendance_puncher.rb +4 -0
  41. data/app/services/hr_lite/overview_query.rb +0 -12
  42. data/app/services/hr_lite/work_status.rb +5 -2
  43. data/app/views/hr_lite/admin/assets/index.html.erb +9 -2
  44. data/app/views/hr_lite/admin/attendances/show.html.erb +3 -1
  45. data/app/views/hr_lite/admin/attendances/summary.html.erb +5 -2
  46. data/app/views/hr_lite/admin/audit_logs/index.html.erb +6 -4
  47. data/app/views/hr_lite/admin/ca_reviews/index.html.erb +3 -3
  48. data/app/views/hr_lite/admin/checklists/index.html.erb +3 -3
  49. data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +1 -2
  50. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +5 -5
  51. data/app/views/hr_lite/admin/designation_changes/new.html.erb +4 -3
  52. data/app/views/hr_lite/admin/documents/index.html.erb +8 -3
  53. data/app/views/hr_lite/admin/documents/show.html.erb +1 -0
  54. data/app/views/hr_lite/admin/employees/_form.html.erb +12 -5
  55. data/app/views/hr_lite/admin/employees/show.html.erb +38 -32
  56. data/app/views/hr_lite/admin/expenses/index.html.erb +3 -2
  57. data/app/views/hr_lite/admin/holidays/index.html.erb +5 -4
  58. data/app/views/hr_lite/admin/hr_requests/index.html.erb +1 -1
  59. data/app/views/hr_lite/admin/hr_requests/show.html.erb +4 -3
  60. data/app/views/hr_lite/admin/leave_balances/index.html.erb +6 -4
  61. data/app/views/hr_lite/admin/leave_requests/index.html.erb +1 -2
  62. data/app/views/hr_lite/admin/leave_requests/show.html.erb +5 -3
  63. data/app/views/hr_lite/admin/leave_types/edit.html.erb +4 -4
  64. data/app/views/hr_lite/admin/leave_types/index.html.erb +3 -3
  65. data/app/views/hr_lite/admin/loans/index.html.erb +1 -1
  66. data/app/views/hr_lite/admin/loans/show.html.erb +8 -12
  67. data/app/views/hr_lite/admin/office_locations/edit.html.erb +4 -4
  68. data/app/views/hr_lite/admin/payroll_runs/index.html.erb +2 -2
  69. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +10 -7
  70. data/app/views/hr_lite/admin/policies/index.html.erb +1 -1
  71. data/app/views/hr_lite/admin/policies/new.html.erb +2 -2
  72. data/app/views/hr_lite/admin/regularization_requests/index.html.erb +3 -4
  73. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +4 -5
  74. data/app/views/hr_lite/admin/reports/index.html.erb +3 -6
  75. data/app/views/hr_lite/admin/reports/show.html.erb +16 -2
  76. data/app/views/hr_lite/admin/roles/_form.html.erb +6 -5
  77. data/app/views/hr_lite/admin/salary_slips/show.html.erb +3 -2
  78. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +1 -0
  79. data/app/views/hr_lite/admin/settings/edit.html.erb +5 -1
  80. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +3 -3
  81. data/app/views/hr_lite/admin/statutory_rate_cards/index.html.erb +3 -1
  82. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +1 -1
  83. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +2 -0
  84. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +17 -16
  85. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +2 -4
  86. data/app/views/hr_lite/admin/work_locations/index.html.erb +1 -1
  87. data/app/views/hr_lite/admin/work_modes/index.html.erb +1 -1
  88. data/app/views/hr_lite/approvals/index.html.erb +1 -1
  89. data/app/views/hr_lite/attendance/_month_grid.html.erb +9 -9
  90. data/app/views/hr_lite/attendance/_penalties.html.erb +2 -2
  91. data/app/views/hr_lite/attendance/_punch_card.html.erb +2 -4
  92. data/app/views/hr_lite/attendance/_slot.html.erb +9 -6
  93. data/app/views/hr_lite/attendance/_trail.html.erb +1 -1
  94. data/app/views/hr_lite/attendance/_work_mode.html.erb +1 -1
  95. data/app/views/hr_lite/attendance/show.html.erb +4 -4
  96. data/app/views/hr_lite/calendar/show.html.erb +2 -2
  97. data/app/views/hr_lite/comp_off_requests/index.html.erb +1 -1
  98. data/app/views/hr_lite/comp_off_requests/new.html.erb +1 -1
  99. data/app/views/hr_lite/documents/_upload_form.html.erb +1 -1
  100. data/app/views/hr_lite/employee_profiles/show.html.erb +4 -2
  101. data/app/views/hr_lite/home/index.html.erb +2 -2
  102. data/app/views/hr_lite/id_cards/show.html.erb +5 -1
  103. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +2 -2
  104. data/app/views/hr_lite/leave_requests/new.html.erb +17 -8
  105. data/app/views/hr_lite/leave_requests/show.html.erb +2 -0
  106. data/app/views/hr_lite/org/show.html.erb +1 -1
  107. data/app/views/hr_lite/regularization_requests/index.html.erb +7 -7
  108. data/app/views/hr_lite/regularization_requests/new.html.erb +7 -5
  109. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +2 -2
  110. data/app/views/hr_lite/salary_slips/pdf.html.erb +1 -1
  111. data/app/views/hr_lite/tax/_computation.html.erb +1 -1
  112. data/app/views/hr_lite/tax_declarations/show.html.erb +4 -4
  113. data/app/views/hr_lite/team/show.html.erb +8 -7
  114. data/app/views/hr_lite/timelines/show.html.erb +1 -0
  115. data/app/views/hr_lite/wfh_requests/index.html.erb +3 -3
  116. data/app/views/hr_lite/work_time/_bars.html.erb +1 -1
  117. data/app/views/hr_lite/work_time/_donut.html.erb +1 -1
  118. data/app/views/hr_lite/work_time/_report.html.erb +14 -8
  119. data/app/views/layouts/hr_lite/application.html.erb +1 -0
  120. data/lib/hr_lite/notifications.rb +5 -2
  121. data/lib/hr_lite/version.rb +1 -1
  122. metadata +2 -1
@@ -41,7 +41,7 @@ module HrLite
41
41
  queue: -> { hrl_queue(HrLite::LeaveRequest.where(status: "pending"), "leave.approve") } },
42
42
  { label: "Comp-off", path: :admin_comp_off_requests_path, match: [ "/admin/comp_off_requests" ], if: OPS,
43
43
  queue: -> { hrl_queue(HrLite::CompOffRequest.pending, "leave.approve") } },
44
- { label: "Attendance fixes", path: :admin_regularization_requests_path, match: [ "/admin/regularization_requests" ], if: OPS,
44
+ { label: "Day 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
47
  queue: -> { hrl_queue(HrLite::WfhRequest.pending.overlapping(Date.current, Date.current + HrLite::WfhRequest::MAX_DAYS_AHEAD), "attendance.manage") } },
@@ -49,6 +49,8 @@ module HrLite
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) },
51
51
  queue: -> { hrl_queue(HrLite::Expense.where(status: "submitted"), "expense.approve") } },
52
+ { label: "Resignations", path: :admin_employees_path, match: [ "/admin/resignations" ], if: LEAD,
53
+ queue: -> { hrl_queue(HrLite::Resignation.pending, "profile.manage") } },
52
54
  { label: "Documents to verify", path: :admin_documents_path, match: [ "/admin/documents" ], if: LEAD,
53
55
  queue: -> { hrl_queue(HrLite::Document.where(verification: "pending"), "document.view") } },
54
56
  { label: "CA review", path: :admin_ca_reviews_path, match: [ "/admin/ca_reviews" ],
@@ -69,11 +71,11 @@ module HrLite
69
71
  { label: "Team benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ], if: MANAGE }
70
72
  ] },
71
73
  { label: "People", match: [ "/people" ], tabs: [
72
- { label: "My team", path: :team_path, match: [ "/team" ] },
74
+ { label: "Team board", path: :team_path, match: [ "/team" ] },
73
75
  { label: "Org chart", path: :org_path, match: [ "/org" ] },
74
76
  { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
75
77
  { label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ], if: LEAD },
76
- { label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ], if: MANAGE },
78
+ { label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ], if: -> { hr_can?("checklist.manage", scope: :all) } },
77
79
  { label: "Awards", path: :admin_awards_path, match: [ "/admin/awards" ], if: LEAD },
78
80
  { label: "PIPs", path: :admin_performance_plans_path, match: [ "/admin/performance_plans" ], if: SUPER }
79
81
  ] },
@@ -87,7 +89,7 @@ module HrLite
87
89
  { label: "Help", tabs: [
88
90
  { label: "Policies", path: :policies_path, match: [ "/policies" ], if: -> { hr_can?("policy.view", scope: :all) } },
89
91
  { label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ], if: -> { hr_can?("hr_request.raise") } },
90
- { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ], if: MANAGE },
92
+ { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ], if: -> { hr_can?("hr_request.manage", scope: :all) } },
91
93
  { label: "Manage policies", path: :admin_policies_path, match: [ "/admin/policies" ], if: -> { hr_can?("policy.manage", scope: :all) } }
92
94
  ] },
93
95
  { label: "Admin", divider: true, tabs: [
@@ -96,7 +98,7 @@ module HrLite
96
98
  { label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ], if: -> { hr_can?("role.manage", scope: :all) } },
97
99
  { label: "Statutory rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ], if: SUPER },
98
100
  { 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) } } },
99
- { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ], if: MANAGE },
101
+ { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ], if: -> { hr_can?("asset.manage", scope: :all) } },
100
102
  { label: "Audit trail", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ], if: LEAD }
101
103
  ] }
102
104
  ].freeze
@@ -164,8 +166,12 @@ module HrLite
164
166
  when HrLite::LeaveRequest
165
167
  [ "#{record.leave_type.name}, #{record.date_range_label} · #{pluralize(hrl_days(record.days_count), 'day')}", hr_lite.admin_leave_request_path(record) ]
166
168
  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) ]
169
+ when HrLite::RegularizationRequest then [ "Day fix, #{record.date.strftime('%a %-d %b')}", hr_lite.admin_regularization_request_path(record) ]
168
170
  when HrLite::WfhRequest then [ "WFH, #{record.dates_label}", hr_lite.admin_wfh_request_path(record) ]
171
+ when HrLite::Expense then [ "Claim: #{record.category.name}, #{hrl_money(record.amount)}, #{record.spent_on.strftime('%a %-d %b')}", hr_lite.admin_expenses_path ]
172
+ when HrLite::Resignation
173
+ profile = HrLite::EmployeeProfile.find_by(user_id: record.user_id)
174
+ [ "Resignation, last day #{record.proposed_last_day.strftime('%-d %b %Y')}", profile ? hr_lite.admin_employee_path(profile) : hr_lite.admin_employees_path ]
169
175
  else [ tab[:label], hr_lite.public_send(tab[:path]) ]
170
176
  end
171
177
  end
@@ -300,8 +306,8 @@ module HrLite
300
306
  # Status pill for a request lifecycle string — one colour map for every
301
307
  # list and show screen.
302
308
  def hrl_request_status_badge(status)
303
- css = { "approved" => "hrl-badge--ok", "rejected" => "hrl-badge--bad",
304
- "cancelled" => "hrl-badge--muted" }[status]
309
+ css = { "approved" => "hrl-badge--ok", "reimbursed" => "hrl-badge--ok", "verified" => "hrl-badge--ok",
310
+ "rejected" => "hrl-badge--bad", "cancelled" => "hrl-badge--muted", "draft" => "hrl-badge--muted" }[status]
305
311
  content_tag(:span, status.humanize, class: "hrl-badge #{css}")
306
312
  end
307
313
 
@@ -58,7 +58,7 @@ module HrLite
58
58
  "Fix it yourself by #{(date + rule[:within_days]).strftime('%d %b')} (up to #{rule[:per_week]} a week), " \
59
59
  "or raise a ticket for HR."
60
60
  else
61
- "Raise a regularization ticket to get it fixed."
61
+ "Use Fix a day to get it fixed."
62
62
  end
63
63
  Notifications.publish(event, title: title, body: body, path: "/regularization_requests/new?date=#{date.iso8601}",
64
64
  bell_to: [ user ], email_to: [ user ])
@@ -0,0 +1,12 @@
1
+ module HrLite
2
+ # Compute off the request (PayrollRun#compute_later!). A failure restores
3
+ # the run's status and lands as a run warning instead of a 500.
4
+ class PayrollComputeJob < ApplicationJob
5
+ def perform(run, from, actor)
6
+ run.compute!(actor: actor, from: from)
7
+ rescue => e
8
+ Rails.error.report(e)
9
+ run.reload.update_columns(warnings: run.warnings | [ "Compute failed (#{e.message.truncate(150)}) — nothing changed; try again." ]) # rubocop:disable Rails/SkipsModelValidations
10
+ end
11
+ end
12
+ end
@@ -8,6 +8,8 @@ module HrLite
8
8
 
9
9
  SKIPPED_ATTRIBUTES = %w[updated_at created_at].freeze
10
10
  REDACTED = "[changed]".freeze
11
+ # Personal text stored in the clear: the trail says it changed, never to what.
12
+ PII_COLUMNS = { "HrLite::EmployeeProfile" => %w[date_of_birth], "HrLite::HrRequest" => %w[subject body] }.freeze
11
13
 
12
14
  # *_commit, not *_create/_update/_destroy: these callbacks write an audit
13
15
  # row and email leadership a diff. Fired inside the transaction they
@@ -40,7 +42,7 @@ module HrLite
40
42
  end
41
43
 
42
44
  def hr_lite_audited_changes(action)
43
- encrypted = self.class.try(:encrypted_attributes)&.map(&:to_s) || []
45
+ encrypted = (self.class.try(:encrypted_attributes)&.map(&:to_s) || []) + PII_COLUMNS.fetch(self.class.name, [])
44
46
 
45
47
  case action
46
48
  when "destroy"
@@ -8,6 +8,10 @@ module HrLite
8
8
  module EncryptedMoney
9
9
  extend ActiveSupport::Concern
10
10
 
11
+ included do
12
+ validate { @unreadable_money&.each { |attribute| errors.add(attribute, "is not a number") } }
13
+ end
14
+
11
15
  class_methods do
12
16
  def encrypted_money(*attributes)
13
17
  attributes.each do |attribute|
@@ -19,11 +23,10 @@ module HrLite
19
23
  end
20
24
 
21
25
  define_method("#{attribute}=") do |value|
22
- if value.nil? || (value.respond_to?(:blank?) && value.blank?)
23
- super(nil)
24
- else
25
- super(BigDecimal(value.to_s).round(2).to_s("F"))
26
- end
26
+ digits = value.to_s.gsub(/[,₹\s]/, "") # "1,50,000" and "₹ 1200" are how people type money
27
+ super(digits.empty? ? nil : BigDecimal(digits).round(2).to_s("F"))
28
+ rescue ArgumentError
29
+ (@unreadable_money ||= []) << attribute
27
30
  end
28
31
  end
29
32
  end
@@ -35,6 +35,7 @@ module HrLite
35
35
  validates :status, inclusion: { in: STATUSES }
36
36
  validates :half_day_part, inclusion: { in: %w[first second] }, allow_blank: true
37
37
  validate :check_out_after_check_in
38
+ validate :times_on_the_day, if: -> { will_save_change_to_date? || will_save_change_to_check_in_at? || will_save_change_to_check_out_at? }
38
39
 
39
40
  scope :for_date, ->(date) { where(date: date) }
40
41
  scope :for_month, ->(month) { where(date: month.beginning_of_month..month.end_of_month) }
@@ -118,5 +119,18 @@ module HrLite
118
119
 
119
120
  errors.add(:check_out_at, "must be after check-in")
120
121
  end
122
+
123
+ # No future days; check-out may run into the next morning (night shift).
124
+ # Only the fields being changed, in the company's zone, so re-saving an old row never fails.
125
+ def times_on_the_day
126
+ return unless date
127
+
128
+ day = ->(time) { time.in_time_zone(HrLite.config.time_zone).to_date }
129
+ errors.add(:date, "can't be in the future") if will_save_change_to_date? && date > day.(Time.current)
130
+ errors.add(:check_in_at, "must be on #{date.strftime('%d %b')}") if will_save_change_to_check_in_at? && check_in_at && day.(check_in_at) != date
131
+ if will_save_change_to_check_out_at? && check_out_at && !(date..date + 1).cover?(day.(check_out_at))
132
+ errors.add(:check_out_at, "must be on #{date.strftime('%d %b')} or the next morning")
133
+ end
134
+ end
121
135
  end
122
136
  end
@@ -50,6 +50,8 @@ module HrLite
50
50
  # (A duplicate live request for the same date cannot exist — the
51
51
  # partial unique index on [user_id, date_worked] guarantees it.)
52
52
  credit_balance!(type)
53
+ AuditLog.record!(action: "comp_off.approved", subject: self, actor: actor,
54
+ changes: { "date_worked" => date_worked.to_s, "days" => credit_days.to_s("F") })
53
55
  end
54
56
 
55
57
  Notifications.publish(
@@ -79,7 +79,7 @@ module HrLite
79
79
  def default_visibility_from_category
80
80
  return if visibility.present?
81
81
 
82
- self.visibility = SENSITIVE_CATEGORIES.include?(category.to_s) ? "money" : "hr"
82
+ self.visibility = SENSITIVE_CATEGORIES.include?(category.to_s.downcase.strip) ? "money" : "hr"
83
83
  end
84
84
 
85
85
  def file_is_an_allowed_kind
@@ -39,6 +39,7 @@ module HrLite
39
39
  end
40
40
 
41
41
  def approve!(actor:, note: nil)
42
+ raise ActiveRecord::RecordInvalid.new(self), "not submitted" unless submitted?
42
43
  return record_routed!(actor, note, :approved) if awaiting?(actor)
43
44
 
44
45
  settle!("approved", actor, note)
@@ -46,6 +47,7 @@ module HrLite
46
47
 
47
48
  def reject!(actor:, note:)
48
49
  raise ArgumentError, "a rejection needs a reason" if note.blank?
50
+ raise ActiveRecord::RecordInvalid.new(self), "not submitted" unless submitted?
49
51
  return record_routed!(actor, note, :rejected) if awaiting?(actor)
50
52
 
51
53
  settle!("rejected", actor, note)
@@ -90,6 +92,7 @@ module HrLite
90
92
 
91
93
  def settle!(new_status, actor, note)
92
94
  update!(status: new_status, decision_note: note.presence)
95
+ approval_route.cancel_all! # decided outside the flow: nobody is left waiting to be asked
93
96
  Notifications.publish(
94
97
  new_status == "approved" ? "expense.approved" : "expense.rejected",
95
98
  title: "Your #{category.name} claim was #{new_status}",
@@ -30,6 +30,7 @@ module HrLite
30
30
  def category_label = self.class.category_label(category)
31
31
 
32
32
  def assign!(actor:, assignee:)
33
+ raise ActiveRecord::RecordInvalid.new(self), "already settled" unless open? || in_progress?
33
34
  update!(assigned_to_id: assignee.id, status: "in_progress")
34
35
  Notifications.publish(
35
36
  "hr_request.assigned",
@@ -41,6 +42,7 @@ module HrLite
41
42
  end
42
43
 
43
44
  def resolve!(actor:, resolution:)
45
+ raise ActiveRecord::RecordInvalid.new(self), "already settled" unless open? || in_progress?
44
46
  raise ArgumentError, "a resolution needs an answer" if resolution.blank?
45
47
 
46
48
  update!(status: "resolved", resolution: resolution, resolved_at: Time.current,
@@ -33,10 +33,10 @@ module HrLite
33
33
  BADGES[badge]
34
34
  end
35
35
 
36
- # Creates mention rows from the message markers (existing users only,
36
+ # Creates mention rows from the message markers (employees only,
37
37
  # giver excluded) and notifies them. Called once, right after create.
38
38
  def register_mentions!
39
- ids = HrLite::MentionParser.user_ids(message) - [ giver_id ]
39
+ ids = (HrLite::MentionParser.user_ids(message) - [ giver_id ]) & HrLite.employees.map(&:id)
40
40
  users = HrLite.user_klass.where(id: ids)
41
41
  users.each { |u| kudo_mentions.create!(user_id: u.id) }
42
42
 
@@ -5,12 +5,15 @@ module HrLite
5
5
  STATUSES = %w[pending approved rejected cancelled].freeze
6
6
 
7
7
  belongs_to :user, class_name: HrLite.config.user_class
8
- belongs_to :leave_type
8
+ belongs_to :leave_type, optional: true # required below, in words ("must exist" is fixed)
9
9
  belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
10
10
  # Set when HR records leave the employee forgot to apply for.
11
11
  belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
12
12
 
13
- validates :start_date, :end_date, presence: true
13
+ validates :leave_type, presence: { message: "is missing — pick one" }
14
+ validates :start_date, presence: { message: "is missing — pick the first day" }
15
+ # With no start the start error says it all (a blank end means one day).
16
+ validates :end_date, presence: { message: "is missing — pick the last day" }, if: :start_date
14
17
  validates :status, inclusion: { in: STATUSES }
15
18
  validates :half_day_part, inclusion: { in: %w[first second] }, allow_blank: true
16
19
 
@@ -50,7 +53,10 @@ module HrLite
50
53
  return 0 if leave_type.unlimited?
51
54
  return paid_days ? LeaveDayCounter.count(self) - paid_days : 0 if approved?
52
55
 
53
- [ LeaveDayCounter.count(self) - covered_by(balance.available(as_of: start_date)), 0 ].max
56
+ # Pending requests of this type and leave year filed first draw on the same balance (warning only).
57
+ queued = self.class.pending.where(user_id: user_id, leave_type_id: leave_type_id, id: ...id,
58
+ start_date: LeaveYear.range(LeaveYear.key_for(start_date))).sum(:days_count)
59
+ [ LeaveDayCounter.count(self) - covered_by(balance.available(as_of: start_date) - queued), 0 ].max
54
60
  end
55
61
 
56
62
  # Unpaid share (0, 0.5 or 1) of `date`: the balance covered the leave's
@@ -113,6 +119,7 @@ module HrLite
113
119
  # And nobody should still be asked to decide a request that has been
114
120
  # called off — it would sit in their inbox for ever.
115
121
  approval_route.cancel_all!
122
+ flag_computed_runs! if was_approved
116
123
  end
117
124
 
118
125
  Notifications.publish(
@@ -229,6 +236,15 @@ module HrLite
229
236
  self.decision_note = note
230
237
  save!
231
238
  audit!(new_status, actor, note)
239
+ approval_route.cancel_all! # decided outside the flow too: nobody else is still asked
240
+ flag_computed_runs! if approved?
241
+ end
242
+ end
243
+
244
+ # A slip computed before this still pays the old days; a recompute clears the note.
245
+ def flag_computed_runs!
246
+ PayrollRun.where(status: "review", period_month: start_date.beginning_of_month..end_date).find_each do |run|
247
+ run.update!(warnings: run.warnings + [ "#{HrLite.display_name(user)}'s leave (#{date_range_label}) changed after compute — recompute" ])
232
248
  end
233
249
  end
234
250
 
@@ -270,8 +286,8 @@ module HrLite
270
286
 
271
287
  # Everyone should know a colleague will be away, the moment they apply —
272
288
  # bell + email to the whole team (matrix row "leave.team_notice"; hosts can mute either
273
- # channel). Deliberately excludes the reason: dates are team-relevant,
274
- # the why is not.
289
+ # channel). Deliberately excludes the reason and the type: dates are
290
+ # team-relevant, the why is not.
275
291
  def notify_team
276
292
  return if end_date < Date.current # nobody needs to hear who was out
277
293
 
@@ -280,7 +296,7 @@ module HrLite
280
296
 
281
297
  Notifications.publish(
282
298
  "leave.team_notice",
283
- title: "#{HrLite.display_name(user)} is on leave — #{leave_type.name} (#{date_range_label})",
299
+ title: "#{HrLite.display_name(user)} is on leave (#{date_range_label})",
284
300
  path: "/team",
285
301
  bell_to: team,
286
302
  email_to: team
@@ -336,6 +352,9 @@ module HrLite
336
352
  clash = self.class.where(user_id: user_id, status: %w[pending approved])
337
353
  .where.not(id: id)
338
354
  .overlapping_range(start_date, end_date)
355
+ # A first half and a second half of the same day do not overlap.
356
+ opposite = { "first" => "second", "second" => "first" }[half_day_part]
357
+ clash = clash.where.not(half_day_part: opposite).or(clash.where(half_day_part: nil)) if opposite
339
358
  errors.add(:base, "Leave already overlaps these dates") if clash.exists?
340
359
  end
341
360
 
@@ -20,6 +20,9 @@ module HrLite
20
20
  validate :period_is_not_already_paid, on: :create
21
21
  # Removing a line from a paid month rewrites a settled payslip just the same.
22
22
  before_destroy { throw :abort if paid_month? }
23
+ # A run already computed for the month still shows the old pay.
24
+ after_create { flag_stale_run("added") }
25
+ after_destroy { flag_stale_run("removed") }
23
26
 
24
27
  scope :for_month, ->(month) { where(period_month: month) }
25
28
 
@@ -55,5 +58,10 @@ module HrLite
55
58
  end
56
59
 
57
60
  def paid_month? = SalarySlip.settled.exists?(user_id: user_id, period_month: period_month)
61
+
62
+ def flag_stale_run(verb)
63
+ run = PayrollRun.find_by(period_month: period_month, status: "review")
64
+ run&.update!(warnings: run.warnings + [ "#{HrLite.display_name(user)}'s #{component.label} was #{verb} after compute — recompute" ])
65
+ end
58
66
  end
59
67
  end
@@ -1,7 +1,7 @@
1
1
  module HrLite
2
2
  # Monthly run lifecycle: draft -> processing -> review -> finalized ->
3
- # published (terminal). Compute is synchronous (pure-Ruby math over a
4
- # small team; the seam for a background job is one perform_later away).
3
+ # published (terminal). The page computes through PayrollComputeJob
4
+ # (compute_later!); compute! itself is synchronous.
5
5
  class PayrollRun < ApplicationRecord
6
6
  STATUSES = %w[draft processing review finalized published].freeze
7
7
 
@@ -12,8 +12,9 @@ module HrLite
12
12
  belongs_to :first_approved_by, class_name: HrLite.config.user_class, optional: true
13
13
 
14
14
  # A run with one of these is not paid on the 3rd without a person looking:
15
- # someone missing, a state levying ₹0, or last year's / unverified rates.
16
- BLOCKING_WARNING = /\ANo (salary structure|professional-tax slabs)|statutory card/
15
+ # someone missing, a state levying ₹0, last year's / unverified rates, or
16
+ # a change after compute ("… after compute — recompute") the slips lack.
17
+ BLOCKING_WARNING = /\ANo (salary structure|professional-tax slabs)|statutory card|after compute/
17
18
 
18
19
  validates :period_month, presence: true, uniqueness: true
19
20
  validates :status, inclusion: { in: STATUSES }
@@ -37,9 +38,9 @@ module HrLite
37
38
  # `processing` is allowed back in so a run stranded there by a killed
38
39
  # process (deploy roll, timeout) can be recomputed instead of blocking
39
40
  # that month forever.
40
- def compute!(actor:)
41
+ def compute!(actor:, from: status)
41
42
  raise_unless %w[draft review processing]
42
- previous = status
43
+ previous = from
43
44
 
44
45
  begin
45
46
  update!(status: "processing")
@@ -61,6 +62,14 @@ module HrLite
61
62
  end
62
63
  end
63
64
 
65
+ # ~1.2k queries at 54 people: the request only flips the run to processing.
66
+ def compute_later!(actor:)
67
+ raise_unless %w[draft review processing]
68
+ from = status
69
+ update!(status: "processing")
70
+ PayrollComputeJob.perform_later(self, from, actor)
71
+ end
72
+
64
73
  def finalize!(actor:)
65
74
  raise_unless %w[review]
66
75
  raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?
@@ -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
@@ -63,7 +63,7 @@ module HrLite
63
63
 
64
64
  # Bump the version when the PDF template changes, or the old layout serves until the cache expires.
65
65
  def pdf_cache_key
66
- [ "hr_lite_slip_v2", id, updated_at.to_i, payroll_run.status,
66
+ [ "hr_lite_slip_v3", id, updated_at.to_i, payroll_run.status,
67
67
  user_profile&.updated_at.to_i ].join("/")
68
68
  end
69
69
 
@@ -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>
@@ -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 %>
@@ -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 %>