hr_lite 0.16.0 → 0.19.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -0
  3. data/README.md +12 -0
  4. data/app/assets/javascripts/hr_lite/nav.js +21 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +1 -1
  6. data/app/controllers/hr_lite/admin/benefits_controller.rb +61 -0
  7. data/app/controllers/hr_lite/admin/expense_categories_controller.rb +43 -0
  8. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -1
  9. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +3 -3
  10. data/app/controllers/hr_lite/admin/payroll_line_items_controller.rb +47 -0
  11. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +3 -1
  12. data/app/controllers/hr_lite/leave_requests_controller.rb +3 -1
  13. data/app/controllers/hr_lite/regularization_requests_controller.rb +10 -1
  14. data/app/controllers/hr_lite/salary_slips_controller.rb +6 -6
  15. data/app/helpers/hr_lite/application_helper.rb +12 -6
  16. data/app/jobs/hr_lite/attendance_close_job.rb +42 -0
  17. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +4 -3
  18. data/app/jobs/hr_lite/slips_ready_job.rb +10 -0
  19. data/app/models/hr_lite/attendance_record.rb +1 -0
  20. data/app/models/hr_lite/audit_log.rb +1 -1
  21. data/app/models/hr_lite/benefit_enrolment.rb +1 -1
  22. data/app/models/hr_lite/leave_balance.rb +2 -1
  23. data/app/models/hr_lite/leave_request.rb +38 -17
  24. data/app/models/hr_lite/payroll_line_item.rb +5 -3
  25. data/app/models/hr_lite/payroll_run.rb +19 -3
  26. data/app/models/hr_lite/regularization_request.rb +40 -3
  27. data/app/models/hr_lite/salary_slip.rb +6 -0
  28. data/app/services/hr_lite/attendance_summary.rb +12 -15
  29. data/app/services/hr_lite/overview_query.rb +4 -1
  30. data/app/views/hr_lite/admin/benefits/index.html.erb +65 -0
  31. data/app/views/hr_lite/admin/benefits/new.html.erb +37 -0
  32. data/app/views/hr_lite/admin/expense_categories/_form.html.erb +16 -0
  33. data/app/views/hr_lite/admin/expense_categories/edit.html.erb +3 -0
  34. data/app/views/hr_lite/admin/expense_categories/index.html.erb +30 -0
  35. data/app/views/hr_lite/admin/expense_categories/new.html.erb +3 -0
  36. data/app/views/hr_lite/admin/leave_balances/index.html.erb +4 -0
  37. data/app/views/hr_lite/admin/leave_requests/new.html.erb +8 -1
  38. data/app/views/hr_lite/admin/leave_requests/show.html.erb +3 -0
  39. data/app/views/hr_lite/admin/leave_types/index.html.erb +1 -0
  40. data/app/views/hr_lite/admin/payroll_line_items/index.html.erb +47 -0
  41. data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +42 -0
  42. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +1 -0
  43. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +5 -1
  44. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +3 -1
  45. data/app/views/hr_lite/leave_requests/index.html.erb +4 -0
  46. data/app/views/hr_lite/leave_requests/new.html.erb +1 -0
  47. data/app/views/hr_lite/regularization_requests/new.html.erb +3 -0
  48. data/app/views/hr_lite/salary_slips/index.html.erb +1 -1
  49. data/app/views/layouts/hr_lite/application.html.erb +5 -4
  50. data/config/routes.rb +5 -0
  51. data/db/migrate/20260929081109_add_paid_days_to_hr_lite_leave_requests.rb +5 -0
  52. data/lib/hr_lite/configuration.rb +21 -1
  53. data/lib/hr_lite/notifications.rb +8 -2
  54. data/lib/hr_lite/version.rb +1 -1
  55. metadata +16 -1
@@ -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)
@@ -40,6 +40,26 @@ module HrLite
40
40
  leave_type.paid
41
41
  end
42
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
+
43
63
  # --- transitions -------------------------------------------------------
