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
@@ -52,7 +52,8 @@ module HrLite
52
52
  def next_financial_year(newest)
53
53
  return FinancialYear.start_for(Date.current) if newest.nil?
54
54
 
55
- [ newest.effective_from.next_year, FinancialYear.start_for(Date.current) ].max
55
+ # A mid-year card (1 Oct) must not push the next one to 1 Oct: years start on 1 April.
56
+ [ FinancialYear.start_for(newest.effective_from.next_year), FinancialYear.start_for(Date.current) ].max
56
57
  end
57
58
 
58
59
  # The figures arrive as flat strings from number fields and are stored
@@ -11,7 +11,7 @@ module HrLite
11
11
  @status = params[:status].presence_in(TaxDeclaration::STATUSES) || "submitted"
12
12
  @declarations = paginate(
13
13
  hr_scope(TaxDeclaration.includes(:user, :tax_declaration_items), "tax.view")
14
- .where(status: @status).order(created_at: :desc)
14
+ .where(status: @status).where.not(user_id: hr_current_user.id).order(created_at: :desc)
15
15
  )
16
16
  end
17
17
 
@@ -45,7 +45,7 @@ module HrLite
45
45
 
46
46
  AuditLog.record!(action: "tax_proof.downloaded", subject: declaration, actor: hr_current_user,
47
47
  changes: { "file" => file.filename.to_s, "owner" => HrLite.display_name(declaration.user) })
48
- redirect_to Rails.application.routes.url_helpers.rails_blob_path(file, disposition: "attachment", only_path: true),
48
+ redirect_to Rails.application.routes.url_helpers.rails_blob_path(file, disposition: "attachment", only_path: true, expires_in: 5.minutes),
49
49
  allow_other_host: false
50
50
  end
51
51
 
@@ -57,6 +57,8 @@ module HrLite
57
57
  declaration.tax_declaration_items.each do |item|
58
58
  item.update!(verified_amount: item.declared_amount) if item.verified_amount.nil?
59
59
  end
60
+ AuditLog.record!(action: "tax_declaration.accepted_all", subject: declaration, actor: hr_current_user,
61
+ changes: { "owner" => HrLite.display_name(declaration.user) })
60
62
  declaration.verify!(actor: hr_current_user, note: "Proof accepted")
61
63
  end
62
64
  redirect_to admin_tax_declarations_path, notice: "Accepted and verified — payroll uses these amounts now."
@@ -66,9 +68,17 @@ module HrLite
66
68
 
67
69
  def update
68
70
  @declaration = find_manageable
71
+ return redirect_to admin_tax_declaration_path(@declaration), alert: "Only a submitted declaration can be checked." unless @declaration.submitted?
72
+
69
73
  # Recording what the proof supported is a separate act from deciding
70
74
  # — an amount can be corrected while the decision is still pending.
71
- if @declaration.update(verification_params)
75
+ recorded = TaxDeclaration.transaction do
76
+ @declaration.update(verification_params) &&
77
+ AuditLog.record!(action: "tax_declaration.amounts_recorded", subject: @declaration, actor: hr_current_user,
78
+ changes: { "owner" => HrLite.display_name(@declaration.user) })
79
+ end
80
+ if recorded
81
+ return verify if params[:verify] # "Record and verify": the typed amounts are saved first
72
82
  redirect_to admin_tax_declaration_path(@declaration), notice: "Amounts recorded."
73
83
  else
74
84
  @tax = TaxComputation.new(user: @declaration.user, declaration: @declaration)
@@ -81,7 +91,8 @@ module HrLite
81
91
  declaration.verify!(actor: hr_current_user, note: params[:note])
82
92
  redirect_to admin_tax_declarations_path, notice: "Verified — payroll will use the checked amounts."
83
93
  rescue ActiveRecord::RecordInvalid
84
- redirect_to admin_tax_declaration_path(declaration), alert: "Only a submitted declaration can be verified."
94
+ redirect_to admin_tax_declaration_path(declaration),
95
+ alert: declaration.errors.full_messages.first || "Only a submitted declaration can be verified."
85
96
  end
86
97
 
87
98
  def reject
@@ -5,7 +5,7 @@ module HrLite
5
5
  # Coordinates are only shown here, inside attendance.view reach.
6
6
  class WorkLocationsController < BaseController
7
7
  def index
8
- @status = params[:status].presence_in(WorkLocation::STATUSES) || "approved"
8
+ @status = params[:status].presence_in(WorkLocation::STATUSES) || "pending"
9
9
  @locations = paginate(hr_scope(WorkLocation.where(status: @status), "attendance.view")
10
10
  .includes(:user).order(created_at: :desc))
11
11
  end
@@ -9,6 +9,7 @@ module HrLite
9
9
  class ApprovalsController < ApplicationController
10
10
  def index
11
11
  @approvals = paginate(Approval.pending_for(hr_current_user).includes(:step, :subject).order(:created_at))
