hr_lite 0.15.0 → 0.18.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/README.md +5 -1
  4. data/app/assets/javascripts/hr_lite/print.js +5 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +72 -0
  6. data/app/controllers/hr_lite/admin/benefits_controller.rb +61 -0
  7. data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
  8. data/app/controllers/hr_lite/admin/expense_categories_controller.rb +43 -0
  9. data/app/controllers/hr_lite/admin/holidays_controller.rb +7 -0
  10. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -1
  11. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +24 -1
  12. data/app/controllers/hr_lite/admin/payroll_line_items_controller.rb +47 -0
  13. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +3 -1
  14. data/app/controllers/hr_lite/admin/policies_controller.rb +29 -0
  15. data/app/controllers/hr_lite/hr_requests_controller.rb +2 -0
  16. data/app/controllers/hr_lite/id_cards_controller.rb +25 -0
  17. data/app/controllers/hr_lite/leave_requests_controller.rb +3 -1
  18. data/app/controllers/hr_lite/salary_slips_controller.rb +6 -6
  19. data/app/helpers/hr_lite/application_helper.rb +21 -6
  20. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +4 -3
  21. data/app/jobs/hr_lite/slips_ready_job.rb +10 -0
  22. data/app/models/hr_lite/audit_log.rb +1 -1
  23. data/app/models/hr_lite/benefit_enrolment.rb +1 -1
  24. data/app/models/hr_lite/employee_profile.rb +21 -3
  25. data/app/models/hr_lite/hr_request.rb +12 -3
  26. data/app/models/hr_lite/leave_balance.rb +2 -1
  27. data/app/models/hr_lite/leave_request.rb +51 -19
  28. data/app/models/hr_lite/payroll_line_item.rb +5 -3
  29. data/app/models/hr_lite/payroll_run.rb +19 -3
  30. data/app/models/hr_lite/policy.rb +15 -0
  31. data/app/models/hr_lite/salary_slip.rb +6 -0
  32. data/app/models/hr_lite/setting.rb +1 -1
  33. data/app/services/hr_lite/attendance_summary.rb +12 -15
  34. data/app/views/hr_lite/admin/benefits/index.html.erb +65 -0
  35. data/app/views/hr_lite/admin/benefits/new.html.erb +37 -0
  36. data/app/views/hr_lite/admin/employees/_form.html.erb +5 -0
  37. data/app/views/hr_lite/admin/employees/show.html.erb +1 -0
  38. data/app/views/hr_lite/admin/expense_categories/_form.html.erb +16 -0
  39. data/app/views/hr_lite/admin/expense_categories/edit.html.erb +3 -0
  40. data/app/views/hr_lite/admin/expense_categories/index.html.erb +30 -0
  41. data/app/views/hr_lite/admin/expense_categories/new.html.erb +3 -0
  42. data/app/views/hr_lite/admin/hr_requests/show.html.erb +3 -0
  43. data/app/views/hr_lite/admin/leave_balances/index.html.erb +4 -0
  44. data/app/views/hr_lite/admin/leave_requests/index.html.erb +3 -0
  45. data/app/views/hr_lite/admin/leave_requests/new.html.erb +35 -0
  46. data/app/views/hr_lite/admin/leave_requests/show.html.erb +3 -0
  47. data/app/views/hr_lite/admin/leave_types/index.html.erb +1 -0
  48. data/app/views/hr_lite/admin/payroll_line_items/index.html.erb +47 -0
  49. data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +42 -0
  50. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +1 -0
  51. data/app/views/hr_lite/admin/policies/index.html.erb +27 -0
  52. data/app/views/hr_lite/admin/policies/new.html.erb +22 -0
  53. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +5 -1
  54. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +3 -1
  55. data/app/views/hr_lite/id_cards/show.html.erb +86 -0
  56. data/app/views/hr_lite/leave_requests/index.html.erb +1 -0
  57. data/app/views/hr_lite/leave_requests/new.html.erb +1 -0
  58. data/app/views/hr_lite/salary_slips/index.html.erb +1 -1
  59. data/app/views/hr_lite/shared/_bell.html.erb +3 -0
  60. data/app/views/layouts/hr_lite/application.html.erb +6 -4
  61. data/config/routes.rb +9 -1
  62. data/db/migrate/20260927100145_add_id_card_fields_to_hr_lite_employee_profiles.rb +6 -0
  63. data/db/migrate/20260927101031_add_created_by_id_to_hr_lite_leave_requests.rb +5 -0
  64. data/db/migrate/20260929081109_add_paid_days_to_hr_lite_leave_requests.rb +5 -0
  65. data/lib/hr_lite/configuration.rb +15 -2
  66. data/lib/hr_lite/notifications.rb +10 -5
  67. data/lib/hr_lite/version.rb +1 -1
  68. data/lib/hr_lite.rb +1 -0
  69. metadata +38 -1
