hr_lite 0.11.0 → 0.13.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 +71 -1
- data/app/assets/stylesheets/hr_lite/hr_lite.css +6 -0
- data/app/controllers/hr_lite/admin/reports_controller.rb +151 -0
- data/app/controllers/hr_lite/benefits_controller.rb +17 -0
- data/app/controllers/hr_lite/expenses_controller.rb +50 -0
- data/app/controllers/hr_lite/hr_requests_controller.rb +43 -0
- data/app/controllers/hr_lite/policies_controller.rb +26 -0
- data/app/helpers/hr_lite/application_helper.rb +6 -1
- data/app/models/hr_lite/approval_flow.rb +1 -1
- data/app/models/hr_lite/benefit.rb +37 -0
- data/app/models/hr_lite/benefit_enrolment.rb +30 -0
- data/app/models/hr_lite/expense.rb +142 -0
- data/app/models/hr_lite/expense_category.rb +33 -0
- data/app/models/hr_lite/hr_request.rb +75 -0
- data/app/models/hr_lite/policy.rb +59 -0
- data/app/models/hr_lite/policy_acknowledgement.rb +13 -0
- data/app/views/hr_lite/admin/reports/index.html.erb +40 -0
- data/app/views/hr_lite/admin/reports/show.html.erb +32 -0
- data/app/views/hr_lite/benefits/index.html.erb +33 -0
- data/app/views/hr_lite/expenses/index.html.erb +68 -0
- data/app/views/hr_lite/expenses/new.html.erb +38 -0
- data/app/views/hr_lite/hr_requests/index.html.erb +39 -0
- data/app/views/hr_lite/hr_requests/new.html.erb +28 -0
- data/app/views/hr_lite/hr_requests/show.html.erb +25 -0
- data/app/views/hr_lite/policies/index.html.erb +34 -0
- data/app/views/hr_lite/policies/show.html.erb +23 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260818004707_create_hr_lite_employee_services.rb +108 -0
- data/lib/hr_lite/notifications.rb +8 -0
- data/lib/hr_lite/permissions.rb +9 -0
- data/lib/hr_lite/role_seeds.rb +10 -3
- data/lib/hr_lite/version.rb +1 -1
- metadata +24 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8202157b6f35309884c9df17895d711553e114a57925c8c2e381e51a7be863ba
|
|
4
|
+
data.tar.gz: 3cb55e413c6bcc70bd3aa39a5c212690a21c9cf747b17bfdf5894ef6d73945de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ddf504c20a84c58a712fd4efe695e9b90b27eea8acc8883bfb37d33df398778169c35eb2c11f37830e91c9ceec63560df57c631246b2b22381b52df5ce30144
|
|
7
|
+
data.tar.gz: 4a724d4a55068129237e010aca71ce94c392656bb56953a6f1ec5329ddf5d33b6ffd91f76c82229a152f85d134a60ca29308634133f0a3a72779cff3cc6dafdb
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.0] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
The screens. No migration.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Employee self-service for everything built since 0.11.0**: claim an
|
|
17
|
+
expense (with what is left of each cap shown before you type an amount),
|
|
18
|
+
see what you are covered for, read and acknowledge a policy, ask HR a
|
|
19
|
+
question and read the answer.
|
|
20
|
+
- **A reports hub** — headcount, joiners and leavers, leave balances,
|
|
21
|
+
attendance and expenses — each with a CSV download, and each **scoped by
|
|
22
|
+
the same permission that guards the screen its data comes from**. A report
|
|
23
|
+
is not a side door into rows somebody cannot otherwise reach: a manager's
|
|
24
|
+
headcount is their own team. The hub lists only the reports the reader can
|
|
25
|
+
actually open, since a list of links that turn you away is worse than a
|
|
26
|
+
short list.
|
|
27
|
+
- CSV downloads are audited, the same way the payout register is.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **The reports hub locked Finance out of the expense report.** It inherited
|
|
32
|
+
the operations gate, and Finance approves expenses but runs none of the
|
|
33
|
+
operations screens. Found by writing the spec for it.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- `.hrl-prose` for long-form policy text, capped at 68 characters a line — a
|
|
38
|
+
policy read across the full width of a laptop is a policy nobody finishes.
|
|
39
|
+
- The demo sandbox seeds an expense claim, a benefit enrolment, a policy
|
|
40
|
+
awaiting acknowledgement and an open HR request, so the new screens have
|
|
41
|
+
something on them at first boot.
|
|
42
|
+
|
|
43
|
+
## [0.12.0] - 2026-08-18
|
|
44
|
+
|
|
45
|
+
The four things an employee had to leave the system to do. **Adds one
|
|
46
|
+
migration.**
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **Expenses**, with categories carrying a monthly cap and a receipt rule.
|
|
51
|
+
The cap is checked when the claim is MADE, not at approval — telling
|
|
52
|
+
somebody they are over it after a week of waiting is the wrong moment to
|
|
53
|
+
find out. A claim awaiting a decision counts against the cap; a rejected
|
|
54
|
+
one gives the room back.
|
|
55
|
+
- Expenses route through the approval engine from 0.9.0 rather than growing a
|
|
56
|
+
fifth approve/reject. Configure a flow and a claim gets manager-then-finance
|
|
57
|
+
for free. `reimbursed` is a separate state from `approved`, because agreeing
|
|
58
|
+
to pay somebody and paying them are different days and the person waiting
|
|
59
|
+
cares about the second one.
|
|
60
|
+
- **Benefits and enrolments.** Dependants are a COUNT, not a list — names and
|
|
61
|
+
dates of birth of somebody's family are data this engine has no reason to
|
|
62
|
+
hold, and holding them would mean protecting them.
|
|
63
|
+
- **An HR help desk**: raise, assign, resolve, with the answer going back to
|
|
64
|
+
the person who asked instead of dying in somebody's inbox.
|
|
65
|
+
- **Policies, versioned.** Re-issuing asks everybody again, because an
|
|
66
|
+
acknowledgement of v1 says nothing about v2 — that is the whole point of
|
|
67
|
+
versioning it. Acknowledgements are `readonly?` once written: evidence that
|
|
68
|
+
can be edited afterwards is not evidence. Clicking twice is not an error.
|
|
69
|
+
- Permissions for all four, granted to the roles that should hold them, and
|
|
70
|
+
nine notification events.
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
|
|
74
|
+
- `HrLite::Expense` is the second module on the approval engine, which is the
|
|
75
|
+
first real test of the claim made in 0.9.0 that a new module gets
|
|
76
|
+
multi-level approval without touching the engine. It did.
|
|
77
|
+
|
|
10
78
|
## [0.11.0] - 2026-08-18
|
|
11
79
|
|
|
12
80
|
Documents, and a tax declaration that is more than one number. **Adds one
|
|
@@ -694,7 +762,9 @@ Initial release.
|
|
|
694
762
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
695
763
|
daily leadership digest and an append-only audit trail.
|
|
696
764
|
|
|
697
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
765
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.13.0...HEAD
|
|
766
|
+
[0.13.0]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...v0.13.0
|
|
767
|
+
[0.12.0]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...v0.12.0
|
|
698
768
|
[0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
|
|
699
769
|
[0.10.0]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...v0.10.0
|
|
700
770
|
[0.9.0]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...v0.9.0
|
|
@@ -318,3 +318,9 @@
|
|
|
318
318
|
.hrl-mt { margin-top: .85rem; }
|
|
319
319
|
.hrl-right { margin-left: auto; }
|
|
320
320
|
.hrl-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
|
321
|
+
|
|
322
|
+
/* Long-form policy text. Constrained measure — a policy read across the
|
|
323
|
+
full width of a laptop is a policy nobody finishes. */
|
|
324
|
+
.hrl-prose { max-width: 68ch; }
|
|
325
|
+
.hrl-prose p { margin: 0 0 .8rem; line-height: 1.6; }
|
|
326
|
+
.hrl-prose p:last-child { margin-bottom: 0; }
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# The numbers somebody asks for at the end of a month, and the CSV they
|
|
4
|
+
# ask for straight afterwards.
|
|
5
|
+
#
|
|
6
|
+
# Every report is scoped by the SAME permission that guards the screens
|
|
7
|
+
# its data comes from — a report is not a side door into rows somebody
|
|
8
|
+
# cannot otherwise reach.
|
|
9
|
+
class ReportsController < BaseController
|
|
10
|
+
# Finance can approve expenses but runs none of the operations screens,
|
|
11
|
+
# so the operations gate would have shut them out of their own expense
|
|
12
|
+
# report. The hub is reachable by anybody who can see AT LEAST ONE
|
|
13
|
+
# report; each report then checks its own permission.
|
|
14
|
+
skip_before_action :require_operations_access!
|
|
15
|
+
before_action :require_any_report!
|
|
16
|
+
|
|
17
|
+
REPORTS = {
|
|
18
|
+
"headcount" => "Headcount by department",
|
|
19
|
+
"joiners_and_leavers" => "Joiners and leavers",
|
|
20
|
+
"leave_balances" => "Leave balances",
|
|
21
|
+
"attendance" => "Attendance summary",
|
|
22
|
+
"expenses" => "Expense claims"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def index
|
|
26
|
+
# Only the reports this person could actually open — a list of links
|
|
27
|
+
# that turn you away is worse than a shorter list.
|
|
28
|
+
@reports = REPORTS.select { |name, _| permitted?(name) }
|
|
29
|
+
@month = parse_month_param(params[:month])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def show
|
|
33
|
+
@month = parse_month_param(params[:month])
|
|
34
|
+
@name = params[:id]
|
|
35
|
+
return hr_access_denied unless REPORTS.key?(@name)
|
|
36
|
+
return hr_access_denied unless permitted?(@name)
|
|
37
|
+
|
|
38
|
+
@rows = rows_for(@name)
|
|
39
|
+
respond_to do |format|
|
|
40
|
+
format.html
|
|
41
|
+
format.csv do
|
|
42
|
+
AuditLog.record!(action: "report.exported", subject: hr_current_user,
|
|
43
|
+
actor: hr_current_user,
|
|
44
|
+
changes: { "report" => @name, "rows" => @rows.size,
|
|
45
|
+
"month" => @month.strftime("%Y-%m") })
|
|
46
|
+
send_data to_csv(@rows), filename: "#{@name}-#{@month.strftime('%Y-%m')}.csv",
|
|
47
|
+
type: "text/csv"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def require_any_report!
|
|
55
|
+
hr_access_denied if REPORTS.keys.none? { |name| permitted?(name) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A report reaches exactly as far as the screen it summarises.
|
|
59
|
+
def permitted?(name)
|
|
60
|
+
case name
|
|
61
|
+
when "expenses" then hr_can?("expense.approve", scope: :all)
|
|
62
|
+
when "leave_balances" then hr_can?("leave.view", scope: :team)
|
|
63
|
+
when "attendance" then hr_can?("attendance.view", scope: :team)
|
|
64
|
+
else hr_can?("profile.view", scope: :team)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def visible_users
|
|
69
|
+
ids = hr_access.visible_user_ids("profile.view")
|
|
70
|
+
employees = HrLite.employees
|
|
71
|
+
ids.nil? ? employees : employees.select { |user| ids.include?(user.id) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def rows_for(name)
|
|
75
|
+
case name
|
|
76
|
+
when "headcount" then headcount_rows
|
|
77
|
+
when "joiners_and_leavers" then joiners_and_leavers_rows
|
|
78
|
+
when "leave_balances" then leave_balance_rows
|
|
79
|
+
when "attendance" then attendance_rows
|
|
80
|
+
when "expenses" then expense_rows
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def headcount_rows
|
|
85
|
+
profiles = EmployeeProfile.where(user_id: visible_users.map(&:id))
|
|
86
|
+
.active_for(@month)
|
|
87
|
+
profiles.group_by { |p| p.department.presence || "Unassigned" }
|
|
88
|
+
.sort_by(&:first)
|
|
89
|
+
.map { |department, rows| { "Department" => department, "People" => rows.size } }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def joiners_and_leavers_rows
|
|
93
|
+
window = @month..@month.end_of_month
|
|
94
|
+
EmployeeProfile.where(user_id: visible_users.map(&:id))
|
|
95
|
+
.where("date_of_joining BETWEEN ? AND ? OR date_of_exit BETWEEN ? AND ?",
|
|
96
|
+
window.first, window.last, window.first, window.last)
|
|
97
|
+
.includes(:user)
|
|
98
|
+
.map do |profile|
|
|
99
|
+
{ "Employee" => HrLite.display_name(profile.user),
|
|
100
|
+
"Department" => profile.department,
|
|
101
|
+
"Joined" => profile.date_of_joining&.to_s,
|
|
102
|
+
"Left" => profile.date_of_exit&.to_s }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def leave_balance_rows
|
|
107
|
+
year = LeaveYear.key_for(@month)
|
|
108
|
+
types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
|
|
109
|
+
visible_users.flat_map do |user|
|
|
110
|
+
types.map do |type|
|
|
111
|
+
balance = LeaveBalance.for(user, type, year)
|
|
112
|
+
{ "Employee" => HrLite.display_name(user), "Type" => type.code,
|
|
113
|
+
"Entitled" => balance.entitled.to_f, "Used" => balance.used.to_f,
|
|
114
|
+
"Available" => balance.available.to_f }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def attendance_rows
|
|
120
|
+
visible_users.map do |user|
|
|
121
|
+
summary = AttendanceSummary.for(user: user, month: @month)
|
|
122
|
+
{ "Employee" => HrLite.display_name(user),
|
|
123
|
+
"Payable days" => summary[:payable_days].to_f,
|
|
124
|
+
"LOP" => summary[:lop_days].to_f,
|
|
125
|
+
"Days in month" => summary[:days_in_month] }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def expense_rows
|
|
130
|
+
Expense.where(spent_on: @month..@month.end_of_month)
|
|
131
|
+
.includes(:user, :category).recent_first.map do |expense|
|
|
132
|
+
{ "Employee" => HrLite.display_name(expense.user),
|
|
133
|
+
"Category" => expense.category.name,
|
|
134
|
+
"Spent on" => expense.spent_on.to_s,
|
|
135
|
+
"Amount" => expense.amount.to_s("F"),
|
|
136
|
+
"Status" => expense.status }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def to_csv(rows)
|
|
141
|
+
require "csv"
|
|
142
|
+
return "" if rows.empty?
|
|
143
|
+
|
|
144
|
+
CSV.generate do |csv|
|
|
145
|
+
csv << rows.first.keys
|
|
146
|
+
rows.each { |row| csv << row.values }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# What the signed-in person is actually covered for — previously something
|
|
3
|
+
# you had to email somebody to find out.
|
|
4
|
+
class BenefitsController < ApplicationController
|
|
5
|
+
before_action :require_viewing!
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@enrolments = BenefitEnrolment.live_on(Date.current)
|
|
9
|
+
.where(user_id: hr_current_user.id)
|
|
10
|
+
.includes(:benefit)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def require_viewing! = hr_require_permission!("benefit.view")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# An employee's own claims. Scoped to the signed-in person the same way
|
|
3
|
+
# every other self-service screen is: a foreign id 404s through the
|
|
4
|
+
# relation rather than 403ing.
|
|
5
|
+
class ExpensesController < ApplicationController
|
|
6
|
+
before_action :require_claiming!
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@expenses = paginate(own.includes(:category).recent_first)
|
|
10
|
+
@categories = ExpenseCategory.active.alphabetical
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def new
|
|
14
|
+
@expense = Expense.new(spent_on: Date.current)
|
|
15
|
+
@categories = ExpenseCategory.active.alphabetical
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
@expense = Expense.new(expense_params.merge(user_id: hr_current_user.id))
|
|
20
|
+
if @expense.save
|
|
21
|
+
@expense.submit!(actor: hr_current_user)
|
|
22
|
+
redirect_to expenses_path, notice: "Claim submitted."
|
|
23
|
+
else
|
|
24
|
+
@categories = ExpenseCategory.active.alphabetical
|
|
25
|
+
render :new, status: :unprocessable_entity
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def cancel
|
|
30
|
+
expense = own.find(params[:id])
|
|
31
|
+
if expense.draft? || expense.submitted?
|
|
32
|
+
expense.update!(status: "cancelled")
|
|
33
|
+
expense.approval_route.cancel_all!
|
|
34
|
+
redirect_to expenses_path, notice: "Claim withdrawn."
|
|
35
|
+
else
|
|
36
|
+
redirect_to expenses_path, alert: "Only a claim still waiting can be withdrawn."
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def require_claiming! = hr_require_permission!("expense.claim")
|
|
43
|
+
|
|
44
|
+
def own = Expense.where(user_id: hr_current_user.id)
|
|
45
|
+
|
|
46
|
+
def expense_params
|
|
47
|
+
params.require(:expense).permit(:category_id, :amount, :spent_on, :description, :receipt)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The employee's side of the help desk.
|
|
3
|
+
class HrRequestsController < ApplicationController
|
|
4
|
+
before_action :require_raising!
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
@requests = paginate(own.recent_first)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
@request = own.find(params[:id])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def new
|
|
15
|
+
@request = HrRequest.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
@request = HrRequest.new(request_params.merge(user_id: hr_current_user.id))
|
|
20
|
+
if @request.save
|
|
21
|
+
redirect_to hr_requests_path, notice: "Sent to HR."
|
|
22
|
+
else
|
|
23
|
+
render :new, status: :unprocessable_entity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cancel
|
|
28
|
+
request = own.find(params[:id])
|
|
29
|
+
request.cancel!(actor: hr_current_user)
|
|
30
|
+
redirect_to hr_requests_path, notice: "Request withdrawn."
|
|
31
|
+
rescue ActiveRecord::RecordInvalid
|
|
32
|
+
redirect_to hr_requests_path, alert: "That request has already been answered."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def require_raising! = hr_require_permission!("hr_request.raise")
|
|
38
|
+
|
|
39
|
+
def own = HrRequest.where(user_id: hr_current_user.id)
|
|
40
|
+
|
|
41
|
+
def request_params = params.require(:hr_request).permit(:category, :subject, :body)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# What people are held to, and the button that says they have read it.
|
|
3
|
+
class PoliciesController < ApplicationController
|
|
4
|
+
before_action :require_reading!
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
@policies = Policy.current.sort_by(&:title)
|
|
8
|
+
@acknowledged = PolicyAcknowledgement.where(user_id: hr_current_user.id)
|
|
9
|
+
.pluck(:policy_id).to_set
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
@policy = Policy.published.find(params[:id])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def acknowledge
|
|
17
|
+
policy = Policy.published.find(params[:id])
|
|
18
|
+
policy.acknowledge!(hr_current_user)
|
|
19
|
+
redirect_to policies_path, notice: "Thank you — recorded against #{policy.title} v#{policy.version}."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def require_reading! = hr_require_permission!("policy.view", scope: :all)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -12,6 +12,10 @@ module HrLite
|
|
|
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
14
|
{ label: "Approvals", path: :approvals_path, match: [ "/approvals" ] },
|
|
15
|
+
{ label: "Expenses", path: :expenses_path, match: [ "/expenses" ] },
|
|
16
|
+
{ label: "Benefits", path: :benefits_path, match: [ "/benefits" ] },
|
|
17
|
+
{ label: "Policies", path: :policies_path, match: [ "/policies" ] },
|
|
18
|
+
{ label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ] },
|
|
15
19
|
{ label: "Org", path: :org_path, match: [ "/org" ] },
|
|
16
20
|
{ label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
|
|
17
21
|
{ label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
|
|
@@ -21,7 +25,8 @@ module HrLite
|
|
|
21
25
|
ADMIN_NAV_ITEMS = [
|
|
22
26
|
{ label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
|
|
23
27
|
{ label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
|
|
24
|
-
{ label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] }
|
|
28
|
+
{ label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
|
|
29
|
+
{ label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] }
|
|
25
30
|
].freeze
|
|
26
31
|
|
|
27
32
|
LEADERSHIP_NAV_ITEMS = [
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# An insurance policy or other benefit the company provides. Employees can
|
|
3
|
+
# finally see what they are covered for without emailing somebody.
|
|
4
|
+
class Benefit < ApplicationRecord
|
|
5
|
+
include EncryptedMoney
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
KINDS = %w[health life accident other].freeze
|
|
9
|
+
|
|
10
|
+
encrypted_money :coverage, :employer_premium, :employee_premium
|
|
11
|
+
|
|
12
|
+
has_many :benefit_enrolments, class_name: "HrLite::BenefitEnrolment", dependent: :destroy
|
|
13
|
+
has_many :enrolled_users, through: :benefit_enrolments, source: :user
|
|
14
|
+
|
|
15
|
+
validates :name, presence: true
|
|
16
|
+
validates :kind, inclusion: { in: KINDS }
|
|
17
|
+
validate :expiry_is_after_the_start
|
|
18
|
+
|
|
19
|
+
scope :active, -> { where(active: true) }
|
|
20
|
+
scope :live_on, ->(date) {
|
|
21
|
+
active.where("effective_from IS NULL OR effective_from <= ?", date)
|
|
22
|
+
.where("expires_on IS NULL OR expires_on >= ?", date)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def expiring?(within: 30, on: Date.current)
|
|
26
|
+
expires_on.present? && expires_on <= on + within && expires_on >= on
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def expiry_is_after_the_start
|
|
32
|
+
return if effective_from.nil? || expires_on.nil? || expires_on >= effective_from
|
|
33
|
+
|
|
34
|
+
errors.add(:expires_on, "must be on or after the start date")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Who is covered, from when. Dependants are a COUNT, not a list — names and
|
|
3
|
+
# dates of birth of somebody's family are data this engine has no reason to
|
|
4
|
+
# hold, and holding them would mean protecting them.
|
|
5
|
+
class BenefitEnrolment < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
belongs_to :benefit, class_name: "HrLite::Benefit"
|
|
9
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
10
|
+
|
|
11
|
+
validates :enrolled_on, presence: true
|
|
12
|
+
validates :user_id, uniqueness: { scope: :benefit_id }
|
|
13
|
+
validates :dependants, numericality: { greater_than_or_equal_to: 0 }
|
|
14
|
+
validate :ended_after_it_started
|
|
15
|
+
|
|
16
|
+
scope :live_on, ->(date) {
|
|
17
|
+
where(enrolled_on: ..date).where("ended_on IS NULL OR ended_on >= ?", date)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def live?(on = Date.current) = enrolled_on <= on && (ended_on.nil? || ended_on >= on)
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def ended_after_it_started
|
|
25
|
+
return if ended_on.nil? || enrolled_on.nil? || ended_on >= enrolled_on
|
|
26
|
+
|
|
27
|
+
errors.add(:ended_on, "must be on or after the enrolment date")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A claim. Routed through the approval engine rather than growing a fifth
|
|
3
|
+
# approve/reject of its own — configure a flow for it and it gets manager
|
|
4
|
+
# then finance for free.
|
|
5
|
+
class Expense < ApplicationRecord
|
|
6
|
+
include EncryptedMoney
|
|
7
|
+
include Audited
|
|
8
|
+
include Approvable
|
|
9
|
+
|
|
10
|
+
STATUSES = %w[draft submitted approved rejected reimbursed cancelled].freeze
|
|
11
|
+
|
|
12
|
+
encrypted_money :amount
|
|
13
|
+
|
|
14
|
+
belongs_to :category, class_name: "HrLite::ExpenseCategory"
|
|
15
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
16
|
+
|
|
17
|
+
has_one_attached :receipt
|
|
18
|
+
|
|
19
|
+
validates :status, inclusion: { in: STATUSES }
|
|
20
|
+
validates :description, presence: true
|
|
21
|
+
validates :spent_on, presence: true
|
|
22
|
+
validate :amount_is_positive
|
|
23
|
+
validate :spent_on_is_not_in_the_future
|
|
24
|
+
validate :within_the_category_cap, on: :create
|
|
25
|
+
validate :receipt_is_present_when_the_category_demands_one
|
|
26
|
+
|
|
27
|
+
scope :awaiting_reimbursement, -> { where(status: "approved") }
|
|
28
|
+
scope :recent_first, -> { order(spent_on: :desc, id: :desc) }
|
|
29
|
+
|
|
30
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
31
|
+
|
|
32
|
+
def submit!(actor:)
|
|
33
|
+
raise ActiveRecord::RecordInvalid.new(self), "not a draft" unless draft? || rejected?
|
|
34
|
+
|
|
35
|
+
update!(status: "submitted", submitted_at: Time.current)
|
|
36
|
+
notify_deciders
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def approve!(actor:, note: nil)
|
|
41
|
+
return record_routed!(actor, note, :approved) if awaiting?(actor)
|
|
42
|
+
|
|
43
|
+
settle!("approved", actor, note)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reject!(actor:, note:)
|
|
47
|
+
raise ArgumentError, "a rejection needs a reason" if note.blank?
|
|
48
|
+
return record_routed!(actor, note, :rejected) if awaiting?(actor)
|
|
49
|
+
|
|
50
|
+
settle!("rejected", actor, note)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Paid out with a payroll month. Kept separate from `approved` because
|
|
54
|
+
# agreeing to pay somebody and actually paying them are different days,
|
|
55
|
+
# and the person waiting cares about the second one.
|
|
56
|
+
def reimburse!(actor:, period_month:)
|
|
57
|
+
raise ActiveRecord::RecordInvalid.new(self), "not approved" unless approved?
|
|
58
|
+
|
|
59
|
+
transaction do
|
|
60
|
+
update!(status: "reimbursed", reimbursed_in: period_month.beginning_of_month)
|
|
61
|
+
AuditLog.record!(action: "expense.reimbursed", subject: self, actor: actor,
|
|
62
|
+
changes: { "employee" => HrLite.display_name(user),
|
|
63
|
+
"period" => period_month.strftime("%B %Y") })
|
|
64
|
+
end
|
|
65
|
+
Notifications.publish(
|
|
66
|
+
"expense.reimbursed",
|
|
67
|
+
title: "Your #{category.name} claim was reimbursed with #{period_month.strftime('%B %Y')} payroll",
|
|
68
|
+
path: "/expenses", bell_to: [ user ], email_to: [ user ]
|
|
69
|
+
)
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def record_routed!(actor, note, intent)
|
|
76
|
+
approval = approval_for(actor)
|
|
77
|
+
result = approval_route.decide!(approval, status: intent.to_s, actor: actor, note: note)
|
|
78
|
+
|
|
79
|
+
case result.outcome
|
|
80
|
+
when :approved then settle!("approved", actor, note)
|
|
81
|
+
# `reject!` already refuses a blank note, so there is nothing to
|
|
82
|
+
# default to here.
|
|
83
|
+
when :rejected then settle!("rejected", actor, note)
|
|
84
|
+
else
|
|
85
|
+
notify_deciders
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def settle!(new_status, actor, note)
|
|
91
|
+
update!(status: new_status, decision_note: note.presence)
|
|
92
|
+
Notifications.publish(
|
|
93
|
+
new_status == "approved" ? "expense.approved" : "expense.rejected",
|
|
94
|
+
title: "Your #{category.name} claim was #{new_status}",
|
|
95
|
+
body: note.presence, path: "/expenses", bell_to: [ user ], email_to: [ user ]
|
|
96
|
+
)
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def notify_deciders
|
|
101
|
+
deciders = pending_approvals.map(&:approver).compact.uniq
|
|
102
|
+
deciders = HrLite.users_holding("expense.approve").to_a if deciders.empty?
|
|
103
|
+
return if deciders.empty?
|
|
104
|
+
|
|
105
|
+
Notifications.publish(
|
|
106
|
+
"expense.submitted",
|
|
107
|
+
title: "#{HrLite.display_name(user)} claimed #{Money.format(amount)} — #{category.name}",
|
|
108
|
+
body: description, path: "/admin/expenses", bell_to: deciders
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def amount_is_positive
|
|
113
|
+
errors.add(:amount, "must be more than zero") unless amount&.positive?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def spent_on_is_not_in_the_future
|
|
117
|
+
return if spent_on.nil? || spent_on <= Date.current
|
|
118
|
+
|
|
119
|
+
errors.add(:spent_on, "cannot be in the future")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Checked at claim time rather than at approval: telling somebody they
|
|
123
|
+
# are over the cap after they have waited a week for an answer is the
|
|
124
|
+
# wrong moment to find out.
|
|
125
|
+
def within_the_category_cap
|
|
126
|
+
return if category.nil? || amount.nil? || spent_on.nil?
|
|
127
|
+
|
|
128
|
+
remaining = category.remaining_for(user, spent_on.beginning_of_month)
|
|
129
|
+
return if remaining.nil? || amount <= remaining
|
|
130
|
+
|
|
131
|
+
errors.add(:amount, "is over the #{category.name} cap for #{spent_on.strftime('%B')} " \
|
|
132
|
+
"— #{Money.format(remaining)} left")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def receipt_is_present_when_the_category_demands_one
|
|
136
|
+
return unless category&.receipt_required
|
|
137
|
+
return if receipt.attached?
|
|
138
|
+
|
|
139
|
+
errors.add(:receipt, "is required for #{category.name}")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "Travel", "Client entertainment", "Home office" — with the cap and the
|
|
3
|
+
# receipt rule that go with each.
|
|
4
|
+
class ExpenseCategory < ApplicationRecord
|
|
5
|
+
include EncryptedMoney
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
encrypted_money :monthly_cap
|
|
9
|
+
|
|
10
|
+
has_many :expenses, class_name: "HrLite::Expense", foreign_key: :category_id,
|
|
11
|
+
dependent: :restrict_with_error
|
|
12
|
+
|
|
13
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
14
|
+
|
|
15
|
+
scope :active, -> { where(active: true) }
|
|
16
|
+
scope :alphabetical, -> { order(:name) }
|
|
17
|
+
|
|
18
|
+
def uncapped? = monthly_cap.nil?
|
|
19
|
+
|
|
20
|
+
# What is left of this month's cap for one person, or nil when uncapped.
|
|
21
|
+
# Counts everything not refused: a claim awaiting a decision has still
|
|
22
|
+
# been spent against the cap.
|
|
23
|
+
def remaining_for(user, month = Date.current.beginning_of_month)
|
|
24
|
+
return nil if uncapped?
|
|
25
|
+
|
|
26
|
+
spent = expenses.where(user_id: user.id)
|
|
27
|
+
.where(spent_on: month..month.end_of_month)
|
|
28
|
+
.where.not(status: %w[rejected cancelled])
|
|
29
|
+
.sum(BigDecimal(0)) { |e| e.amount || BigDecimal(0) }
|
|
30
|
+
[ monthly_cap - spent, BigDecimal(0) ].max
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|