44
64
 
45
65
  # With a flow configured this is ONE RUNG: the request becomes `approved`
@@ -47,9 +67,10 @@ module HrLite
47
67
  # the flow is not waiting on, such as HR overriding — the first approval
48
68
  # settles it, exactly as before.
49
69
  #
50
- # Returns false (leaving the request pending) when the balance no longer
51
- # covers it; the re-check runs inside the row lock so two concurrent
52
- # 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.
53
74
  def approve!(actor:, note: nil)
54
75
  return record_routed_decision!(actor, note, :approved) if awaiting?(actor)
55
76
 
@@ -132,15 +153,20 @@ module HrLite
132
153
  def approve_outright!(actor:, note:)
133
154
  insufficient = false
134
155
  transition!("approved", actor, note) do
156
+ next if leave_type.unlimited?
157
+
135
158
  # Serialize on the BALANCE row. `transition!`'s own lock is on this
136
159
  # request, and two pending requests are two different rows — so both
137
160
  # approvals could read the same untouched balance and overdraw it.
138
- 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
139
164
 
140
- if insufficient_balance_now?
165
+ if leave_type.comp_off
141
166
  insufficient = true
142
167
  raise ActiveRecord::Rollback
143
168
  end
169
+ self.paid_days = covered
144
170
  end
145
171
  return false if insufficient
146
172
 
@@ -195,24 +221,17 @@ module HrLite
195
221
  "type" => leave_type.code,
196
222
  "dates" => date_range_label,
197
223
  "days" => days_count&.to_s("F"),
224
+ "paid_days" => paid_days&.to_s("F"),
198
225
  "note" => note.presence
199
226
  }.compact
200
227
  )
201
228
  end
202
229
 
203
- # This request is still pending here, so balance.used excludes it:
204
- # approving is valid iff the request fits the remaining balance.
205
- def insufficient_balance_now?
206
- return false if leave_type.unlimited?
207
-
208
- # Same as-of date the create-time check used. Reading it as of TODAY
209
- # meant a request validated against the balance it would have on the
210
- # leave dates could never be approved on a monthly-accrual type.
211
- LeaveDayCounter.count(self) > balance.available(as_of: start_date)
212
- end
213
-
214
230
  def recorded_by_hr? = created_by_id.present? && created_by_id != user_id
215
231
 
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
+
216
235
  # Recorded leave is approved in the same transaction, so there is nothing
217
236
  # left for a flow to route.
218
237
  def open_approval_route = pending? && super
@@ -310,9 +329,11 @@ module HrLite
310
329
  errors.add(:base, "Attendance is marked in this period") if punched.exists?
311
330
  end
312
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.
313
334
  def sufficient_balance
314
335
  return unless start_date && end_date && leave_type
315
- return if leave_type.unlimited? || errors.any?
336
+ return if leave_type.unlimited? || !leave_type.comp_off || errors.any?
316
337
 
317
338
  if LeaveDayCounter.count(self) > balance.available(as_of: start_date)
318
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).
@@ -21,7 +21,7 @@ module HrLite
21
21
  validate :no_approved_leave_conflict
22
22
  end
23
23
 
24
- after_create :notify_requested
24
+ after_create :self_fix_or_notify
25
25
 
26
26
  scope :pending, -> { where(status: "pending") }
27
27
  scope :recent_first, -> { order(date: :desc, id: :desc) }
@@ -39,6 +39,27 @@ module HrLite
39
39
  AttendanceRecord.find_by(user_id: user_id, date: date)
40
40
  end
41
41
 
