hr_lite 0.12.0 → 0.14.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 +67 -1
- data/app/assets/stylesheets/hr_lite/hr_lite.css +6 -0
- data/app/controllers/hr_lite/admin/assets_controller.rb +53 -0
- data/app/controllers/hr_lite/admin/checklists_controller.rb +32 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +4 -0
- data/app/controllers/hr_lite/admin/expenses_controller.rb +45 -0
- data/app/controllers/hr_lite/admin/hr_requests_controller.rb +38 -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 +10 -1
- data/app/models/hr_lite/asset.rb +57 -0
- data/app/models/hr_lite/asset_assignment.rb +24 -0
- data/app/models/hr_lite/checklist_item.rb +43 -0
- data/app/models/hr_lite/checklist_template.rb +50 -0
- data/app/models/hr_lite/employee_profile.rb +13 -0
- data/app/views/hr_lite/admin/assets/index.html.erb +59 -0
- data/app/views/hr_lite/admin/assets/new.html.erb +23 -0
- data/app/views/hr_lite/admin/checklists/index.html.erb +50 -0
- data/app/views/hr_lite/admin/expenses/index.html.erb +54 -0
- data/app/views/hr_lite/admin/hr_requests/index.html.erb +41 -0
- data/app/views/hr_lite/admin/hr_requests/show.html.erb +47 -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 +23 -0
- data/db/migrate/20260818010401_create_hr_lite_assets_and_onboarding.rb +76 -0
- data/lib/hr_lite/permissions.rb +3 -0
- data/lib/hr_lite/role_seeds.rb +5 -3
- data/lib/hr_lite/version.rb +1 -1
- data/lib/tasks/hr_lite_tasks.rake +2 -1
- metadata +31 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '04966d2c7a57a06e49c64c6a375b148c497f83340a79445eb0f243dd783246f0'
|
|
4
|
+
data.tar.gz: 1112874bf22548910a651e53123056fd1341e045598060660c12f93b284789bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '00219df8c7244175712e7a87768f50301fa2f8b0110ae201f343409064bdb1083cce3eef587ad046cf7e1831a1981eb7d3c6e816e2a9d431cbbb86aaf5ef8c89'
|
|
7
|
+
data.tar.gz: 53af8da6feddf2be67301f9754f2912a08fe246bada0b68eb89b2eb49b70a3279db4ded5645f3438bc859e1a57be3cde29e6c1fc59526db2747d1cc98a5525c6
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.0] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
The admin half, plus the two ends of employment that were still manual.
|
|
13
|
+
**Adds one migration.**
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Admin screens for everything shipped since 0.11.0**: decide a claim and
|
|
18
|
+
record the payroll month it was paid with, answer or hand on a help-desk
|
|
19
|
+
request, hand out and take back an asset. The engine had all of this; there
|
|
20
|
+
was no way to do any of it.
|
|
21
|
+
- **Assets.** One live holder at a time, enforced by a partial unique index —
|
|
22
|
+
two open assignments would mean the laptop is in two places, which it is
|
|
23
|
+
not. Assignments are append-only and record what condition it came back in,
|
|
24
|
+
because "screen cracked" is the difference between an asset returned and an
|
|
25
|
+
asset written off.
|
|
26
|
+
- **Joining and leaving checklists**, from templates. The joining list opens
|
|
27
|
+
when a profile is created and the leaving list when an exit date is
|
|
28
|
+
stamped — the checklist nobody remembers to start is the one that does not
|
|
29
|
+
happen. Opening is idempotent, so adding a template later adds only what is
|
|
30
|
+
new. A failure to build one can never stop an employee being created.
|
|
31
|
+
- Each step names the permission its owner should hold, so IT taking back a
|
|
32
|
+
laptop and HR collecting a document are not the same person by accident.
|
|
33
|
+
- Permissions `asset.view`, `asset.manage`, `checklist.manage`.
|
|
34
|
+
|
|
35
|
+
### Why it matters
|
|
36
|
+
|
|
37
|
+
An asset that is never returned is a cost nobody notices, and access that is
|
|
38
|
+
never revoked is a security problem that outlives the employment. Both were
|
|
39
|
+
previously somebody's memory.
|
|
40
|
+
|
|
41
|
+
## [0.13.0] - 2026-08-18
|
|
42
|
+
|
|
43
|
+
The screens. No migration.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **Employee self-service for everything built since 0.11.0**: claim an
|
|
48
|
+
expense (with what is left of each cap shown before you type an amount),
|
|
49
|
+
see what you are covered for, read and acknowledge a policy, ask HR a
|
|
50
|
+
question and read the answer.
|
|
51
|
+
- **A reports hub** — headcount, joiners and leavers, leave balances,
|
|
52
|
+
attendance and expenses — each with a CSV download, and each **scoped by
|
|
53
|
+
the same permission that guards the screen its data comes from**. A report
|
|
54
|
+
is not a side door into rows somebody cannot otherwise reach: a manager's
|
|
55
|
+
headcount is their own team. The hub lists only the reports the reader can
|
|
56
|
+
actually open, since a list of links that turn you away is worse than a
|
|
57
|
+
short list.
|
|
58
|
+
- CSV downloads are audited, the same way the payout register is.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **The reports hub locked Finance out of the expense report.** It inherited
|
|
63
|
+
the operations gate, and Finance approves expenses but runs none of the
|
|
64
|
+
operations screens. Found by writing the spec for it.
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
|
|
68
|
+
- `.hrl-prose` for long-form policy text, capped at 68 characters a line — a
|
|
69
|
+
policy read across the full width of a laptop is a policy nobody finishes.
|
|
70
|
+
- The demo sandbox seeds an expense claim, a benefit enrolment, a policy
|
|
71
|
+
awaiting acknowledgement and an open HR request, so the new screens have
|
|
72
|
+
something on them at first boot.
|
|
73
|
+
|
|
10
74
|
## [0.12.0] - 2026-08-18
|
|
11
75
|
|
|
12
76
|
The four things an employee had to leave the system to do. **Adds one
|
|
@@ -729,7 +793,9 @@ Initial release.
|
|
|
729
793
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
730
794
|
daily leadership digest and an append-only audit trail.
|
|
731
795
|
|
|
732
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
796
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.14.0...HEAD
|
|
797
|
+
[0.14.0]: https://github.com/kshtzkr/hr_lite/compare/v0.13.0...v0.14.0
|
|
798
|
+
[0.13.0]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...v0.13.0
|
|
733
799
|
[0.12.0]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...v0.12.0
|
|
734
800
|
[0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
|
|
735
801
|
[0.10.0]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...v0.10.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,53 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Who has the laptop.
|
|
4
|
+
class AssetsController < BaseController
|
|
5
|
+
skip_before_action :require_operations_access!
|
|
6
|
+
before_action :require_assets!
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@assets = paginate(Asset.order(:status, :name).includes(asset_assignments: :user))
|
|
10
|
+
@outstanding = AssetAssignment.live.includes(:asset, :user)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def new
|
|
14
|
+
@asset = Asset.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create
|
|
18
|
+
@asset = Asset.new(asset_params)
|
|
19
|
+
if @asset.save
|
|
20
|
+
redirect_to admin_assets_path, notice: "#{@asset.name} added."
|
|
21
|
+
else
|
|
22
|
+
render :new, status: :unprocessable_entity
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def assign
|
|
27
|
+
asset = Asset.find(params[:id])
|
|
28
|
+
user = HrLite.user_klass.find(params[:user_id])
|
|
29
|
+
asset.assign_to!(user, actor: hr_current_user)
|
|
30
|
+
redirect_to admin_assets_path, notice: "#{asset.name} given to #{HrLite.display_name(user)}."
|
|
31
|
+
rescue ActiveRecord::RecordInvalid
|
|
32
|
+
redirect_to admin_assets_path, alert: "#{asset.name} is already with somebody."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def take_back
|
|
36
|
+
asset = Asset.find(params[:id])
|
|
37
|
+
asset.return!(actor: hr_current_user, condition: params[:condition_note],
|
|
38
|
+
status: params[:status].presence_in(Asset::STATUSES) || "available")
|
|
39
|
+
redirect_to admin_assets_path, notice: "#{asset.name} taken back."
|
|
40
|
+
rescue ActiveRecord::RecordInvalid
|
|
41
|
+
redirect_to admin_assets_path, alert: "#{asset.name} is not with anybody."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def require_assets! = hr_require_permission!("asset.manage", scope: :all)
|
|
47
|
+
|
|
48
|
+
def asset_params
|
|
49
|
+
params.require(:asset).permit(:name, :category, :serial_number, :purchased_on, :notes)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Joining and leaving, step by step, with what is overdue at the top.
|
|
4
|
+
class ChecklistsController < BaseController
|
|
5
|
+
skip_before_action :require_operations_access!
|
|
6
|
+
before_action :require_checklists!
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@kind = params[:kind].presence_in(ChecklistTemplate::KINDS) || "onboarding"
|
|
10
|
+
@items = ChecklistItem.for_kind(@kind).outstanding.includes(:user)
|
|
11
|
+
.order(Arel.sql("due_on IS NULL, due_on"))
|
|
12
|
+
@overdue = @items.select(&:overdue?)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def complete
|
|
16
|
+
item = ChecklistItem.find(params[:id])
|
|
17
|
+
item.complete!(actor: hr_current_user, note: params[:note])
|
|
18
|
+
redirect_to admin_checklists_path(kind: item.kind), notice: "#{item.title} — done."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reopen
|
|
22
|
+
item = ChecklistItem.find(params[:id])
|
|
23
|
+
item.reopen!(actor: hr_current_user)
|
|
24
|
+
redirect_to admin_checklists_path(kind: item.kind), notice: "#{item.title} — reopened."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def require_checklists! = hr_require_permission!("checklist.manage", scope: :all)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -93,6 +93,10 @@ module HrLite
|
|
|
93
93
|
exit_date = params[:date_of_exit].present? ? parse_date_param(params[:date_of_exit]) : Date.current
|
|
94
94
|
|
|
95
95
|
@profile.update!(date_of_exit: exit_date)
|
|
96
|
+
# The last day has a list too — and it is the one that matters most,
|
|
97
|
+
# since an unreturned laptop and un-revoked access both outlive the
|
|
98
|
+
# employment.
|
|
99
|
+
ChecklistItem.open_for!(@profile.user, kind: "offboarding", anchor_date: exit_date)
|
|
96
100
|
begin
|
|
97
101
|
HrLite.config.offboard_user.call(@profile.user)
|
|
98
102
|
rescue => e
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# The other side of the claim: decide it, then say when it was paid.
|
|
4
|
+
class ExpensesController < BaseController
|
|
5
|
+
skip_before_action :require_operations_access!
|
|
6
|
+
before_action :require_deciding!
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@status = params[:status].presence_in(Expense::STATUSES) || "submitted"
|
|
10
|
+
scope = Expense.where(status: @status).includes(:user, :category).recent_first
|
|
11
|
+
@expenses = paginate(hr_scope(scope, "expense.approve"))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def approve
|
|
15
|
+
expense = decidable.find(params[:id])
|
|
16
|
+
expense.approve!(actor: hr_current_user, note: params[:decision_note].presence)
|
|
17
|
+
redirect_to admin_expenses_path, notice: "Claim approved."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def reject
|
|
21
|
+
expense = decidable.find(params[:id])
|
|
22
|
+
note = params[:decision_note].to_s.strip
|
|
23
|
+
return redirect_to admin_expenses_path, alert: "A note is required to reject." if note.blank?
|
|
24
|
+
|
|
25
|
+
expense.reject!(actor: hr_current_user, note: note)
|
|
26
|
+
redirect_to admin_expenses_path, notice: "Claim rejected."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reimburse
|
|
30
|
+
expense = decidable.find(params[:id])
|
|
31
|
+
month = parse_month_param(params[:period_month])
|
|
32
|
+
expense.reimburse!(actor: hr_current_user, period_month: month)
|
|
33
|
+
redirect_to admin_expenses_path, notice: "Marked reimbursed with #{month.strftime('%B %Y')}."
|
|
34
|
+
rescue ActiveRecord::RecordInvalid
|
|
35
|
+
redirect_to admin_expenses_path, alert: "Only an approved claim can be reimbursed."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def require_deciding! = hr_require_permission!("expense.approve", scope: :team)
|
|
41
|
+
|
|
42
|
+
def decidable = hr_scope(Expense.all, "expense.approve")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# The desk itself.
|
|
4
|
+
class HrRequestsController < BaseController
|
|
5
|
+
skip_before_action :require_operations_access!
|
|
6
|
+
before_action :require_desk!
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@status = params[:status].presence_in(HrRequest::STATUSES) || "open"
|
|
10
|
+
@requests = paginate(HrRequest.where(status: @status).includes(:user, :assigned_to)
|
|
11
|
+
.recent_first)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
@request = HrRequest.find(params[:id])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def assign
|
|
19
|
+
request = HrRequest.find(params[:id])
|
|
20
|
+
assignee = HrLite.user_klass.find(params[:assignee_id])
|
|
21
|
+
request.assign!(actor: hr_current_user, assignee: assignee)
|
|
22
|
+
redirect_to admin_hr_request_path(request), notice: "Assigned to #{HrLite.display_name(assignee)}."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolve
|
|
26
|
+
request = HrRequest.find(params[:id])
|
|
27
|
+
request.resolve!(actor: hr_current_user, resolution: params[:resolution].to_s.strip)
|
|
28
|
+
redirect_to admin_hr_requests_path, notice: "Answered."
|
|
29
|
+
rescue ArgumentError
|
|
30
|
+
redirect_to admin_hr_request_path(request), alert: "Write an answer before resolving."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def require_desk! = hr_require_permission!("hr_request.manage", scope: :all)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -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,12 @@ 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" ] },
|
|
30
|
+
{ label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ] },
|
|
31
|
+
{ label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
|
|
32
|
+
{ label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
|
|
33
|
+
{ label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
|
|
25
34
|
].freeze
|
|
26
35
|
|
|
27
36
|
LEADERSHIP_NAV_ITEMS = [
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A laptop, a phone, a SIM. An asset nobody tracks is a cost nobody
|
|
3
|
+
# notices, and the moment it matters is somebody's last day.
|
|
4
|
+
class Asset < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[available assigned returned lost damaged retired].freeze
|
|
8
|
+
CATEGORIES = %w[laptop phone sim id_card accessory vehicle other].freeze
|
|
9
|
+
|
|
10
|
+
has_many :asset_assignments, class_name: "HrLite::AssetAssignment", dependent: :destroy
|
|
11
|
+
|
|
12
|
+
validates :name, presence: true
|
|
13
|
+
validates :status, inclusion: { in: STATUSES }
|
|
14
|
+
validates :category, inclusion: { in: CATEGORIES }
|
|
15
|
+
validates :serial_number, uniqueness: { allow_blank: true }
|
|
16
|
+
|
|
17
|
+
scope :available, -> { where(status: "available") }
|
|
18
|
+
scope :out, -> { where(status: "assigned") }
|
|
19
|
+
|
|
20
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
21
|
+
|
|
22
|
+
def live_assignment = asset_assignments.find_by(returned_on: nil)
|
|
23
|
+
|
|
24
|
+
def holder = live_assignment&.user
|
|
25
|
+
|
|
26
|
+
def assign_to!(user, actor: nil, on: Date.current)
|
|
27
|
+
raise ActiveRecord::RecordInvalid.new(self), "already assigned" if live_assignment
|
|
28
|
+
|
|
29
|
+
transaction do
|
|
30
|
+
asset_assignments.create!(user_id: user.id, assigned_on: on, assigned_by_id: actor&.id)
|
|
31
|
+
update!(status: "assigned")
|
|
32
|
+
end
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# `condition` records what came back — "screen cracked" is the difference
|
|
37
|
+
# between an asset returned and an asset written off.
|
|
38
|
+
def return!(actor: nil, on: Date.current, condition: nil, status: "available")
|
|
39
|
+
assignment = live_assignment
|
|
40
|
+
raise ActiveRecord::RecordInvalid.new(self), "not assigned" if assignment.nil?
|
|
41
|
+
|
|
42
|
+
transaction do
|
|
43
|
+
assignment.update!(returned_on: on, condition_note: condition.presence)
|
|
44
|
+
update!(status: status)
|
|
45
|
+
end
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Everything still out with somebody who has left — the report that
|
|
50
|
+
# should exist before an exit, not after.
|
|
51
|
+
def self.outstanding_for(user)
|
|
52
|
+
joins(:asset_assignments)
|
|
53
|
+
.where(hr_lite_asset_assignments: { user_id: user.id, returned_on: nil })
|
|
54
|
+
.distinct
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|