@@ -11,7 +11,8 @@ module HrLite
11
11
  # belongs in "More" rather than displacing something everybody uses.
12
12
  # Employee tier all the same — holding an approval IS the authorisation,
13
13
  # so a manager and a stand-in covering for one reach the same screen.
14
- { label: "Approvals", path: :approvals_path, match: [ "/approvals" ] },
14
+ { label: "Approvals", path: :approvals_path, match: [ "/approvals" ],
15
+ if: -> { HrLite::ApprovalFlow.where(active: true).exists? } },
15
16
  { label: "Expenses", path: :expenses_path, match: [ "/expenses" ] },
16
17
  { label: "Benefits", path: :benefits_path, match: [ "/benefits" ] },
17
18
  { label: "Policies", path: :policies_path, match: [ "/policies" ] },
@@ -19,8 +20,10 @@ module HrLite
19
20
  { label: "Org", path: :org_path, match: [ "/org" ] },
20
21
  { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
21
22
  { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
22
- { label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] },
23
+ { label: "Career", path: :career_path, match: [ "/career", "/appraisals" ] },
24
+ { label: "Profile", path: :employee_profile_path, match: [ "/profile", "/resignation" ] },
23
25
  { label: "Documents", path: :documents_path, match: [ "/documents" ] },
26
+ { label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
24
27
  { label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
25
28
  { label: "Loans", path: :loans_path, match: [ "/loans" ] }
26
29
  ].freeze
@@ -30,16 +33,19 @@ module HrLite
30
33
  { label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
31
34
  { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
32
35
  { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] },
33
- { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ] },
36
+ { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ],
37
+ if: -> { hr_can?("expense.approve", scope: :team) } },
34
38
  { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
35
39
  { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
40
+ { label: "Benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ] },
36
41
  { label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
37
42
  ].freeze
38
43
 
39
44
  LEADERSHIP_NAV_ITEMS = [
40
45
  { label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
41
- { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
46
+ { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting", "/admin/expense_categories" ] },
42
47
  { label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
48
+ { label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
43
49
  { label: "Documents", path: :admin_documents_path, match: [ "/admin/documents" ] }
44
50
  ].freeze
45
51
 
@@ -54,9 +60,18 @@ module HrLite
54
60
  { label: "Loans", path: :admin_loans_path, match: [ "/admin/loans" ] }
55
61
  ].freeze
56
62
 
57
- # Only items whose routes exist yet (the nav grows with each phase).
63
+ # The ID card's QR: office address and phone as plain text, so any phone
64
+ # camera shows who to call about a found card. No URL, nothing to host.
65
+ def hrl_office_qr_svg(company, brand)
66
+ text = [ brand, company[:address], company[:phone] ].compact_blank.join("\n")
67
+ RQRCode::QRCode.new(text).as_svg(use_path: true, viewbox: true, color: "0F172A").html_safe
68
+ end
69
+
70
+ # Only items whose routes exist yet (the nav grows with each phase), and
71
+ # whose optional `if:` holds — a link that always lands on "access
72
+ # denied" or an empty page is worse than no link.
58
73
  def hrl_nav_items(items)
59
- items.select { |item| hr_lite_route?(item[:path]) }
74
+ items.select { |item| hr_lite_route?(item[:path]) && (item[:if].nil? || instance_exec(&item[:if])) }
60
75
  end
61
76
 
62
77
  def hr_lite_route?(helper_name)
@@ -1,7 +1,7 @@
1
1
  module HrLite
2
2
  # Monthly automation: on the 1st, draft + compute the previous month's
3
- # payroll from attendance and the policy, then tell leadership it is
4
- # waiting for review. Publishing stays a deliberate human action —
3
+ # payroll from attendance and the policy, then tell whoever runs payroll
4
+ # (payroll.manage) it is waiting for review — leadership cannot open it. Publishing stays a deliberate human action —
5
5
  # the system prepares, people approve.
6
6
  class PayrollAutoDraftJob < ApplicationJob
7
7
  queue_as :default
@@ -16,10 +16,11 @@ module HrLite
16
16
  return unless run.draft? && run.salary_slips.none?
17
17
 
18
18
  run.compute!(actor: nil)
19
+ payroll = HrLite.users_holding("payroll.manage", scope: :all).to_a
19
20
  Notifications.publish(
20
21
  "payroll.draft_ready",
21
22
  title: "Payroll #{run.label} computed from attendance — #{run.salary_slips.count} slips await review",
22
- path: "/admin/payroll_runs/#{run.id}"
23
+ path: "/admin/payroll_runs/#{run.id}", bell_to: payroll, email_to: payroll
23
24
  )
24
25
  end
25
26
  end
@@ -0,0 +1,10 @@
1
+ module HrLite
2
+ # Tells employees their slip is ready on config.slip_release_day, when
3
+ # SalarySlip.released first lets them open it. Enqueued by
4
+ # PayrollRun#publish!, which already sent the leadership copy.
5
+ class SlipsReadyJob < ApplicationJob
6
+ def perform(run)
7
+ run.notify_slips_ready(leadership: false)
8
+ end
9
+ end
10
+ end
@@ -14,7 +14,7 @@ module HrLite
14
14
  # leadership audit screen or the policy.changed email.
15
15
  MONEY_TIER_TYPES = %w[
16
16
  HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
17
- HrLite::PayrollRun HrLite::SalarySlip
17
+ HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem
18
18
  ].freeze
19
19
 
20
20
  scope :recent, -> { order(created_at: :desc) }
@@ -9,7 +9,7 @@ module HrLite
9
9
  belongs_to :user, class_name: HrLite.config.user_class
10
10
 
11
11
  validates :enrolled_on, presence: true
12
- validates :user_id, uniqueness: { scope: :benefit_id }
12
+ validates :user_id, uniqueness: { scope: :benefit_id, message: "is already enrolled" }
13
13
  validates :dependants, numericality: { greater_than_or_equal_to: 0 }
14
14
  validate :ended_after_it_started
15
15
 
@@ -13,7 +13,7 @@ module HrLite
13
13
  # profiles as roots (founders/directors).
14
14
  belongs_to :manager, class_name: HrLite.config.user_class, optional: true
15
15
 
16
- encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc
16
+ encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc, :blood_group, :emergency_contact
17
17
  # fy_opening_*: income already paid this financial year that this install
18
18
  # did not run — a previous employer, or the months before payroll was
19
19
  # switched on here. Feeds the TDS projection so a mid-year start does not
@@ -21,12 +21,21 @@ module HrLite
21
21
  encrypted_money :declared_annual_deductions, :fy_opening_gross, :fy_opening_tds
22
22
 
23
23
  TAX_REGIMES = %w[new old].freeze
24
+ BLOOD_GROUPS = %w[A+ A- B+ B- AB+ AB- O+ O-].freeze
25
+ # The ID-card photo. Images a browser can print; content-sniffed like
26
+ # Document, so the check is on the real bytes.
27
+ PHOTO_TYPES = %w[image/jpeg image/png image/webp].freeze
28
+ PHOTO_MAX_BYTES = 5.megabytes
29
+
30
+ has_one_attached :photo
24
31
 
25
32
  # Onboarding-form virtuals (the controller hands them to
26
33
  # config.onboard_user; never persisted, never audited).
27
34
  attr_accessor :new_user_name, :new_user_email, :new_user_password
28
35
 
29
36
  before_validation :assign_employee_code, on: :create
37
+ # Without a role a new hire cannot see policies or raise an HR request.
38
+ after_create { (role = Role.find_by(name: Role::EMPLOYEE)) && RoleAssignment.find_or_create_by!(user_id:, role:) }
30
39
 
31
40
  validates :employee_code, presence: true, uniqueness: true
32
41
  validates :user_id, uniqueness: true
@@ -37,6 +46,8 @@ module HrLite
37
46
  validates :bank_ifsc, format: { with: /\A[A-Z]{4}0[A-Z0-9]{6}\z/, message: "is not a valid IFSC" },
38
47
  allow_blank: true
39
48
  validates :pf_uan, format: { with: /\A\d{12}\z/, message: "must be 12 digits" }, allow_blank: true
49
+ validates :blood_group, inclusion: { in: BLOOD_GROUPS }, allow_blank: true
50
+ validate :photo_is_a_small_image
40
51
  validate :exit_after_joining
41
52
  validate :manager_chain_acyclic
42
53
  validate :manager_is_active_staff, if: :manager_id_changed?
@@ -97,7 +108,7 @@ module HrLite
97
108
  private
98
109
 
99
110
  # Codes are system-assigned: Settings prefix + zero-padded next number
100
- # (EMP001, EMP002, ...). Scans the highest existing suffix for the
111
+ # (ESA-000001, ESA-000002, ...). Scans the highest existing suffix for the
101
112
  # CURRENT prefix so changing the prefix restarts a fresh sequence
102
113
  # without colliding with history.
103
114
  def assign_employee_code
@@ -108,7 +119,7 @@ module HrLite
108
119
  .pluck(:employee_code)
109
120
  .filter_map { |code| code.delete_prefix(prefix)[/\A\d+\z/]&.to_i }
110
121
  .max || 0
111
- self.employee_code = format("%s%03d", prefix, last + 1)
122
+ self.employee_code = format("%s%06d", prefix, last + 1)
112
123
  end
113
124
 
114
125
  def sanitize_sql_like(value)
@@ -140,6 +151,13 @@ module HrLite
140
151
  end
141
152
  end
142
153
 
154
+ def photo_is_a_small_image
155
+ return unless photo.attached?
156
+ return errors.add(:photo, "must be a JPG, PNG or WebP image") unless PHOTO_TYPES.include?(photo.blob.content_type)
157
+
158
+ errors.add(:photo, "must be under #{PHOTO_MAX_BYTES / 1.megabyte} MB") if photo.blob.byte_size > PHOTO_MAX_BYTES
159
+ end
160
+
143
161
  def mask_middle(value)
144
162
  return nil if value.blank?
145
163
  return "•" * value.length if value.length <= 4
@@ -8,7 +8,7 @@ module HrLite
8
8
 
9
9
  CATEGORIES = %w[
10
10
  salary_certificate employment_certificate address_change bank_change
11
- document_request payroll_query tax_query insurance_query policy_query other
11
+ document_request payroll_query tax_query insurance_query policy_query id_card other
12
12
  ].freeze
13
13
 
14
14
  belongs_to :user, class_name: HrLite.config.user_class
@@ -17,6 +17,7 @@ module HrLite
17
17
  validates :subject, presence: true
18
18
  validates :category, inclusion: { in: CATEGORIES }
19
19
  validates :status, inclusion: { in: STATUSES }
20
+ validate :id_card_can_be_printed, on: :create, if: -> { category == "id_card" }
20
21
 
21
22
  scope :open_requests, -> { where(status: %w[open in_progress]) }
22
23
  scope :recent_first, -> { order(created_at: :desc) }
@@ -25,7 +26,7 @@ module HrLite
25
26
 
26
27
  after_create_commit :notify_desk
27
28
 
28
- def category_label = category.humanize
29
+ def category_label = category.humanize.sub(/\AId\b/, "ID")
29
30
 
30
31
  def assign!(actor:, assignee:)
31
32
  update!(assigned_to_id: assignee.id, status: "in_progress")
@@ -68,8 +69,16 @@ module HrLite
68
69
  Notifications.publish(
69
70
  "hr_request.raised",
70
71
  title: "#{HrLite.display_name(user)} asked: #{subject}",
71
- body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk
72
+ body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk, email_to: desk
72
73
  )
73
74
  end
75
+
76
+ def id_card_can_be_printed
77
+ if !EmployeeProfile.find_by(user_id: user_id)&.photo&.attached?
78
+ errors.add(:base, "Add your photo to the ID card first")
79
+ elsif self.class.open_requests.exists?(user_id: user_id, category: "id_card")
80
+ errors.add(:base, "Your ID card print is already requested")
81
+ end
82
+ end
74
83
  end
75
84
  end
@@ -63,7 +63,8 @@ module HrLite
63
63
  # each — and the admin balances grid reads this cell for every employee
64
64
  # times every leave type.
65
65
  calendar = WorkingCalendar.new(range)
66
- requests.sum { |request| LeaveDayCounter.count(request, calendar: calendar) }
66
+ # Loss-of-pay days (beyond paid_days) never drew on the balance.
67
+ requests.sum { |request| [ LeaveDayCounter.count(request, calendar: calendar), request.paid_days ].compact.min }
67
68
  end
68
69
 
69
70
  def available(as_of: Date.current)
@@ -7,6 +7,8 @@ module HrLite
7
7
  belongs_to :user, class_name: HrLite.config.user_class
8
8
  belongs_to :leave_type
9
9
  belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
10
+ # Set when HR records leave the employee forgot to apply for.
11
+ belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
10
12
 
11
13
  validates :start_date, :end_date, presence: true
12
14
  validates :status, inclusion: { in: STATUSES }
@@ -22,7 +24,7 @@ module HrLite
22
24
  end
23
25
 
24
26
  before_validation :cache_days_count, on: :create
25
- after_create :notify_requested
27
+ after_create :notify_requested, unless: :recorded_by_hr?
26
28
 
27
29
  scope :pending, -> { where(status: "pending") }
28
30
  scope :approved, -> { where(status: "approved") }
@@ -38,6 +40,26 @@ module HrLite
38
40
  leave_type.paid
39
41
  end
40
42
 
43
+ # Days this request would take past the balance — the loss of pay
44
+ # approving it today would fix, or did fix once approved. 0 for unlimited types.
45
+ def days_beyond_balance
46
+ return 0 if leave_type.unlimited?
47
+ return paid_days ? LeaveDayCounter.count(self) - paid_days : 0 if approved?
48
+
49
+ [ LeaveDayCounter.count(self) - covered_by(balance.available(as_of: start_date)), 0 ].max
50
+ end
51
+
52
+ # Unpaid share (0, 0.5 or 1) of `date`: the balance covered the leave's
53
+ # EARLIEST working days (paid_days of them), so the rest are loss of pay.
54
+ def unpaid_on(date)
55
+ return 0 if paid_days.nil?
56
+
57
+ @leave_calendar ||= WorkingCalendar.new(start_date..end_date)
58
+ through = LeaveDayCounter.count_range(start_date: start_date, end_date: date, half_day: half_day,
59
+ calendar: @leave_calendar)
60
+ (through - paid_days).clamp(0, half_day ? BigDecimal("0.5") : 1)
61
+ end
62
+
41
63
  # --- transitions -------------------------------------------------------
42
64
 
43
65
  # With a flow configured this is ONE RUNG: the request becomes `approved`
@@ -45,9 +67,10 @@ module HrLite
45
67
  # the flow is not waiting on, such as HR overriding — the first approval
46
68
  # settles it, exactly as before.
47
69
  #
48
- # Returns false (leaving the request pending) when the balance no longer
49
- # covers it; the re-check runs inside the row lock so two concurrent
50
- # approvals cannot overdraw one balance.
70
+ # Days beyond the balance are approved as loss of pay (paid_days); only
71
+ # comp-off returns false (still pending) when its credit no longer covers
72
+ # it. The balance is read inside the row lock, so two concurrent
73
+ # approvals cannot both spend it.
51
74
  def approve!(actor:, note: nil)
52
75
  return record_routed_decision!(actor, note, :approved) if awaiting?(actor)
53
76
 
@@ -130,15 +153,20 @@ module HrLite
130
153
  def approve_outright!(actor:, note:)
131
154
  insufficient = false
132
155
  transition!("approved", actor, note) do
156
+ next if leave_type.unlimited?
157
+
133
158
  # Serialize on the BALANCE row. `transition!`'s own lock is on this
134
159
  # request, and two pending requests are two different rows — so both
135
160
  # approvals could read the same untouched balance and overdraw it.
136
- LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)) unless leave_type.unlimited?
161
+ # As of start_date: the same date the create-time check uses.
162
+ covered = covered_by(LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)).available(as_of: start_date))
163
+ next if LeaveDayCounter.count(self) <= covered
137
164
 
138
- if insufficient_balance_now?
165
+ if leave_type.comp_off
139
166
  insufficient = true
140
167
  raise ActiveRecord::Rollback
141
168
  end
169
+ self.paid_days = covered
142
170
  end
143
171
  return false if insufficient
144
172
 
@@ -193,21 +221,20 @@ module HrLite
193
221
  "type" => leave_type.code,
194
222
  "dates" => date_range_label,
195
223
  "days" => days_count&.to_s("F"),
224
+ "paid_days" => paid_days&.to_s("F"),
196
225
  "note" => note.presence
197
226
  }.compact
198
227
  )
199
228
  end
200
229
 
201
- # This request is still pending here, so balance.used excludes it:
202
- # approving is valid iff the request fits the remaining balance.
203
- def insufficient_balance_now?
204
- return false if leave_type.unlimited?
230
+ def recorded_by_hr? = created_by_id.present? && created_by_id != user_id
205
231
 
206
- # Same as-of date the create-time check used. Reading it as of TODAY
207
- # meant a request validated against the balance it would have on the
208
- # leave dates could never be approved on a monthly-accrual type.
209
- LeaveDayCounter.count(self) > balance.available(as_of: start_date)
210
- end
232
+ # The balance covers whole half-days only, never less than nothing.
233
+ def covered_by(available) = [ (BigDecimal(available.to_s) * 2).floor / BigDecimal(2), 0 ].max
234
+
235
+ # Recorded leave is approved in the same transaction, so there is nothing
236
+ # left for a flow to route.
237
+ def open_approval_route = pending? && super
211
238
 
212
239
  def notify_requested
213
240
  Notifications.publish(
@@ -215,7 +242,8 @@ module HrLite
215
242
  title: "#{HrLite.display_name(user)} applied for #{leave_type.name} (#{date_range_label})",
216
243
  body: reason.presence,
217
244
  path: "/admin/leave_requests/#{id}",
218
- bell_to: HrLite.admin_users
245
+ bell_to: HrLite.admin_users,
246
+ email_to: HrLite.admin_users
219
247
  )
220
248
  end
221
249
 
@@ -224,6 +252,8 @@ module HrLite
224
252
  # channel). Deliberately excludes the reason: dates are team-relevant,
225
253
  # the why is not.
226
254
  def notify_team
255
+ return if end_date < Date.current # nobody needs to hear who was out
256
+
227
257
  team = HrLite.active_employees.reject { |member| member.id == user_id }
228
258
  return if team.empty?
229
259
 
@@ -285,7 +315,7 @@ module HrLite
285
315
  clash = self.class.where(user_id: user_id, status: %w[pending approved])
286
316
  .where.not(id: id)
287
317
  .overlapping_range(start_date, end_date)
288
- errors.add(:base, "You already have leave overlapping these dates") if clash.exists?
318
+ errors.add(:base, "Leave already overlaps these dates") if clash.exists?
289
319
  end
290
320
 
291
321
  # A full-day leave over a day that already has a check-in makes no
@@ -296,12 +326,14 @@ module HrLite
296
326
 
297
327
  punched = AttendanceRecord.where(user_id: user_id, date: start_date..end_date)
298
328
  .where.not(check_in_at: nil)
299
- errors.add(:base, "You have marked attendance in this period") if punched.exists?
329
+ errors.add(:base, "Attendance is marked in this period") if punched.exists?
300
330
  end
301
331
 
332
+ # Only comp-off is refused past its balance: it is earned credit, never
333
+ # borrowed. Any other type's excess becomes loss of pay at approval.
302
334
  def sufficient_balance
303
335
  return unless start_date && end_date && leave_type
304
- return if leave_type.unlimited? || errors.any?
336
+ return if leave_type.unlimited? || !leave_type.comp_off || errors.any?
305
337
 
306
338
  if LeaveDayCounter.count(self) > balance.available(as_of: start_date)
307
339
  errors.add(:base, "Not enough #{leave_type.name} balance")
@@ -18,6 +18,8 @@ module HrLite
18
18
  validate :period_is_first_of_month
19
19
  validate :amount_is_positive
20
20
  validate :period_is_not_already_paid, on: :create
21
+ # Removing a line from a paid month rewrites a settled payslip just the same.
22
+ before_destroy { throw :abort if paid_month? }
21
23
 
22
24
  scope :for_month, ->(month) { where(period_month: month) }
23
25
 
@@ -47,11 +49,11 @@ module HrLite
47
49
  # TDS projector reads.
48
50
  def period_is_not_already_paid
49
51
  return if user_id.nil? || period_month.nil?
50
-
51
- settled = SalarySlip.settled.exists?(user_id: user_id, period_month: period_month)
52
- return unless settled
52
+ return unless paid_month?
53
53
 
54
54
  errors.add(:period_month, "has already been paid — put this on the next open month")
55
55
  end
56
+
57
+ def paid_month? = SalarySlip.settled.exists?(user_id: user_id, period_month: period_month)
56
58
  end
57
59
  end
@@ -96,7 +96,23 @@ module HrLite
96
96
  audit!("payroll.published", actor, "slips" => salary_slips.count)
97
97
  end
98
98
 
99
- slips = salary_slips.includes(:user).to_a
99
+ # config.slip_release_day holds the employee notice until the slip opens
100
+ # (SalarySlip.released); leadership still hears at publish.
101
+ day = HrLite.config.slip_release_day
102
+ release_at = day && period_month.next_month.change(day: day).in_time_zone(HrLite.config.time_zone)
103
+ if release_at&.future?
104
+ notify_slips_ready(employees: false)
105
+ SlipsReadyJob.set(wait_until: release_at).perform_later(self)
106
+ else
107
+ notify_slips_ready
108
+ end
109
+ true
110
+ end
111
+
112
+ # "Your salary slip is ready". SlipsReadyJob sends the employee half on
113
+ # the release day, without repeating the leadership copy.
114
+ def notify_slips_ready(employees: true, leadership: true)
115
+ slips = employees ? salary_slips.includes(:user).to_a : []
100
116
  # Everyone is emailed — a final settlement matters most to the person who
101
117
  # has left — but a bell is only useful to someone who can still sign in,
102
118
  # and offboarding revokes that. Sending one pointed at a page they cannot
@@ -111,9 +127,9 @@ module HrLite
111
127
  body: "Open Earthly HR to view or download it.",
112
128
  path: "/salary_slips",
113
129
  bell_to: slips.reject { |slip| exited.include?(slip.user_id) }.map(&:user),
114
- email_to: slips.map(&:user)
130
+ email_to: slips.map(&:user),
131
+ skip_leadership: !leadership
115
132
  )
116
- true
117
133
  end
118
134
 
119
135
  # Ruby-side aggregates (amounts are encrypted — no SQL sums).
@@ -12,6 +12,8 @@ module HrLite
12
12
  validates :effective_from, presence: true
13
13
  validates :version, numericality: { greater_than: 0 }
14
14
 
15
+ after_create_commit :announce, if: :published?
16
+
15
17
  scope :published, -> { where(published: true) }
16
18
  scope :live_on, ->(date) { published.where(effective_from: ..date) }
17
19
  scope :newest_first, -> { order(effective_from: :desc, version: :desc) }
@@ -34,6 +36,8 @@ module HrLite
34
36
  )
