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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/app/assets/stylesheets/hr_lite/hr_lite.css +6 -0
  4. data/app/controllers/hr_lite/admin/assets_controller.rb +53 -0
  5. data/app/controllers/hr_lite/admin/checklists_controller.rb +32 -0
  6. data/app/controllers/hr_lite/admin/employees_controller.rb +4 -0
  7. data/app/controllers/hr_lite/admin/expenses_controller.rb +45 -0
  8. data/app/controllers/hr_lite/admin/hr_requests_controller.rb +38 -0
  9. data/app/controllers/hr_lite/admin/reports_controller.rb +151 -0
  10. data/app/controllers/hr_lite/benefits_controller.rb +17 -0
  11. data/app/controllers/hr_lite/expenses_controller.rb +50 -0
  12. data/app/controllers/hr_lite/hr_requests_controller.rb +43 -0
  13. data/app/controllers/hr_lite/policies_controller.rb +26 -0
  14. data/app/helpers/hr_lite/application_helper.rb +10 -1
  15. data/app/models/hr_lite/asset.rb +57 -0
  16. data/app/models/hr_lite/asset_assignment.rb +24 -0
  17. data/app/models/hr_lite/checklist_item.rb +43 -0
  18. data/app/models/hr_lite/checklist_template.rb +50 -0
  19. data/app/models/hr_lite/employee_profile.rb +13 -0
  20. data/app/views/hr_lite/admin/assets/index.html.erb +59 -0
  21. data/app/views/hr_lite/admin/assets/new.html.erb +23 -0
  22. data/app/views/hr_lite/admin/checklists/index.html.erb +50 -0
  23. data/app/views/hr_lite/admin/expenses/index.html.erb +54 -0
  24. data/app/views/hr_lite/admin/hr_requests/index.html.erb +41 -0
  25. data/app/views/hr_lite/admin/hr_requests/show.html.erb +47 -0
  26. data/app/views/hr_lite/admin/reports/index.html.erb +40 -0
  27. data/app/views/hr_lite/admin/reports/show.html.erb +32 -0
  28. data/app/views/hr_lite/benefits/index.html.erb +33 -0
  29. data/app/views/hr_lite/expenses/index.html.erb +68 -0
  30. data/app/views/hr_lite/expenses/new.html.erb +38 -0
  31. data/app/views/hr_lite/hr_requests/index.html.erb +39 -0
  32. data/app/views/hr_lite/hr_requests/new.html.erb +28 -0
  33. data/app/views/hr_lite/hr_requests/show.html.erb +25 -0
  34. data/app/views/hr_lite/policies/index.html.erb +34 -0
  35. data/app/views/hr_lite/policies/show.html.erb +23 -0
  36. data/config/routes.rb +23 -0
  37. data/db/migrate/20260818010401_create_hr_lite_assets_and_onboarding.rb +76 -0
  38. data/lib/hr_lite/permissions.rb +3 -0
  39. data/lib/hr_lite/role_seeds.rb +5 -3
  40. data/lib/hr_lite/version.rb +1 -1
  41. data/lib/tasks/hr_lite_tasks.rake +2 -1
  42. metadata +31 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a035599be75b81c0d2f3d1fc1b8f95328df845445f6c93d997a9906f9b43846e
4
- data.tar.gz: 8a840cc6c1af2ef49af660859fc6edf0f2ccec4b59377017c9cc0467793e8396
3
+ metadata.gz: '04966d2c7a57a06e49c64c6a375b148c497f83340a79445eb0f243dd783246f0'
4
+ data.tar.gz: 1112874bf22548910a651e53123056fd1341e045598060660c12f93b284789bb
5
5
  SHA512:
6
- metadata.gz: 9bef92ea728cc960bc548c37fe3a8a34090ad531bec3ab248dc572ce9931f44fb5bf2591b1578df0c22f68ffd5ab7c02e6e7a8ed43445b1087524fc59584889b
7
- data.tar.gz: 2598075e1f67e8e2c9e7fa4142db5e7561f42f17e32a9be6207ca560116a8e9d3ac7b7876c02e4bc77c250df6ef0ec0b05592ad9c073b22aaa8f01158129276f
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.12.0...HEAD
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