12
+ @in_flow = Approval.pending_for(hr_current_user).pluck(:subject_type, :subject_id) # their queue rows would repeat them
12
13
  @delegations = ApprovalDelegation.live_on(Date.current)
13
14
  .where(from_user_id: hr_current_user.id)
14
15
  .includes(:to_user)
@@ -3,27 +3,18 @@ module HrLite
3
3
  def index
4
4
  @latest_kudos = Kudo.recent.includes(:giver, kudo_mentions: :user).limit(3)
5
5
  @awards = Award.current
6
- @out_week = LeaveRequest.approved.overlapping_range(Date.current.beginning_of_week, Date.current.end_of_week)
6
+ @out_week = LeaveRequest.approved.overlapping_range(Date.current, Date.current.end_of_week)
7
7
  .includes(:user).where(user_id: HrLite.active_employees.map(&:id))
8
8
  .sort_by { |leave| hr_display_name(leave.user).downcase }
9
9
  @attention = attention_days
10
10
  @waiting = Approval.pending_for(hr_current_user).count
11
- @queue = queue_count
12
- @mine_pending = [ LeaveRequest.includes(:leave_type), RegularizationRequest, CompOffRequest ]
11
+ @mine_pending = [ LeaveRequest.includes(:leave_type), RegularizationRequest, CompOffRequest, WfhRequest ]
13
12
  .flat_map { |scope| scope.pending.where(user_id: hr_current_user.id).order(created_at: :desc).limit(5) }
14
13
  .max_by(5, &:created_at)
15
14
  end
16
15
 
17
16
  private
18
17
 
19
- # Requests with no routed flow are decided straight off the HR queues, scoped as those queues decide them.
20
- def queue_count
21
- leave = ApprovalFlow.for(LeaveRequest.name) ? 0 : decidable(LeaveRequest, "leave.approve")
22
- leave + decidable(CompOffRequest, "leave.approve") + decidable(RegularizationRequest, "attendance.manage")
23
- end
24
-
25
- def decidable(model, key) = hr_scope(model.pending, key).where.not(user_id: hr_current_user.id).count
26
-
27
18
  # Last week's days that still need a fix (absent, no check-out, flagged, short), minus those with a pending ticket.
28
19
  def attention_days
29
20
  range = (Date.current - 7)..(Date.current - 1)
@@ -4,7 +4,7 @@ module HrLite
4
4
  class LoansController < ApplicationController
5
5
  def index
6
6
  @loans = Loan.where(user_id: hr_current_user.id).order(starts_on: :desc)
7
- @outstanding = @loans.sum(BigDecimal(0), &:outstanding)
7
+ @outstanding = @loans.active.sum(BigDecimal(0), &:outstanding)
8
8
  @next_month = Date.current.next_month.beginning_of_month
9
9
  end
10
10
  end
@@ -22,19 +22,19 @@ module HrLite
22
22
  def cancel
23
23
  request = RegularizationRequest.where(user_id: hr_current_user.id).find(params[:id])
24
24
  request.cancel!(actor: hr_current_user)
25
- redirect_to regularization_requests_path, notice: "Ticket cancelled."
25
+ redirect_to regularization_requests_path, notice: "Day fix cancelled."
26
26
  rescue ActiveRecord::RecordInvalid
27
- redirect_to regularization_requests_path, alert: "Only pending tickets can be cancelled."
27
+ redirect_to regularization_requests_path, alert: "Only pending day fixes can be cancelled."
28
28
  end
29
29
 
30
30
  private
31
31
 
32
32
  def create_notice
33
33
  rule = HrLite.config.self_regularization
34
- return "Ticket raised — an admin will review it." unless rule
34
+ return "Sent for approval — your manager or HR will review it." unless rule
35
35
  return "Fixed. #{helpers.pluralize(@request.self_fixes_left, 'self-fix')} left this week." if @request.approved?
36
36
 
37
- "Sent to HR as a ticket — you can fix only a missed punch or short day on a working day in the last #{rule[:within_days]} days yourself, " \
37
+ "Sent for approval — you can fix only a missed punch or short day on a working day in the last #{rule[:within_days]} days yourself, " \
38
38
  "#{rule[:per_week]} times a week."
39
39
  end
40
40
 
@@ -28,10 +28,11 @@ module HrLite
28
28
  TaxDeclaration.new(user_id: hr_current_user.id,
29
29
  financial_year: financial_year, regime: employee_regime)
30
30
  # A verified declaration is what payroll is deducting against; changing
31
- # it would silently re-open a decision HR has already made.
32
- if @declaration.verified?
31
+ # it would silently re-open a decision HR has already made. A submitted
32
+ # one is what HR is checking, so it cannot shift under them either.
33
+ if @declaration.verified? || @declaration.submitted?
33
34
  return redirect_to tax_declaration_path,
34
- alert: "This year's declaration is verified. Ask HR to reopen it."
35
+ alert: "This year's declaration is #{@declaration.status}. Ask HR to reopen it."
35
36
  end
36
37
 
37
38
  if @declaration.update(declaration_params)