35
37
  end
36
38
 
39
+ def announcement? = !acknowledgement_required
40
+
37
41
  def acknowledged_by?(user)
38
42
  policy_acknowledgements.exists?(user_id: user.id)
39
43
  end
@@ -55,5 +59,16 @@ module HrLite
55
59
  rescue ActiveRecord::RecordNotUnique
56
60
  policy_acknowledgements.find_by!(user_id: user.id)
57
61
  end
62
+
63
+ private
64
+
65
+ def announce
66
+ staff = HrLite.active_employees
67
+ Notifications.publish(
68
+ "policy.published",
69
+ title: "#{announcement? ? 'Announcement' : 'Policy'}: #{title}#{' (please read and acknowledge)' unless announcement?}",
70
+ body: body.truncate(300), path: "/policies/#{id}", bell_to: staff, email_to: staff
71
+ )
72
+ end
58
73
  end
59
74
  end
@@ -18,6 +18,12 @@ module HrLite
18
18
  before_save :ensure_run_editable
19
19
 
20
20
  scope :published, -> { joins(:payroll_run).where(hr_lite_payroll_runs: { status: "published" }) }
21
+ # What an employee may open: published, and with config.slip_release_day
22
+ # only from that day of the next month (day 10: October's slip on 10 Nov).
23
+ scope :released, ->(on: Date.current) {
24
+ day = HrLite.config.slip_release_day
25
+ day ? published.where(period_month: ..(on - (day - 1)).beginning_of_month.prev_month) : published
26
+ }
21
27
  # What counts as already paid for tax-to-date. A finalized run is money
