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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +117 -0
- data/README.md +12 -0
- data/app/assets/javascripts/hr_lite/nav.js +21 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +1 -1
- data/app/controllers/hr_lite/admin/benefits_controller.rb +61 -0
- data/app/controllers/hr_lite/admin/expense_categories_controller.rb +43 -0
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -1
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +3 -3
- data/app/controllers/hr_lite/admin/payroll_line_items_controller.rb +47 -0
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +3 -1
- data/app/controllers/hr_lite/leave_requests_controller.rb +3 -1
- data/app/controllers/hr_lite/regularization_requests_controller.rb +10 -1
- data/app/controllers/hr_lite/salary_slips_controller.rb +6 -6
- data/app/helpers/hr_lite/application_helper.rb +12 -6
- data/app/jobs/hr_lite/attendance_close_job.rb +42 -0
- data/app/jobs/hr_lite/payroll_auto_draft_job.rb +4 -3
- data/app/jobs/hr_lite/slips_ready_job.rb +10 -0
- data/app/models/hr_lite/attendance_record.rb +1 -0
- data/app/models/hr_lite/audit_log.rb +1 -1
- data/app/models/hr_lite/benefit_enrolment.rb +1 -1
- data/app/models/hr_lite/leave_balance.rb +2 -1
- data/app/models/hr_lite/leave_request.rb +38 -17
- data/app/models/hr_lite/payroll_line_item.rb +5 -3
- data/app/models/hr_lite/payroll_run.rb +19 -3
- data/app/models/hr_lite/regularization_request.rb +40 -3
- data/app/models/hr_lite/salary_slip.rb +6 -0
- data/app/services/hr_lite/attendance_summary.rb +12 -15
- data/app/services/hr_lite/overview_query.rb +4 -1
- data/app/views/hr_lite/admin/benefits/index.html.erb +65 -0
- data/app/views/hr_lite/admin/benefits/new.html.erb +37 -0
- data/app/views/hr_lite/admin/expense_categories/_form.html.erb +16 -0
- data/app/views/hr_lite/admin/expense_categories/edit.html.erb +3 -0
- data/app/views/hr_lite/admin/expense_categories/index.html.erb +30 -0
- data/app/views/hr_lite/admin/expense_categories/new.html.erb +3 -0
- data/app/views/hr_lite/admin/leave_balances/index.html.erb +4 -0
- data/app/views/hr_lite/admin/leave_requests/new.html.erb +8 -1
- data/app/views/hr_lite/admin/leave_requests/show.html.erb +3 -0
- data/app/views/hr_lite/admin/leave_types/index.html.erb +1 -0
- data/app/views/hr_lite/admin/payroll_line_items/index.html.erb +47 -0
- data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +42 -0
- data/app/views/hr_lite/admin/payroll_runs/show.html.erb +1 -0
- data/app/views/hr_lite/admin/salary_structures/_form.html.erb +5 -1
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +3 -1
- data/app/views/hr_lite/leave_requests/index.html.erb +4 -0
- data/app/views/hr_lite/leave_requests/new.html.erb +1 -0
- data/app/views/hr_lite/regularization_requests/new.html.erb +3 -0
- data/app/views/hr_lite/salary_slips/index.html.erb +1 -1
- data/app/views/layouts/hr_lite/application.html.erb +5 -4
- data/config/routes.rb +5 -0
- data/db/migrate/20260929081109_add_paid_days_to_hr_lite_leave_requests.rb +5 -0
- data/lib/hr_lite/configuration.rb +21 -1
- data/lib/hr_lite/notifications.rb +8 -2
- data/lib/hr_lite/version.rb +1 -1
- 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
|
-
|
|
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
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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)
|
|
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>
|
|
@@ -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")) %>">←</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")) %>">→</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>
|