@@ -59,10 +60,13 @@ module HrLite
59
60
 
60
61
  private
61
62
 
63
+ # This year or last (late proof); any other year falls back to this one.
62
64
  def financial_year
63
- FinancialYear.start_for(params[:financial_year].present? ? Date.parse(params[:financial_year]) : Date.current)
65
+ current = FinancialYear.start_for(Date.current)
66
+ asked = FinancialYear.start_for(Date.parse(params[:financial_year].to_s))
67
+ asked.between?(current.prev_year, current) ? asked : current
64
68
  rescue Date::Error
65
- FinancialYear.start_for(Date.current)
69
+ current
66
70
  end
67
71
 
68
72
  def current_declaration
@@ -41,14 +41,16 @@ 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
- 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) },
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,15 +89,16 @@ 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: [
94
- { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting", "/admin/expense_categories" ], if: LEAD },
96
+ { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/expense_categories" ], if: LEAD },
97
+ { label: "HR settings", path: :edit_admin_setting_path, match: [ "/admin/setting" ], if: LEAD },
95
98
  { label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ], if: -> { hr_can?("role.manage", scope: :all) } },
96
99
  { label: "Statutory rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ], if: SUPER },
97
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) } } },
98
- { 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) } },
99
102
  { label: "Audit trail", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ], if: LEAD }
100
103
  ] }
101
104
  ].freeze
@@ -151,6 +154,28 @@ module HrLite
151
154
  # What a queue page lets this viewer decide: their reach, never their own requests.
152
155
  def hrl_queue(relation, key) = hr_access.scope_relation(relation, key).where.not(user_id: hr_current_user.id)
153
156
 
157
+ # The Inbox badge's number; Home and the overview board show the same one.
158
+ def hrl_inbox_count
159
+ inbox = hrl_sections.find { |s| s[:label] == "Inbox" }
160
+ inbox ? hrl_waiting(inbox).to_i : 0
161
+ end
162
+
163
+ # One queued request in words, and where it is decided.
164
+ def hrl_inbox_item(tab, record)
165
+ case record
166
+ when HrLite::LeaveRequest
167
+ [ "#{record.leave_type.name}, #{record.date_range_label} · #{pluralize(hrl_days(record.days_count), 'day')}", hr_lite.admin_leave_request_path(record) ]
168
+ when HrLite::CompOffRequest then [ "Comp-off for #{record.date_worked.strftime('%a %-d %b')}", hr_lite.admin_comp_off_request_path(record) ]
169
+ when HrLite::RegularizationRequest then [ "Day fix, #{record.date.strftime('%a %-d %b')}", hr_lite.admin_regularization_request_path(record) ]
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 ]
175
+ else [ tab[:label], hr_lite.public_send(tab[:path]) ]
176
+ end
177
+ end
178
+
154
179
  # Everything waiting in this viewer's queues, oldest first, as [tab, record].
155
180
  def hrl_inbox_rows(limit = 50)
156
181
  inbox = hrl_sections.find { |s| s[:label] == "Inbox" } or return []
@@ -281,8 +306,8 @@ module HrLite
281
306
  # Status pill for a request lifecycle string — one colour map for every
282
307
  # list and show screen.
283
308
  def hrl_request_status_badge(status)
284
- css = { "approved" => "hrl-badge--ok", "rejected" => "hrl-badge--bad",
285
- "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]
286
311
  content_tag(:span, status.humanize, class: "hrl-badge #{css}")
287
312
  end
288
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) }
@@ -57,9 +58,11 @@ module HrLite
57
58
  EmployeeProfile.day_minutes_for(user_id, on: date) unless HrLite.config.work_hours.nil? || date.saturday?
58
59
  end
59
60
 
60
- # Hours worked, not the clock: an early start counts.
61
+ # Hours worked, not the clock: an early start counts. The day's penalty
62
+ # minutes count too, the same rule as required_check_out_at, so the card
63
+ # never says "day done" above "short by 20m".
61
64
  def short?
62
- worked_under?(required_minutes)
65
+ worked_under?(required_minutes && required_minutes + penalty_minutes)
63
66
  end
64
67
 
65
68
  def below_half_day?
@@ -70,10 +73,11 @@ module HrLite
70
73
  def required_check_out_at
71
74
  return unless HrLite.config.attendance_slots && check_in_at
72
75
 
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
76
+ check_in_at + (EmployeeProfile.day_minutes_for(user_id, on: date) + penalty_minutes).minutes
75
77
  end
76
78
 
79
+ def penalty_minutes = AttendancePenalty.active.where(user_id: user_id, date: date).sum(:minutes)
80
+
77
81
  def worked_under?(minutes) = !!(minutes && worked_duration && worked_duration < minutes.minutes)
78
82
 
79
83
  # Check in again after checking out: once a day, inside the hours HR sets in
@@ -115,5 +119,18 @@ module HrLite
115
119
 
116
120
  errors.add(:check_out_at, "must be after check-in")
117
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
118
135
  end
119
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?