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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d99d3408ecc4296bfb60310343f56df91495a48a6bc277881f6ebbd79a57de80
|
|
4
|
+
data.tar.gz: 9286e4b08358dbbe9f360967a57b134aa0ee6d74841daf80310ad79b372ae7b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a96b511a5031812bf57b8b10314c1316eb29857f25f0e5dd4e454caea586bf88c3feff01180e3d5bdd8da693421c52524c9da93038740266c8bf9c079dca24ed
|
|
7
|
+
data.tar.gz: dc3d6659e50a6421935556bb13db5a0749198e119b6bc01fa5a84415a913d26e4a38397c71f2e529c379fdc14663eb9afb8825c1f402e9723e7c1fb2ba933ba8
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,123 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.19.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
Each night closes the day. A missed check-out becomes a half day, and
|
|
13
|
+
employees can fix a recent missed punch themselves. No migration.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`HrLite::AttendanceCloseJob`**, scheduled by the host each night (a run
|
|
18
|
+
before noon closes the previous day, so retries are safe). On a working
|
|
19
|
+
day it checks out every punch still open at the day's end (23:59), marks the
|
|
20
|
+
day `half_day` (payroll pays
|
|
21
|
+
half and counts half as loss of pay), and emails the employee. Anyone who
|
|
22
|
+
never checked in is emailed that the day is absent; no record is written.
|
|
23
|
+
Weekends, holidays and approved leave (full or half day) are skipped. Run it
|
|
24
|
+
once a day: a re-run emails the no-shows again.
|
|
25
|
+
- **Self-fix.** With `config.self_regularization = { within_days:, per_week: }`,
|
|
26
|
+
a ticket that fills a missed punch on a working day, today or up to
|
|
27
|
+
`within_days` back, is approved as soon as the employee submits it, for up to
|
|
28
|
+
`per_week` days in a Monday–Sunday week. The punch is written the way an HR
|
|
29
|
+
approval writes it, with the usual audit row, and nobody is notified. Older
|
|
30
|
+
days, off days, fixes past the weekly limit, a change to a real punch, and a
|
|
31
|
+
day HR already regularized or rejected go to HR as ordinary tickets; the
|
|
32
|
+
flash says which happened. A check-out-only ticket for a day with no check-in
|
|
33
|
+
stays pending for HR.
|
|
34
|
+
`nil` (default) keeps HR as the only one who fixes punches. The ticket form
|
|
35
|
+
states the rule when it is on.
|
|
36
|
+
- The Leaves page links "Record leave for someone" for anyone who can approve
|
|
37
|
+
leave.
|
|
38
|
+
- The record-leave form says why somebody may be missing from the employee
|
|
39
|
+
list (no HR profile yet). Leadership gets a link to set one up.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Approving a regularization ticket on a day the close job marked half day
|
|
44
|
+
sets it back to present. A half day HR set on purpose stays.
|
|
45
|
+
- Approving a ticket for a month whose payroll run is in review adds a warning
|
|
46
|
+
to that run, so the slip is recomputed before it is finalized.
|
|
47
|
+
- The admin overview and daily digest still count a day the close job checked
|
|
48
|
+
out as a missing checkout.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- The sidebar, and the phone's More sheet, open scrolled to the current screen
|
|
53
|
+
instead of the top. A Manage item such as Approvals is no longer below the
|
|
54
|
+
fold.
|
|
55
|
+
- Page action buttons wrap on a narrow phone instead of running off the screen.
|
|
56
|
+
|
|
57
|
+
### Upgrading
|
|
58
|
+
|
|
59
|
+
Schedule `HrLite::AttendanceCloseJob` daily at `55 23 * * * Asia/Kolkata` in
|
|
60
|
+
your job scheduler (a run before noon closes the previous day). To let employees fix their own recent misses, set
|
|
61
|
+
`c.self_regularization = { within_days: 2, per_week: 2 }` in the initializer.
|
|
62
|
+
No migration.
|
|
63
|
+
|
|
64
|
+
## [0.18.0] - 2026-09-29
|
|
65
|
+
|
|
66
|
+
Salary follows leave. One migration (`paid_days` on leave requests, nullable;
|
|
67
|
+
existing rows read as fully paid).
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- **Leave beyond the balance is loss of pay, not refused.** On approval —
|
|
72
|
+
applied by the employee or recorded by HR — the balance covers the leave's
|
|
73
|
+
earliest working days in half-day steps (`paid_days`), and payroll cuts the
|
|
74
|
+
rest at the month's gross divided by its calendar days. The balance is never
|
|
75
|
+
overdrawn by it, so next month's accrual is not swallowed. Each unpaid day is
|
|
76
|
+
cut in its own month when a leave spans two. The split is fixed at approval;
|
|
77
|
+
correct it with the slip's LOP override while the run is in review.
|
|
78
|
+
- Comp-off is still refused past its earned credit.
|
|
79
|
+
- The monthly draft alert on the 1st goes to whoever runs payroll
|
|
80
|
+
(`payroll.manage`), with email, instead of leadership — who cannot open it.
|
|
81
|
+
|
|
82
|
+
### Added
|
|
83
|
+
|
|
84
|
+
- The employee is told on applying how many days will be unpaid; the approver
|
|
85
|
+
sees it on the request before approving.
|
|
86
|
+
- `config.slip_release_day` (1..28): employees see a published slip from that
|
|
87
|
+
day of the following month, and the "slip ready" bell and email wait until
|
|
88
|
+
then. `nil` (default) keeps today's behaviour. Needs an ActiveJob backend
|
|
89
|
+
that runs scheduled jobs.
|
|
90
|
+
|
|
91
|
+
## [0.17.0] - 2026-09-29
|
|
92
|
+
|
|
93
|
+
Screens for the things other screens were waiting on. An audit of every HR
|
|
94
|
+
screen found four that could never show anything, because the rows they list
|
|
95
|
+
could only be created from a console. No migration.
|
|
96
|
+
|
|
97
|
+
### Added
|
|
98
|
+
|
|
99
|
+
- **Expense categories** (Settings → Expense categories, leadership). Until
|
|
100
|
+
now nothing created one, so the claim form had no categories and every
|
|
101
|
+
expense claim failed with "Category must exist". Add or edit name, monthly
|
|
102
|
+
cap, receipt rule and active; untick Active to retire one (no delete —
|
|
103
|
+
claims point at it).
|
|
104
|
+
- **One-off pay items** (Payroll → a run → One-off pay items, money tier): a
|
|
105
|
+
bonus, incentive, arrears, reimbursement or one-time deduction for a person
|
|
106
|
+
and month, carried by that month's computed slip. A month already paid
|
|
107
|
+
refuses both adding and removing an item; the loan repayment line stays
|
|
108
|
+
with the loan. Items are money-tier in the audit trail, so the leadership
|
|
109
|
+
email no longer carries the amount.
|
|
110
|
+
- **Benefits admin** (Manage → Benefits, `benefit.manage`): add a benefit,
|
|
111
|
+
enrol somebody with their dependants, end their cover. Employees' Benefits
|
|
112
|
+
page was always empty before.
|
|
113
|
+
|
|
114
|
+
### Fixed
|
|
115
|
+
|
|
116
|
+
- **A manager could adjust anybody's leave balance.** The adjust action never
|
|
117
|
+
asked whose balance it was. It now needs `leave.manage` reaching that person;
|
|
118
|
+
the balances grid lists only people the viewer can see, and the adjust form
|
|
119
|
+
shows only to someone who holds `leave.manage`.
|
|
120
|
+
- The professional-tax state picker on the salary structure form had no
|
|
121
|
+
options.
|
|
122
|
+
- Sidebar links that led nowhere: the employee Approvals inbox shows only once
|
|
123
|
+
an approval flow exists; Claims shows to whoever approves claims (Finance
|
|
124
|
+
sees it, HR no longer lands on access denied); Profile is linked, and with it
|
|
125
|
+
Resignation; both leave balance pages are linked.
|
|
126
|
+
|
|
10
127
|
## [0.16.0] - 2026-09-27
|
|
11
128
|
|
|
12
129
|
Employee ID cards, print requests through Ask HR, recording leave for
|
data/README.md
CHANGED
|
@@ -141,6 +141,10 @@ HrLite.configure do |c|
|
|
|
141
141
|
c.render_pdf = ->(template:, assigns:, cache_key:) {
|
|
142
142
|
PdfRenderer.render(template: template, assigns: assigns, cache_key: cache_key)
|
|
143
143
|
}
|
|
144
|
+
c.slip_release_day = 10 # employees see a slip from the 10th of the next month; nil (default) = on publish
|
|
145
|
+
# Self-fix: employees correct their own missed punch within 2 days, at most
|
|
146
|
+
# 2 a week. nil (default) = only HR fixes it, via a regularization ticket.
|
|
147
|
+
c.self_regularization = { within_days: 2, per_week: 2 }
|
|
144
148
|
|
|
145
149
|
# Mirror promotions into your own user model (optional).
|
|
146
150
|
c.on_designation_change = ->(user, designation) { user.update!(designation: designation) }
|
|
@@ -266,6 +270,13 @@ approved; regularization tickets (`/regularization_requests`) let someone who
|
|
|
266
270
|
forgot to punch propose the real times for admin approval, which writes them
|
|
267
271
|
onto the attendance record with a full audit trail.
|
|
268
272
|
|
|
273
|
+
With `config.self_regularization = { within_days: 2, per_week: 2 }`, a ticket that
|
|
274
|
+
fills a missed punch on a working day, today or up to 2 days back, is approved on
|
|
275
|
+
submit, by the employee, at most twice per Mon–Sun week. No approver is notified;
|
|
276
|
+
the audit row is the trail. Older days, off days, the third fix of a week, a change
|
|
277
|
+
to a real punch, a day HR already regularized or rejected, or a fix that cannot
|
|
278
|
+
apply (no check-in that day) stay ordinary tickets for HR.
|
|
279
|
+
|
|
269
280
|
### Holidays and calendar
|
|
270
281
|
|
|
271
282
|
`bin/rails hr_lite:seed` inserts the three fixed-date national holidays
|
|
@@ -352,6 +363,7 @@ whatever you run). Each is idempotent and sends nothing on a quiet day.
|
|
|
352
363
|
| `HrLite::DailyDigestJob` | each morning | Leadership digest: who is out today, pending approvals, flagged punches, missing checkouts |
|
|
353
364
|
| `HrLite::PayrollAutoDraftJob` | monthly, on the 1st | Draft and compute the previous month's payroll from attendance, then notify leadership for review (publishing stays a human action) |
|
|
354
365
|
| `HrLite::LeaveYearRolloverJob` | leave year's first day (Jan 1, or Jul 1 for a July–June year) | Materialize carry-forward into the new year's balances |
|
|
366
|
+
| `HrLite::AttendanceCloseJob` | each night, `55 23 * * * Asia/Kolkata` | Close the day: an open punch is checked out at 23:59 and marked half day; a run before noon (a retry, or a morning schedule that lets night shifts check out first) closes the previous day instead; nobody checked in stays absent. Each is emailed how to fix it. Skips weekends, holidays and approved leave. Run it once a day — a re-run tells the no-shows again. |
|
|
355
367
|
|
|
356
368
|
## Rake tasks
|
|
357
369
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Keeps the current screen in view. The rail and the phone's More sheet both
|
|
2
|
+
// scroll, and a Manage item sits below the fold. scrollIntoView is avoided on
|
|
3
|
+
// purpose: it would scroll the page as well as the menu.
|
|
4
|
+
(function () {
|
|
5
|
+
function centre(panel) {
|
|
6
|
+
var link = panel && panel.querySelector(".hrl-nav__link--active");
|
|
7
|
+
if (!link) return;
|
|
8
|
+
var box = panel.getBoundingClientRect();
|
|
9
|
+
var item = link.getBoundingClientRect();
|
|
10
|
+
panel.scrollTop += item.top - box.top - (box.height - item.height) / 2;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
centre(document.querySelector(".hrl-side"));
|
|
14
|
+
|
|
15
|
+
var more = document.querySelector(".hrl-tabbar__more");
|
|
16
|
+
if (more) {
|
|
17
|
+
more.addEventListener("toggle", function () {
|
|
18
|
+
if (more.open) centre(more.querySelector(".hrl-sheet"));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
})();
|
|
@@ -143,7 +143,7 @@
|
|
|
143
143
|
.hrl-page__head { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; margin: .25rem 0 .9rem; }
|
|
144
144
|
.hrl-page__title { font-size: 1.25rem; font-weight: 700; margin: 0; }
|
|
145
145
|
.hrl-page__sub { color: var(--hrl-muted); font-size: .86rem; margin: .15rem 0 0; width: 100%; }
|
|
146
|
-
.hrl-page__actions { margin-left: auto; display: flex; gap: .5rem; }
|
|
146
|
+
.hrl-page__actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: .5rem; }
|
|
147
147
|
|
|
148
148
|
/* Cards */
|
|
149
149
|
.hrl-card {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Who is covered by what. Before this screen nothing created a benefit,
|
|
4
|
+
# so every employee's /benefits page said "not enrolled".
|
|
5
|
+
class BenefitsController < BaseController
|
|
6
|
+
skip_before_action :require_operations_access!
|
|
7
|
+
before_action :require_benefits!
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
@benefits = Benefit.order(:name)
|
|
11
|
+
@enrolments = paginate(covered.includes(:benefit, :user).order(:enrolled_on))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def new
|
|
15
|
+
@benefit = Benefit.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
@benefit = Benefit.new(benefit_params)
|
|
20
|
+
if @benefit.save
|
|
21
|
+
redirect_to admin_benefits_path, notice: "#{@benefit.name} added."
|
|
22
|
+
else
|
|
23
|
+
render :new, status: :unprocessable_entity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def enrol
|
|
28
|
+
benefit = Benefit.find(params[:id])
|
|
29
|
+
user = HrLite.user_klass.find(params[:user_id])
|
|
30
|
+
benefit.benefit_enrolments.create!(user_id: user.id, enrolled_on: parse_date_param(params[:enrolled_on]),
|
|
31
|
+
dependants: params[:dependants].presence || 0)
|
|
32
|
+
redirect_to admin_benefits_path, notice: "#{HrLite.display_name(user)} enrolled in #{benefit.name}."
|
|
33
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
34
|
+
redirect_to admin_benefits_path, alert: e.record.errors.full_messages.to_sentence
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Ends cover rather than deleting the row: who was covered when is
|
|
38
|
+
# the record an insurance claim gets checked against.
|
|
39
|
+
def unenrol
|
|
40
|
+
enrolment = covered.where(benefit_id: params[:id]).find_by!(user_id: params[:user_id])
|
|
41
|
+
enrolment.update!(ended_on: parse_date_param(params[:ended_on]))
|
|
42
|
+
redirect_to admin_benefits_path, notice: "Cover ended for #{HrLite.display_name(enrolment.user)}."
|
|
43
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
44
|
+
redirect_to admin_benefits_path, alert: e.record.errors.full_messages.to_sentence
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def require_benefits! = hr_require_permission!("benefit.manage", scope: :all)
|
|
50
|
+
|
|
51
|
+
# Still covered today — including cover that ends on a future date, which
|
|
52
|
+
# HR must still be able to see and change.
|
|
53
|
+
def covered = BenefitEnrolment.where("ended_on IS NULL OR ended_on >= ?", Date.current)
|
|
54
|
+
|
|
55
|
+
def benefit_params
|
|
56
|
+
params.require(:benefit).permit(:name, :kind, :provider, :policy_number, :coverage, :employer_premium,
|
|
57
|
+
:employee_premium, :effective_from, :expires_on, :notes)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# No destroy: claims hold the category by foreign key. Retire one by
|
|
4
|
+
# unticking Active, which takes it off the claim form.
|
|
5
|
+
class ExpenseCategoriesController < LeadershipController
|
|
6
|
+
def index
|
|
7
|
+
@expense_categories = ExpenseCategory.alphabetical
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def new
|
|
11
|
+
@expense_category = ExpenseCategory.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
@expense_category = ExpenseCategory.new(expense_category_params)
|
|
16
|
+
if @expense_category.save
|
|
17
|
+
redirect_to admin_expense_categories_path, notice: "Category added."
|
|
18
|
+
else
|
|
19
|
+
render :new, status: :unprocessable_entity
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def edit
|
|
24
|
+
@expense_category = ExpenseCategory.find(params[:id])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def update
|
|
28
|
+
@expense_category = ExpenseCategory.find(params[:id])
|
|
29
|
+
if @expense_category.update(expense_category_params)
|
|
30
|
+
redirect_to admin_expense_categories_path, notice: "Category updated."
|
|
31
|
+
else
|
|
32
|
+
render :edit, status: :unprocessable_entity
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def expense_category_params
|
|
39
|
+
params.require(:expense_category).permit(:name, :monthly_cap, :receipt_required, :active)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -4,12 +4,15 @@ module HrLite
|
|
|
4
4
|
def index
|
|
5
5
|
@year = sanitized_year
|
|
6
6
|
@types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
|
|
7
|
-
@employees = HrLite.employees
|
|
7
|
+
@employees = HrLite.employees.select { |employee| hr_reaches?("leave.view", employee) }
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
# Manual credit/debit — also the comp-off credit mechanism.
|
|
11
11
|
def adjust
|
|
12
12
|
user = HrLite.user_klass.find(params[:user_id])
|
|
13
|
+
# A manager reaches this screen through leave.approve; changing
|
|
14
|
+
# somebody's quota is leave.manage, for THAT person.
|
|
15
|
+
hr_require_reach!("leave.manage", user)
|
|
13
16
|
type = LeaveType.find(params[:leave_type_id])
|
|
14
17
|
# Unsanitised, a missing param wrote the adjustment into leave year 0,
|
|
15
18
|
# where no screen can ever show it.
|
|
@@ -17,7 +17,7 @@ module HrLite
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# HR records leave somebody forgot to apply for. Approved on save, by the
|
|
20
|
-
# person recording it; the balance
|
|
20
|
+
# person recording it; days beyond the balance become loss of pay.
|
|
21
21
|
def new
|
|
22
22
|
@request = LeaveRequest.new(start_date: Date.current, end_date: Date.current)
|
|
23
23
|
@people = recordable
|
|
@@ -32,7 +32,7 @@ module HrLite
|
|
|
32
32
|
end
|
|
33
33
|
return redirect_to(admin_leave_request_path(@request), notice: "Leave recorded and approved.") if recorded
|
|
34
34
|
|
|
35
|
-
@request.errors.add(:base, profile ? "Not enough
|
|
35
|
+
@request.errors.add(:base, profile ? "Not enough comp-off credit" : "Pick an employee from the list") if @request.errors.empty?
|
|
36
36
|
@people = recordable
|
|
37
37
|
render :new, status: :unprocessable_entity
|
|
38
38
|
end
|
|
@@ -43,7 +43,7 @@ module HrLite
|
|
|
43
43
|
redirect_to admin_leave_requests_path, notice: "Leave approved."
|
|
44
44
|
else
|
|
45
45
|
redirect_to admin_leave_request_path(request),
|
|
46
|
-
alert: "Cannot approve —
|
|
46
|
+
alert: "Cannot approve — comp-off credit no longer covers this request."
|
|
47
47
|
end
|
|
48
48
|
rescue ActiveRecord::RecordInvalid
|
|
49
49
|
redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# One-off payslip lines: a bonus, an incentive, arrears, a reimbursement,
|
|
4
|
+
# a one-time deduction. Money tier, like loans — this is somebody's pay.
|
|
5
|
+
# Create and destroy are audited by PayrollLineItem's Audited concern.
|
|
6
|
+
class PayrollLineItemsController < SuperadminController
|
|
7
|
+
def index
|
|
8
|
+
@month = parse_month_param(params[:month])
|
|
9
|
+
@items = paginate(PayrollLineItem.for_month(@month).includes(:user, :component).order(:created_at))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def new
|
|
13
|
+
@item = PayrollLineItem.new(period_month: parse_month_param(params[:month]))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create
|
|
17
|
+
@item = PayrollLineItem.new(item_params.merge(
|
|
18
|
+
period_month: parse_month_param(params.dig(:payroll_line_item, :period_month)),
|
|
19
|
+
created_by_id: hr_current_user.id,
|
|
20
|
+
# Same list the form offers: loan repayments come from the loan itself.
|
|
21
|
+
component: addable_components.find_by(id: params.dig(:payroll_line_item, :component_id))
|
|
22
|
+
))
|
|
23
|
+
if @item.save
|
|
24
|
+
redirect_to admin_payroll_line_items_path(month: @item.period_month.strftime("%Y-%m")),
|
|
25
|
+
notice: "#{@item.component.label} added for #{HrLite.display_name(@item.user)}."
|
|
26
|
+
else
|
|
27
|
+
render :new, status: :unprocessable_entity
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def destroy
|
|
32
|
+
item = PayrollLineItem.find(params[:id])
|
|
33
|
+
removed = item.destroy
|
|
34
|
+
redirect_to admin_payroll_line_items_path(month: item.period_month.strftime("%Y-%m")), status: :see_other,
|
|
35
|
+
**(removed ? { notice: "Removed." } : { alert: "That month has already been paid — it can't change now." })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def item_params
|
|
41
|
+
params.require(:payroll_line_item).permit(:user_id, :amount, :note)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def addable_components = SalaryComponent.active.where.not(code: "loan_repayment")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -48,7 +48,9 @@ module HrLite
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def publish
|
|
51
|
-
|
|
51
|
+
day = HrLite.config.slip_release_day
|
|
52
|
+
opens = day && "Published — employees can open it, and are notified, from the #{day.ordinalize} of next month."
|
|
53
|
+
transition { |run| run.publish!(actor: hr_current_user) && (opens || "Published — employees notified.") }
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
# Payout register: the full money sheet including bank details —
|
|
@@ -19,7 +19,9 @@ module HrLite
|
|
|
19
19
|
def create
|
|
20
20
|
@request = LeaveRequest.new(request_params.merge(user_id: hr_current_user.id))
|
|
21
21
|
if @request.save
|
|
22
|
-
|
|
22
|
+
# Say it now, not on the payslip: days past the balance are unpaid.
|
|
23
|
+
short = @request.days_beyond_balance
|
|
24
|
+
redirect_to leave_requests_path, notice: "Leave request submitted.#{" #{short.to_s('F')} day(s) are beyond your balance and will be unpaid if approved." if short.positive?}"
|
|
23
25
|
else
|
|
24
26
|
@balances = balance_cards
|
|
25
27
|
render :new, status: :unprocessable_entity
|
|
@@ -13,7 +13,7 @@ module HrLite
|
|
|
13
13
|
def create
|
|
14
14
|
@request = RegularizationRequest.new(request_params.merge(user_id: hr_current_user.id))
|
|
15
15
|
if @request.save
|
|
16
|
-
redirect_to regularization_requests_path, notice:
|
|
16
|
+
redirect_to regularization_requests_path, notice: create_notice
|
|
17
17
|
else
|
|
18
18
|
render :new, status: :unprocessable_entity
|
|
19
19
|
end
|
|
@@ -29,6 +29,15 @@ module HrLite
|
|
|
29
29
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
|
+
def create_notice
|
|
33
|
+
rule = HrLite.config.self_regularization
|
|
34
|
+
return "Ticket raised — an admin will review it." unless rule
|
|
35
|
+
return "Fixed. #{helpers.pluralize(@request.self_fixes_left, 'self-fix')} left this week." if @request.approved?
|
|
36
|
+
|
|
37
|
+
"Sent to HR as a ticket — you can fix only a missed punch on a working day in the last #{rule[:within_days]} days yourself, " \
|
|
38
|
+
"#{rule[:per_week]} times a week."
|
|
39
|
+
end
|
|
40
|
+
|
|
32
41
|
def request_params
|
|
33
42
|
params.require(:regularization_request).permit(:date, :check_in_at, :check_out_at, :reason)
|
|
34
43
|
end
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
module HrLite
|
|
2
|
-
# Employee surface: strictly own
|
|
3
|
-
#
|
|
2
|
+
# Employee surface: strictly own RELEASED slips — a foreign, unpublished
|
|
3
|
+
# or not-yet-released id 404s through the scoped relation, never 403s.
|
|
4
4
|
class SalarySlipsController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
@slips = paginate(
|
|
6
|
+
@slips = paginate(own_released.recent_first)
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
|
-
@slip =
|
|
10
|
+
@slip = own_released.find(params[:id])
|
|
11
11
|
|
|
12
12
|
respond_to do |format|
|
|
13
13
|
format.html
|
|
@@ -17,8 +17,8 @@ module HrLite
|
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
|
-
def
|
|
21
|
-
SalarySlip.
|
|
20
|
+
def own_released
|
|
21
|
+
SalarySlip.released.where(user_id: hr_current_user.id)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def send_slip_pdf(slip)
|
|
@@ -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,7 +20,8 @@ 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"
|
|
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" ] },
|
|
24
26
|
{ label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
|
|
25
27
|
{ label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
|
|
@@ -31,15 +33,17 @@ module HrLite
|
|
|
31
33
|
{ label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
|
|
32
34
|
{ label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
|
|
33
35
|
{ label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] },
|
|
34
|
-
{ 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) } },
|
|
35
38
|
{ label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
|
|
36
39
|
{ label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
|
|
40
|
+
{ label: "Benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ] },
|
|
37
41
|
{ label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
|
|
38
42
|
].freeze
|
|
39
43
|
|
|
40
44
|
LEADERSHIP_NAV_ITEMS = [
|
|
41
45
|
{ label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
|
|
42
|
-
{ 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" ] },
|
|
43
47
|
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
|
|
44
48
|
{ label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
|
|
45
49
|
{ label: "Documents", path: :admin_documents_path, match: [ "/admin/documents" ] }
|
|
@@ -63,9 +67,11 @@ module HrLite
|
|
|
63
67
|
RQRCode::QRCode.new(text).as_svg(use_path: true, viewbox: true, color: "0F172A").html_safe
|
|
64
68
|
end
|
|
65
69
|
|
|
66
|
-
# Only items whose routes exist yet (the nav grows with each phase)
|
|
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.
|
|
67
73
|
def hrl_nav_items(items)
|
|
68
|
-
items.select { |item| hr_lite_route?(item[:path]) }
|
|
74
|
+
items.select { |item| hr_lite_route?(item[:path]) && (item[:if].nil? || instance_exec(&item[:if])) }
|
|
69
75
|
end
|
|
70
76
|
|
|
71
77
|
def hr_lite_route?(helper_name)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Closes the working day that ended last: a run at 23:55 closes today, and a
|
|
3
|
+
# retry or late run after midnight (or a morning schedule) closes yesterday.
|
|
4
|
+
# A punch left open is checked out at the day's end and paid as a half day.
|
|
5
|
+
# Nobody punched in already reads as absent (DayStatus), so they are only
|
|
6
|
+
# told. Leave days are left alone.
|
|
7
|
+
class AttendanceCloseJob < ApplicationJob
|
|
8
|
+
def perform(date: (Time.current - 12.hours).to_date)
|
|
9
|
+
return unless WorkingCalendar.new(date..date).working_day?(date)
|
|
10
|
+
|
|
11
|
+
on_leave = LeaveRequest.active_on(date).pluck(:user_id)
|
|
12
|
+
records = AttendanceRecord.for_date(date).index_by(&:user_id)
|
|
13
|
+
day = date.strftime("%d %b")
|
|
14
|
+
HrLite.active_employees(on: date).each do |user|
|
|
15
|
+
next if on_leave.include?(user.id)
|
|
16
|
+
|
|
17
|
+
record = records[user.id]
|
|
18
|
+
if record.nil? || record.check_in_at.nil?
|
|
19
|
+
tell(user, date, "attendance.missed_check_in", "You didn't check in on #{day} — marked absent")
|
|
20
|
+
elsif record.check_out_at.nil?
|
|
21
|
+
record.update!(check_out_at: date.end_of_day, status: "half_day",
|
|
22
|
+
regularization_note: AttendanceRecord::AUTO_CHECKOUT_NOTE)
|
|
23
|
+
tell(user, date, "attendance.missed_check_out", "You didn't check out on #{day} — marked half day")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def tell(user, date, event, title)
|
|
31
|
+
rule = HrLite.config.self_regularization
|
|
32
|
+
body = if rule
|
|
33
|
+
"Fix it yourself by #{(date + rule[:within_days]).strftime('%d %b')} (up to #{rule[:per_week]} a week), " \
|
|
34
|
+
"or raise a ticket for HR."
|
|
35
|
+
else
|
|
36
|
+
"Raise a regularization ticket to get it fixed."
|
|
37
|
+
end
|
|
38
|
+
Notifications.publish(event, title: title, body: body, path: "/regularization_requests/new?date=#{date.iso8601}",
|
|
39
|
+
bell_to: [ user ], email_to: [ user ])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -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
|
|
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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
class AttendanceRecord < ApplicationRecord
|
|
3
3
|
STATUSES = %w[present half_day].freeze
|
|
4
|
+
AUTO_CHECKOUT_NOTE = "Auto check-out: no check-out punched".freeze
|
|
4
5
|
|
|
5
6
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
6
7
|
belongs_to :regularized_by, class_name: HrLite.config.user_class, optional: true
|
|
@@ -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
|
|