42
+ # config.self_regularization: a missed punch on a recent working day, under
43
+ # the weekly cap, is fixed by the employee without HR. A real punch, or a
44
+ # day HR already decided or rejected, stays HR's call.
45
+ def self_fixable?
46
+ rule = HrLite.config.self_regularization
47
+ return false unless rule && date >= Date.current - rule[:within_days] && WorkingCalendar.new(date..date).working_day?(date)
48
+
49
+ day = punch
50
+ fills_gap = (check_in_at.nil? || !day&.check_in_at) &&
51
+ (check_out_at.nil? || !day&.check_out_at || day.regularization_note == AttendanceRecord::AUTO_CHECKOUT_NOTE)
52
+ fills_gap && !day&.regularized? && !self.class.exists?(user_id: user_id, date: date, status: "rejected") &&
53
+ self_fixes_left.positive?
54
+ end
55
+
56
+ # Self-fixes the user still has in this ticket's Mon–Sun week.
57
+ def self_fixes_left
58
+ week = date.beginning_of_week(:monday)..date.end_of_week(:monday)
59
+ used = self.class.where(user_id: user_id, decided_by_id: user_id, status: "approved", date: week).count
60
+ HrLite.config.self_regularization[:per_week] - used
61
+ end
62
+
42
63
  # --- transitions -------------------------------------------------------
43
64
 
44
65
  # Applies the proposed times to the day's record (creating it if the
@@ -47,7 +68,7 @@ module HrLite
47
68
  # A merge that would produce a nonsense record (checkout before the
48
69
  # existing check-in, or a checkout with no check-in at all) raises
49
70
  # InvalidMerge with the real story so the admin knows what to fix.
50
- def approve!(actor:, note: nil)
71
+ def approve!(actor:, note: nil, notify: true)
51
72
  transition!("approved", actor, note) do
52
73
  # Re-checked at approval, not only at create: leave can be approved for
53
74
  # that day while the ticket waits in the queue, and DayStatus ranks
@@ -64,7 +85,8 @@ module HrLite
64
85
  raise InvalidMerge, "the day has no check-in — the ticket needs a check-in time too"
65
86
  end
66
87
 
67
- record.status = "present" if record.status.blank?
88
+ # Lifts only the close job's half day; one HR set on purpose stays.
89
+ record.status = "present" if record.status.blank? || record.regularization_note == AttendanceRecord::AUTO_CHECKOUT_NOTE
68
90
  record.regularized_by_id = actor.id
69
91
  record.regularized_at = Time.current
70
92
  record.regularization_note = "Ticket ##{id}: #{reason}"
@@ -75,7 +97,12 @@ module HrLite
75
97
  action: "regularize", subject: record, actor: actor,
76
98
  changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
77
99
  )
100
+ # A slip computed before this fix still counts the old day; a recompute clears the note.
101
+ run = PayrollRun.find_by(period_month: date.beginning_of_month, status: "review")
102
+ run&.update!(warnings: run.warnings + [ "#{HrLite.display_name(user)}'s attendance on #{date.strftime('%d %b')} " \
103
+ "was fixed after compute — recompute" ])
78
104
  end
105
+ return true unless notify # self-fix: the flash and the audit row are the trail
79
106
 