22
28
  # already decided; waiting for publication made the next month's TDS
23
29
  # projection read those earnings as zero.
@@ -7,7 +7,7 @@ module HrLite
7
7
 
8
8
  validates :weekend_policy, inclusion: { in: WEEKEND_POLICIES }
9
9
  validates :employee_code_prefix, presence: true,
10
- format: { with: /\A[A-Za-z]{1,10}\z/, message: "letters only, max 10" }
10
+ format: { with: /\A[A-Za-z]{1,10}-?\z/, message: "letters only (max 10), optionally ending in -" }
11
11
 
12
12
  def self.instance
13
13
  first_or_create!
@@ -2,7 +2,8 @@ module HrLite
2
2
  # Month roll-up per user — the payroll contract. Exact math per date:
3
3
  #
4
4
  # holiday / weekend / present / paid full-day leave -> payable +1
5
- # unpaid full-day leave (LWP) / absent -> lop +1
5
+ # unpaid full-day leave (LWP, or a paid-type day beyond the balance —
6
+ # LeaveRequest#unpaid_on) / absent -> lop +1
6
7
  # half-day punch (no leave) -> payable +0.5, lop +0.5
7
8
  # half-day PAID leave: leave half payable; other half payable if
8
9
  # punched, else lop (unpaid half-day leave: leave half is lop too)
@@ -54,13 +55,11 @@ module HrLite
54
55
  summary[:payable_days] += BigDecimal("0.5")
55
56
  summary[:lop_days] += BigDecimal("0.5")
56
57
  when :leave
57
- if day.leave.paid?
58
- summary[:paid_leave] += 1
59
- summary[:payable_days] += 1
60
- else
61
- summary[:unpaid_leave] += 1
62
- summary[:lop_days] += 1
63
- end
58
+ unpaid = day.leave.paid? ? day.leave.unpaid_on(date) : 1
59
+ summary[:paid_leave] += 1 - unpaid
60
+ summary[:payable_days] += 1 - unpaid
61
+ summary[:unpaid_leave] += unpaid
62
+ summary[:lop_days] += unpaid
64
63
  when :half_day_leave
65
64
  apply_half_day_leave(summary, day)
66
65
  when :upcoming
@@ -77,13 +76,11 @@ module HrLite
77
76
  def self.apply_half_day_leave(summary, day)
78
77
  half = BigDecimal("0.5")
79
78
 
80
- if day.leave.paid?
81
- summary[:paid_leave] += half
82
- summary[:payable_days] += half
83
- else
84
- summary[:unpaid_leave] += half
85
- summary[:lop_days] += half
86
- end
79
+ unpaid = day.leave.paid? ? day.leave.unpaid_on(day.leave.start_date) : half
80
+ summary[:paid_leave] += half - unpaid
81
+ summary[:payable_days] += half - unpaid
82
+ summary[:unpaid_leave] += unpaid
83
+ summary[:lop_days] += unpaid
87
84
 