80
107
  Notifications.publish(
81
108
  "regularization.approved",
@@ -174,6 +201,16 @@ module HrLite
174
201
  errors.add(:date, "is covered by your approved leave — cancel the leave first") if leave.exists?
175
202
  end
176
203
 
204
+ # A self-fix approve! can't merge (e.g. no check-in yet) -> an ordinary ticket.
205
+ def self_fix_or_notify
206
+ user.lock! if HrLite.config.self_regularization # one user's tickets at once can't both spend the last fix
207
+ return approve!(actor: user, note: "Self-fixed", notify: false) if self_fixable?
208
+
209
+ notify_requested
210
+ rescue InvalidMerge
211
+ notify_requested
212
+ end
213
+
177
214
  def notify_requested
178
215
  Notifications.publish(
179
216
  "regularization.requested",
@@ -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.
@@ -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
@@ -30,8 +30,11 @@ module HrLite
30
30
  AttendanceRecord.for_date(@date).flagged.includes(:user)
31
31
  end
32
32
 
33
+ # AttendanceCloseJob fills check_out_at, so the days it closed count too.
33
34
  def missing_checkout_yesterday
34
- AttendanceRecord.for_date(@date - 1).missing_checkout.includes(:user)
35
+ yesterday = AttendanceRecord.for_date(@date - 1)
36
+ yesterday.missing_checkout.or(yesterday.where(regularization_note: AttendanceRecord::AUTO_CHECKOUT_NOTE))
37
+ .includes(:user)
35
38
  end
36
39
 
37
40
  def kpis
@@ -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 %>
@@ -0,0 +1,37 @@
1
+ <% content_for(:page_title) { "Add benefit" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Add benefit</h1></div>
3
+
4
+ <section class="hrl-card">
5
+ <%= form_with model: @benefit, url: hr_lite.admin_benefits_path, method: :post, local: true do |f| %>
6
+ <%= render "hr_lite/shared/form_errors", record: @benefit %>
7
+ <div class="hrl-field"><%= f.label :name %><%= f.text_field :name %></div>
8
+ <div class="hrl-field">
9
+ <%= f.label :kind %>
10
+ <%= f.select :kind, HrLite::Benefit::KINDS.map { |k| [ k.humanize, k ] } %>
11
+ </div>
12
+ <div class="hrl-field"><%= f.label :provider, "Provider (optional)" %><%= f.text_field :provider %></div>
13
+ <div class="hrl-field">
14
+ <%= f.label :policy_number, "Policy number (optional)" %>
15
+ <%= f.text_field :policy_number %>
16
+ </div>
17
+ <div class="hrl-field">
18
+ <%= f.label :coverage, "Cover (₹)" %>
19
+ <%= f.text_field :coverage, value: @benefit.coverage&.to_s("F"), inputmode: "decimal" %>
20
+ </div>
21
+ <div class="hrl-field">
22
+ <%= f.label :employer_premium, "Premium the company pays (₹)" %>
23
+ <%= f.text_field :employer_premium, value: @benefit.employer_premium&.to_s("F"), inputmode: "decimal" %>
24
+ </div>
25
+ <div class="hrl-field">
26
+ <%= f.label :employee_premium, "Premium the employee pays (₹)" %>
27
+ <%= f.text_field :employee_premium, value: @benefit.employee_premium&.to_s("F"), inputmode: "decimal" %>
28
+ </div>
29
+ <div class="hrl-field"><%= f.label :effective_from, "Starts" %><%= f.date_field :effective_from %></div>
30
+ <div class="hrl-field"><%= f.label :expires_on, "Expires" %><%= f.date_field :expires_on %></div>
31
+ <div class="hrl-field"><%= f.label :notes %><%= f.text_field :notes %></div>
32
+ <div class="hrl-form-actions">
33
+ <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
34
+ <a class="hrl-btn" href="<%= hr_lite.admin_benefits_path %>">Cancel</a>
35
+ </div>
36
+ <% end %>
37
+ </section>
@@ -0,0 +1,16 @@
1
+ <section class="hrl-card">
2
+ <%= form_with model: expense_category, url: url, method: method, local: true do |f| %>
3
+ <%= render "hr_lite/shared/form_errors", record: expense_category %>
4
+ <div class="hrl-field"><%= f.label :name %><%= f.text_field :name %></div>
5
+ <div class="hrl-field">
6
+ <%= f.label :monthly_cap, "Monthly cap per person (₹, blank = no cap)" %>
7
+ <%= f.number_field :monthly_cap, step: 0.01, min: 0, value: expense_category.monthly_cap&.to_s("F") %>
8
+ </div>
9
+ <div class="hrl-field hrl-field--check"><%= f.check_box :receipt_required %><%= f.label :receipt_required, "Receipt required" %></div>
10
+ <div class="hrl-field hrl-field--check"><%= f.check_box :active %><%= f.label :active, "Active (offered on the claim form)" %></div>
11
+ <div class="hrl-form-actions">
12
+ <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
13
+ <a class="hrl-btn" href="<%= hr_lite.admin_expense_categories_path %>">Cancel</a>
14
+ </div>
15
+ <% end %>
16
+ </section>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "Edit expense category" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Edit <%= @expense_category.name %></h1></div>
3
+ <%= render "form", expense_category: @expense_category, url: hr_lite.admin_expense_category_path(@expense_category), method: :patch %>
@@ -0,0 +1,30 @@
1
+ <% content_for(:page_title) { "Expense categories" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Expense categories</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_expense_category_path %>">Add category</a>
6
+ </div>
7
+ </div>
8
+
9
+ <section class="hrl-card">
10
+ <% if @expense_categories.any? %>
11
+ <div class="hrl-table-wrap">
12
+ <table class="hrl-table hrl-table--stack">
13
+ <thead><tr><th>Name</th><th>Monthly cap</th><th>Receipt</th><th>Active</th><th></th></tr></thead>
14
+ <tbody>
15
+ <% @expense_categories.each do |category| %>
16
+ <tr>
17
+ <td data-label="Name"><%= category.name %></td>
18
+ <td data-label="Monthly cap" class="hrl-num"><%= category.uncapped? ? "No cap" : hrl_money(category.monthly_cap) %></td>
19
+ <td data-label="Receipt"><%= category.receipt_required ? "Required" : "Optional" %></td>
20
+ <td data-label="Active"><%= category.active ? "Yes" : "No" %></td>
21
+ <td data-label=""><a class="hrl-small" href="<%= hr_lite.edit_admin_expense_category_path(category) %>">Edit</a></td>
22
+ </tr>
23
+ <% end %>
24
+ </tbody>
25
+ </table>
26
+ </div>
27
+ <% else %>
28
+ <p class="hrl-card--empty">No categories yet — nobody can file an expense claim until one exists.</p>
29
+ <% end %>
30
+ </section>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "Add expense category" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Add expense category</h1></div>
3
+ <%= render "form", expense_category: @expense_category, url: hr_lite.admin_expense_categories_path, method: :post %>
@@ -3,6 +3,8 @@
3
3
  <h1 class="hrl-page__title">Team leave balances <%= HrLite::LeaveYear.label(@year) %></h1>
4
4
  </div>
5
5
 
6
+ <%= render "hr_lite/admin/shared/approvals_tabs", active: :balances %>
7
+
6
8
  <section class="hrl-card">
7
9
  <div class="hrl-table-wrap">
8
10
  <table class="hrl-table">
@@ -24,6 +26,7 @@
24
26
  </div>
25
27
  </section>
26
28
 
29
+ <% if hr_can?("leave.manage", scope: :team) %>
27
30
  <section class="hrl-card">
28
31
  <h2 class="hrl-card__title">Adjust a balance</h2>
29
32
  <p class="hrl-small hrl-muted">Positive credits (comp-off), negative debits. Fully audited.</p>
@@ -56,3 +59,4 @@
56
59
  </div>
57
60
  <% end %>
58
61
  </section>
62
+ <% end %>
@@ -9,12 +9,19 @@
9
9
  <%= render "hr_lite/shared/form_errors", record: @request %>
10
10
  <div class="hrl-field">
11
11
  <%= label_tag :employee_code, "Employee (type a name or code)" %>
12
- <%= text_field_tag :employee_code, params[:employee_code], list: "hrl-employees", required: true, autocomplete: "off" %>
12
+ <%= text_field_tag :employee_code, params[:employee_code], list: "hrl-employees", required: true, autocomplete: "off", placeholder: "Start typing a name or code…" %>
13
13
  <datalist id="hrl-employees">
14
14
  <% @people.each do |p| %>
15
15
  <option value="<%= p.employee_code %>"><%= hr_display_name(p.user) %></option>
16
16
  <% end %>
17
17
  </datalist>
18
+ <p class="hrl-small hrl-muted">Someone missing? Only people with an HR profile are listed.
19
+ <% if hr_leadership? %>
20
+ <a href="<%= hr_lite.admin_employees_path %>">Set up their profile</a>
21
+ <% else %>
22
+ Ask leadership to set up their HR profile.
23
+ <% end %>
24
+ </p>
18
25
  </div>
19
26
  <div class="hrl-field">
20
27
  <%= f.label :leave_type_id, "Type" %>
@@ -18,6 +18,9 @@
18
18
  <% unless @request.leave_type.unlimited? %>
19
19
  <dt>Balance</dt>
20
20
  <dd><%= @balance.available.to_f %> of <%= @balance.entitled.to_f %> available</dd>
21
+ <% if (@request.pending? || @request.approved?) && (short = @request.days_beyond_balance).positive? %>
22
+ <dt>Loss of pay</dt><dd><%= short.to_s("F") %> day(s) beyond the balance will be unpaid</dd>
23
+ <% end %>
21
24
  <% end %>
22
25
  <% if @request.decision_note.present? %><dt>Decision note</dt><dd><%= @request.decision_note %></dd><% end %>
23
26
  </dl>
@@ -35,5 +35,6 @@
35
35
  <div class="hrl-row">
36
36
  <a class="hrl-btn" href="<%= hr_lite.admin_office_locations_path %>">Office locations</a>
37
37
  <a class="hrl-btn" href="<%= hr_lite.admin_holidays_path %>">Holidays</a>
38
+ <a class="hrl-btn" href="<%= hr_lite.admin_expense_categories_path %>">Expense categories</a>
38
39
  <a class="hrl-btn" href="<%= hr_lite.edit_admin_setting_path %>">Weekend policy</a>
39
40
  </div>
@@ -0,0 +1,47 @@
1
+ <% content_for(:page_title) { "One-off pay items" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">One-off pay items</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_payroll_line_item_path(month: @month.strftime("%Y-%m")) %>">Add an item</a>
6
+ </div>
7
+ </div>
8
+
9
+ <section class="hrl-card">
10
+ <div class="hrl-row">
11
+ <a class="hrl-btn" href="<%= hr_lite.admin_payroll_line_items_path(month: (@month - 1.month).strftime("%Y-%m")) %>">&larr;</a>
12
+ <strong><%= @month.strftime("%B %Y") %></strong>
13
+ <a class="hrl-btn" href="<%= hr_lite.admin_payroll_line_items_path(month: (@month + 1.month).strftime("%Y-%m")) %>">&rarr;</a>
14
+ </div>
15
+ <p class="hrl-muted hrl-small">
16
+ Payroll reads these when the month's run is computed. Add them before
17
+ computing, or recompute the run while it is still in review.
18
+ </p>
19
+ </section>
20
+
21
+ <section class="hrl-card">
22
+ <% if @items.empty? %>
23
+ <p class="hrl-muted">Nothing added for <%= @month.strftime("%B %Y") %>.</p>
24
+ <% else %>
25
+ <div class="hrl-table-wrap">
26
+ <table class="hrl-table hrl-table--stack">
27
+ <thead><tr><th>Person</th><th>Item</th><th>Amount</th><th>Note</th><th></th></tr></thead>
28
+ <tbody>
29
+ <% @items.each do |item| %>
30
+ <tr>
31
+ <td data-label="Person"><%= hr_display_name(item.user) %></td>
32
+ <td data-label="Item"><%= item.component.label %> (<%= item.component.kind %>)</td>
33
+ <td data-label="Amount" class="hrl-num"><%= hrl_money(item.amount) %></td>
34
+ <td data-label="Note"><%= item.note %></td>
35
+ <td data-label="">
36
+ <%= button_to "Remove", hr_lite.admin_payroll_line_item_path(item),
37
+ method: :delete, class: "hrl-btn hrl-btn--danger",
38
+ form: { data: { turbo_confirm: "Remove this item?" } } %>
39
+ </td>
40
+ </tr>
41
+ <% end %>
42
+ </tbody>
43
+ </table>
44
+ </div>
45
+ <% end %>
46
+ <%= hrl_pagination %>
47
+ </section>