88
85
  if day.record&.check_in_at
89
86
  summary[:present] += half
@@ -0,0 +1,65 @@
1
+ <% content_for(:page_title) { "Benefits" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Benefits</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_benefit_path %>">Add benefit</a>
6
+ </div>
7
+ </div>
8
+
9
+ <% if @benefits.none? %>
10
+ <section class="hrl-card"><p class="hrl-card--empty">No benefits yet — add one, then enrol people here.</p></section>
11
+ <% end %>
12
+ <% people = HrLite.active_employees.map { |u| [ hr_display_name(u), u.id ] } %>
13
+ <% @benefits.each do |benefit| %>
14
+ <section class="hrl-card">
15
+ <h2 class="hrl-card__title"><%= benefit.name %></h2>
16
+ <p class="hrl-small hrl-muted">
17
+ <%= benefit.kind.humanize %><%= " · #{benefit.provider}" if benefit.provider.present? %>
18
+ · cover <%= hrl_money(benefit.coverage) %>
19
+ </p>
20
+ <%= form_with url: hr_lite.enrol_admin_benefit_path(benefit), method: :post, local: true do %>
21
+ <div class="hrl-field">
22
+ <%= label_tag "enrol_user_#{benefit.id}", "Employee" %>
23
+ <%= select_tag :user_id, options_for_select(people), id: "enrol_user_#{benefit.id}" %>
24
+ </div>
25
+ <div class="hrl-field">
26
+ <%= label_tag "enrol_on_#{benefit.id}", "Covered from" %>
27
+ <%= date_field_tag :enrolled_on, Date.current, id: "enrol_on_#{benefit.id}" %>
28
+ </div>
29
+ <div class="hrl-field">
30
+ <%= label_tag "enrol_dependants_#{benefit.id}", "Dependants covered" %>
31
+ <%= number_field_tag :dependants, 0, min: 0, id: "enrol_dependants_#{benefit.id}" %>
32
+ </div>
33
+ <div class="hrl-form-actions"><%= submit_tag "Enrol", class: "hrl-btn hrl-btn--primary" %></div>
34
+ <% end %>
35
+ </section>
36
+ <% end %>
37
+
38
+ <% if @enrolments.any? %>
39
+ <section class="hrl-card">
40
+ <h2 class="hrl-card__title">Enrolled</h2>
41
+ <div class="hrl-table-wrap">
42
+ <table class="hrl-table hrl-table--stack">
43
+ <thead><tr><th>Who</th><th>Benefit</th><th>Since</th><th>Dependants</th><th></th></tr></thead>
44
+ <tbody>
45
+ <% @enrolments.each do |enrolment| %>
46
+ <tr>
47
+ <td data-label="Who"><%= hr_display_name(enrolment.user) %></td>
48
+ <td data-label="Benefit"><%= enrolment.benefit.name %></td>
49
+ <td data-label="Since"><%= enrolment.enrolled_on.strftime("%d %b %Y") %></td>
50
+ <td data-label="Dependants" class="hrl-num"><%= enrolment.dependants %></td>
51
+ <td data-label="">
52
+ <%= form_with url: hr_lite.unenrol_admin_benefit_path(enrolment.benefit), method: :post do %>
53
+ <%= 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" %>
56
+ <% end %>
57
+ </td>
58
+ </tr>
59
+ <% end %>
60
+ </tbody>
61
+ </table>
62
+ </div>
63
+ <%= hrl_pagination %>
64
+ </section>
65
+ <% end %>