hr_lite 0.5.2 → 0.6.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 +112 -1
- data/README.md +67 -31
- data/app/controllers/hr_lite/admin/attendances_controller.rb +21 -6
- data/app/controllers/hr_lite/admin/base_controller.rb +16 -6
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/leadership_controller.rb +11 -8
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -5
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
- data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +5 -6
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
- data/app/controllers/hr_lite/application_controller.rb +39 -1
- data/app/helpers/hr_lite/application_helper.rb +5 -2
- data/app/models/hr_lite/audit_log.rb +26 -1
- data/app/models/hr_lite/comp_off_request.rb +3 -1
- data/app/models/hr_lite/leave_request.rb +27 -4
- data/app/models/hr_lite/payroll_run.rb +30 -4
- data/app/models/hr_lite/regularization_request.rb +3 -4
- data/app/models/hr_lite/role.rb +66 -0
- data/app/models/hr_lite/role_assignment.rb +18 -0
- data/app/models/hr_lite/role_grant.rb +15 -0
- data/app/models/hr_lite/salary_slip.rb +1 -2
- data/app/services/hr_lite/access.rb +111 -0
- data/app/services/hr_lite/payroll_run_processor.rb +4 -1
- data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
- data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
- data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
- data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
- data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
- data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
- data/lib/hr_lite/configuration.rb +11 -7
- data/lib/hr_lite/current.rb +4 -0
- data/lib/hr_lite/financial_year.rb +26 -0
- data/lib/hr_lite/permissions.rb +79 -0
- data/lib/hr_lite/role_seeds.rb +81 -0
- data/lib/hr_lite/statutory_rate_card.rb +45 -5
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +81 -11
- data/lib/tasks/hr_lite_tasks.rake +10 -2
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7dd5723f67a89de78893c7ef752581da400b8703fb60876ad7d283e280308d31
|
|
4
|
+
data.tar.gz: d8e517a4a80bb2026de50a9f30b7837b37b785d02a5aea79802ad72b78fcc630
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ea7291efd85233dfabf368fdc2355f8050920a22e324ea9f0ac083ba10040aaacaf125f93ed2d89b94e3ab05ed496375d69a6499af66372a974498677a30a18b
|
|
7
|
+
data.tar.gz: 1c1ad921b96ae6d778711b61b62501b5a8fa2e6cfd08cb8fd2c162bc01d37c7f048bc9637a617f1eebcac2f51a01e669cff89d94adf3bd48724b7cb9c7959a05
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,115 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
Access stops being three lambdas and becomes a role table. **Breaking**, with
|
|
13
|
+
an upgrade path that is designed not to be: the migration reads your own
|
|
14
|
+
configuration and lands everybody where they already were.
|
|
15
|
+
|
|
16
|
+
**Adds two migrations.** The first creates the role tables; the second seeds
|
|
17
|
+
the built-in roles and derives assignments from your existing email lists,
|
|
18
|
+
printing who it put where.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **Any admin could approve anybody's leave.** Every admin screen gated on a
|
|
23
|
+
tier and then acted on `Model.find(params[:id])`, so whoever could decide
|
|
24
|
+
leave could decide everyone's. `manager_id` sat on the profile driving
|
|
25
|
+
nothing but the org chart. There is now a Manager role at `team` scope,
|
|
26
|
+
every list is narrowed through the relation, and every member action
|
|
27
|
+
re-checks the row.
|
|
28
|
+
- **Two of the three tiers were a string match on `user.email`** — a mutable,
|
|
29
|
+
host-owned, unverified column that each host had to remember never to let
|
|
30
|
+
anybody edit. Roles are rows now.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- `HrLite::Permissions` — the declared vocabulary. A grant is a (role, key,
|
|
35
|
+
scope) row, so an unknown key cannot be stored, and asking about one raises
|
|
36
|
+
rather than quietly answering "no".
|
|
37
|
+
- `HrLite::Access` — resolves what somebody may do and whose rows it reaches,
|
|
38
|
+
memoized per request. `can?`, `reaches?`, `visible_user_ids`,
|
|
39
|
+
`scope_relation`.
|
|
40
|
+
- Controller helpers `hr_can?`, `hr_reaches?`, `hr_require_permission!`,
|
|
41
|
+
`hr_require_reach!`, `hr_scope`.
|
|
42
|
+
- `HrLite.can?`, `HrLite.reaches?`, `HrLite.users_holding` for host code.
|
|
43
|
+
- A Roles screen (`/admin/roles`, behind `role.manage`) for grants and
|
|
44
|
+
assignments. Built-in roles cannot be renamed or deleted, and the last
|
|
45
|
+
person able to manage roles cannot be removed from the role that lets them.
|
|
46
|
+
- `rake hr_lite:roles`, and `hr_lite:seed` now seeds roles too.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- `HrLite.admin?`, `.leadership?` and `.superadmin?` read off roles. They
|
|
51
|
+
still exist because views, hosts and the notification fan-out call them.
|
|
52
|
+
- `config.legacy_tier_checks` (default `false`) hands authority back to the
|
|
53
|
+
pre-0.6.0 lambdas for a host mid-migration. Honoured for one minor version;
|
|
54
|
+
**removed in 0.7.0**.
|
|
55
|
+
- `HrLite.admin_users` is one query over the grant tables rather than
|
|
56
|
+
instantiating every employee and asking.
|
|
57
|
+
- Branch-coverage floor 90% -> 90.5%.
|
|
58
|
+
|
|
59
|
+
## [0.5.3] - 2026-08-17
|
|
60
|
+
|
|
61
|
+
Correctness and traceability pass, ahead of the permissions work in 0.6.0.
|
|
62
|
+
**Adds one migration** — check constraints on every status column, plus an
|
|
63
|
+
index and foreign key on `hr_lite_designation_changes.appraisal_id`. It
|
|
64
|
+
refuses to run, naming the table and the offending values, if a live row
|
|
65
|
+
already holds a status no model declares.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **Payroll ran silently on an out-of-date statutory card.** The lookup fell
|
|
70
|
+
back to the newest card on or before the run month with no signal, so a run
|
|
71
|
+
in a financial year the gem ships no card for computed PF, ESI, PT and TDS
|
|
72
|
+
on the previous year's figures and said nothing. It still falls back —
|
|
73
|
+
payroll cannot stop dead every 1 April — but `PayrollRunProcessor` now puts
|
|
74
|
+
a warning naming both financial years at the TOP of the run's warnings, on
|
|
75
|
+
every compute. Adding a year remains one new entry in
|
|
76
|
+
`StatutoryRateCard::CARDS`; rates are never inferred forward.
|
|
77
|
+
- **A blank email could be granted the leadership tier.** `leadership_check`
|
|
78
|
+
did not drop empty entries the way `superadmin_check` did, so one stray
|
|
79
|
+
comma in `HR_LEADERSHIP_EMAILS` ("a@x.com,,b@x.com") put `""` on the list
|
|
80
|
+
and any user whose email was blank or nil matched it. Both checks now share
|
|
81
|
+
`HrLite.email_listed?`, which refuses a blank address against any list.
|
|
82
|
+
- Comp-off approval no longer reaches for a plural that cannot occur — a
|
|
83
|
+
credit is 0.5 or 1 day and never more.
|
|
84
|
+
|
|
85
|
+
### Added
|
|
86
|
+
|
|
87
|
+
- **The money path leaves a trail.** Payroll compute, finalize, unlock and
|
|
88
|
+
publish, every leave approval, rejection and cancellation, and the payout
|
|
89
|
+
register download now write `AuditLog` rows. Previously a run could be
|
|
90
|
+
computed, finalized, unlocked, edited and republished and the audit screen
|
|
91
|
+
showed none of it.
|
|
92
|
+
- These rows are written inside the same transaction as the change they
|
|
93
|
+
describe and RAISE on failure, unlike the `Audited` concern, which stays
|
|
94
|
+
best-effort for ordinary policy edits. A payroll transition nobody can
|
|
95
|
+
account for afterwards does not happen at all.
|
|
96
|
+
- Amounts are deliberately excluded — `audit_logs` is not encrypted. Rows
|
|
97
|
+
record who moved a run, how far, and over how many people. Leave rows
|
|
98
|
+
carry the approver's note and never the employee's own reason.
|
|
99
|
+
- `HrLite::PayrollRun` and `HrLite::SalarySlip` join `MONEY_TIER_TYPES`, so
|
|
100
|
+
payroll rows stay out of the leadership audit screen.
|
|
101
|
+
- `HrLite::AuditLog.record!` — the five-line `create!` that five call sites
|
|
102
|
+
had each written out.
|
|
103
|
+
- `HrLite::FinancialYear` — the Indian FY (1 April) worked out in one place
|
|
104
|
+
instead of three.
|
|
105
|
+
|
|
106
|
+
### Changed
|
|
107
|
+
|
|
108
|
+
- Statuses are enforced by the database. `update_column`, `update_all`,
|
|
109
|
+
`insert_all` and console fixes can no longer write a value that no screen
|
|
110
|
+
renders and no transition accepts.
|
|
111
|
+
- The suite enforces its own coverage: 100% line, 90% branch, 90% per file.
|
|
112
|
+
The README claimed 100% and nothing checked it; switching the floor on found
|
|
113
|
+
eight untested paths, including all three slip-override money guards (LOP
|
|
114
|
+
above the days in the month, negative LOP, negative TDS) and the offboarding
|
|
115
|
+
error path. All are covered now.
|
|
116
|
+
- `docs/PAYROLL.md` no longer lists ESI contribution-period lock-in as
|
|
117
|
+
unmodelled — 0.5.2 implemented it.
|
|
118
|
+
|
|
10
119
|
## [0.5.2] - 2026-08-08
|
|
11
120
|
|
|
12
121
|
The rest of the audit that produced 0.5.1. **Adds three migrations** — they
|
|
@@ -349,7 +458,9 @@ Initial release.
|
|
|
349
458
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
350
459
|
daily leadership digest and an append-only audit trail.
|
|
351
460
|
|
|
352
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
461
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...HEAD
|
|
462
|
+
[0.6.0]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...v0.6.0
|
|
463
|
+
[0.5.3]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...v0.5.3
|
|
353
464
|
[0.5.2]: https://github.com/kshtzkr/hr_lite/compare/v0.5.1...v0.5.2
|
|
354
465
|
[0.5.1]: https://github.com/kshtzkr/hr_lite/compare/v0.5.0...v0.5.1
|
|
355
466
|
[0.5.0]: https://github.com/kshtzkr/hr_lite/compare/v0.4.0...v0.5.0
|
data/README.md
CHANGED
|
@@ -14,8 +14,8 @@ auth, it does the HR.
|
|
|
14
14
|
|
|
15
15
|
- Mountable engine, isolated namespace (`HrLite`), tables prefixed `hr_lite_`.
|
|
16
16
|
- Bring your own users: any user model and auth (Devise or otherwise) via config hooks.
|
|
17
|
-
-
|
|
18
|
-
|
|
17
|
+
- Roles and permissions: a seeded role table you re-cut, where each grant
|
|
18
|
+
carries the scope it reaches — their own records, their team's, or everyone's.
|
|
19
19
|
- One event bus routes every HR event to in-app bells, employee emails and a
|
|
20
20
|
leadership fan-out, with an append-only audit trail of governing changes.
|
|
21
21
|
- Self-contained UI: mobile-first, dependency-free JS, theming through CSS
|
|
@@ -28,7 +28,7 @@ auth, it does the HR.
|
|
|
28
28
|
- [Installation](#installation)
|
|
29
29
|
- [Quick start (sandbox)](#quick-start-sandbox)
|
|
30
30
|
- [Configuration](#configuration)
|
|
31
|
-
- [
|
|
31
|
+
- [Roles and permissions](#roles-and-permissions)
|
|
32
32
|
- [Features](#features)
|
|
33
33
|
- [Attendance and geolocation](#attendance-and-geolocation)
|
|
34
34
|
- [Leave, comp-off and regularization](#leave-comp-off-and-regularization)
|
|
@@ -148,34 +148,70 @@ end
|
|
|
148
148
|
override it, plus the full event/notification matrix. `parent_controller` is
|
|
149
149
|
resolved once at boot — restart after changing it.
|
|
150
150
|
|
|
151
|
-
##
|
|
152
|
-
|
|
153
|
-
Authorization is
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
151
|
+
## Roles and permissions
|
|
152
|
+
|
|
153
|
+
Authorization is a **role table**, seeded and then yours to re-cut. A
|
|
154
|
+
permission is a KEY plus a SCOPE:
|
|
155
|
+
|
|
156
|
+
| | |
|
|
157
|
+
|---|---|
|
|
158
|
+
| key | what the action is — `leave.approve`, `payroll.manage`, `attendance.view` |
|
|
159
|
+
| scope | whose records it reaches — `self`, `team` (their reports) or `all` |
|
|
160
|
+
|
|
161
|
+
That is why there is no `leave.approve_own` / `_team` / `_all` triple. One key
|
|
162
|
+
with three possible scopes says the same thing with a third of the surface,
|
|
163
|
+
and it is what makes a **Manager** expressible: `leave.approve` at `team`.
|
|
164
|
+
|
|
165
|
+
Six roles ship. They are a starting point, not a ladder — `hr_lite:seed`
|
|
166
|
+
creates any that are missing and never touches one you have tuned.
|
|
167
|
+
|
|
168
|
+
| Role | Reaches |
|
|
169
|
+
|---|---|
|
|
170
|
+
| Employee | their own leave, attendance, profile and payslips |
|
|
171
|
+
| Manager | the above, plus their reports' leave and attendance |
|
|
172
|
+
| HR | leave, attendance, holidays and tickets for everyone |
|
|
173
|
+
| Finance | payroll, salary structures, the payout register, the money audit |
|
|
174
|
+
| Leadership | people and policy for everyone — deliberately **not** pay |
|
|
175
|
+
| Super Admin | everything, including who holds which role |
|
|
176
|
+
|
|
177
|
+
Grants are edited on the Roles screen (`/admin/roles`, behind `role.manage`).
|
|
178
|
+
Built-in roles cannot be renamed or deleted — the seed and the upgrade path
|
|
179
|
+
identify them by name — but every permission inside them can change. The last
|
|
180
|
+
person who can manage roles cannot be removed from the role that lets them;
|
|
181
|
+
there is no way back from that without a console.
|
|
182
|
+
|
|
183
|
+
### In your own code
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
HrLite.can?(user, "leave.approve", scope: :team) # may they, at all?
|
|
187
|
+
HrLite.reaches?(user, "leave.approve", employee) # may they, for THIS person?
|
|
188
|
+
HrLite.users_holding("payroll.manage") # everyone who can
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Controllers get `hr_can?`, `hr_reaches?`, `hr_require_permission!`,
|
|
192
|
+
`hr_require_reach!` and `hr_scope(relation, key)`. Reaching a screen and
|
|
193
|
+
reaching a particular person's row are separate questions — an index is
|
|
194
|
+
narrowed with `hr_scope` and each member action re-checks with
|
|
195
|
+
`hr_require_reach!`, which 404s rather than 403s so a stranger does not learn
|
|
196
|
+
that the other person exists.
|
|
197
|
+
|
|
198
|
+
`HrLite.admin?`, `HrLite.leadership?` and `HrLite.superadmin?` still work and
|
|
199
|
+
are read off roles.
|
|
200
|
+
|
|
201
|
+
### Upgrading from the email lists (pre-0.6.0)
|
|
202
|
+
|
|
203
|
+
Access used to come from three lambdas, two of which matched the user's
|
|
204
|
+
`email` — a mutable, host-owned, unverified column that every host then had to
|
|
205
|
+
remember never to let anybody edit.
|
|
206
|
+
|
|
207
|
+
The upgrade migration reads your existing configuration and lands everybody
|
|
208
|
+
where they already were: `superadmin_emails` to Super Admin,
|
|
209
|
+
`leadership_emails` to Leadership, `admin_check` to HR, everyone to Employee.
|
|
210
|
+
It prints who it put where, and it leaves a host that has already assigned
|
|
211
|
+
roles by hand alone.
|
|
212
|
+
|
|
213
|
+
Set `config.legacy_tier_checks = true` to keep the old lambdas in charge while
|
|
214
|
+
you migrate. It is honoured for one minor version and removed in 0.7.0.
|
|
179
215
|
|
|
180
216
|
## Features
|
|
181
217
|
|
|
@@ -4,14 +4,18 @@ module HrLite
|
|
|
4
4
|
# Team day view: everyone × their punch/status for one date.
|
|
5
5
|
def index
|
|
6
6
|
@date = parse_date_param(params[:date])
|
|
7
|
+
# A manager's board is their reports; HR's is the company. Both read
|
|
8
|
+
# the same screen — the difference is one permission scope.
|
|
9
|
+
visible = hr_access.visible_user_ids("attendance.view")
|
|
7
10
|
@employees = HrLite.employees
|
|
11
|
+
@employees = @employees.select { |user| visible.include?(user.id) } if visible
|
|
8
12
|
@records = AttendanceRecord.for_date(@date).where(user_id: @employees.map(&:id)).index_by(&:user_id)
|
|
9
13
|
@flagged_count = @records.values.count(&:flagged?)
|
|
10
14
|
end
|
|
11
15
|
|
|
12
16
|
# One employee's month + the regularization form for ?date=.
|
|
13
17
|
def show
|
|
14
|
-
@employee =
|
|
18
|
+
@employee = find_visible_employee
|
|
15
19
|
@month = parse_month_param(params[:month])
|
|
16
20
|
@day_status = DayStatus.new(user: @employee, range: @month.beginning_of_month..@month.end_of_month)
|
|
17
21
|
@counts = @day_status.counts
|
|
@@ -23,7 +27,7 @@ module HrLite
|
|
|
23
27
|
# Clearing both punch times deletes the record (that is how an
|
|
24
28
|
# erroneous punch is removed).
|
|
25
29
|
def update
|
|
26
|
-
@employee =
|
|
30
|
+
@employee = find_manageable_employee
|
|
27
31
|
date = parse_date_param(params[:date])
|
|
28
32
|
record = AttendanceRecord.find_or_initialize_by(user_id: @employee.id, date: date)
|
|
29
33
|
|
|
@@ -74,11 +78,22 @@ module HrLite
|
|
|
74
78
|
|
|
75
79
|
private
|
|
76
80
|
|
|
81
|
+
# Viewing somebody's month and rewriting their punches are different
|
|
82
|
+
# authorities, so they resolve through different permissions — a role
|
|
83
|
+
# can be given the board without being given the pencil.
|
|
84
|
+
def find_visible_employee = employee_within("attendance.view")
|
|
85
|
+
def find_manageable_employee = employee_within("attendance.manage")
|
|
86
|
+
|
|
87
|
+
def employee_within(permission)
|
|
88
|
+
employee = HrLite.user_klass.find(params[:user_id])
|
|
89
|
+
hr_require_reach!(permission, employee)
|
|
90
|
+
employee
|
|
91
|
+
end
|
|
92
|
+
|
|
77
93
|
def log_regularization(record, note, removed:)
|
|
78
|
-
AuditLog.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
audited_changes: { "date" => record.date.to_s, "note" => note }
|
|
94
|
+
AuditLog.record!(
|
|
95
|
+
action: removed ? "destroy" : "regularize", subject: record, actor: hr_current_user,
|
|
96
|
+
changes: { "date" => record.date.to_s, "note" => note }
|
|
82
97
|
)
|
|
83
98
|
Notifications.publish(
|
|
84
99
|
"attendance.regularized",
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
|
-
# Day-to-day operations
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# Day-to-day operations: the overview board, team attendance, leave
|
|
4
|
+
# decisions, balance adjustments.
|
|
5
|
+
#
|
|
6
|
+
# The gate is now "can you decide somebody's leave, or fix somebody's
|
|
7
|
+
# attendance" rather than "are you an admin". That is what admits a
|
|
8
|
+
# MANAGER to these screens for the first time — at `team` scope, which
|
|
9
|
+
# every controller below then enforces row by row. Reaching the screen
|
|
10
|
+
# and reaching a particular person's row are two separate questions, and
|
|
11
|
+
# conflating them is precisely the bug this replaces.
|
|
6
12
|
class BaseController < ApplicationController
|
|
7
|
-
before_action :
|
|
13
|
+
before_action :require_operations_access!
|
|
8
14
|
|
|
9
15
|
private
|
|
10
16
|
|
|
11
|
-
def
|
|
12
|
-
|
|
17
|
+
def require_operations_access!
|
|
18
|
+
return if hr_can?("leave.approve", scope: :team) ||
|
|
19
|
+
hr_can?("attendance.manage", scope: :team) ||
|
|
20
|
+
hr_can?("leave.view", scope: :team)
|
|
21
|
+
|
|
22
|
+
hr_access_denied
|
|
13
23
|
end
|
|
14
24
|
end
|
|
15
25
|
end
|
|
@@ -3,15 +3,16 @@ module HrLite
|
|
|
3
3
|
class CompOffRequestsController < BaseController
|
|
4
4
|
def index
|
|
5
5
|
@status = params[:status].presence_in(CompOffRequest::STATUSES) || "pending"
|
|
6
|
-
@requests = paginate(CompOffRequest.includes(:user).where(status: @status).recent_first
|
|
6
|
+
@requests = paginate(hr_scope(CompOffRequest.includes(:user).where(status: @status).recent_first,
|
|
7
|
+
"leave.view"))
|
|
7
8
|
end
|
|
8
9
|
|
|
9
10
|
def show
|
|
10
|
-
@request =
|
|
11
|
+
@request = find_decidable
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def approve
|
|
14
|
-
request =
|
|
15
|
+
request = find_decidable
|
|
15
16
|
request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
|
|
16
17
|
redirect_to admin_comp_off_requests_path,
|
|
17
18
|
notice: "Approved — #{request.credit_days.to_f} day credited."
|
|
@@ -22,7 +23,7 @@ module HrLite
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def reject
|
|
25
|
-
request =
|
|
26
|
+
request = find_decidable
|
|
26
27
|
note = params[:decision_note].to_s.strip
|
|
27
28
|
if note.blank?
|
|
28
29
|
return redirect_to admin_comp_off_request_path(request), alert: "A note is required to reject."
|
|
@@ -33,6 +34,17 @@ module HrLite
|
|
|
33
34
|
rescue ActiveRecord::RecordInvalid
|
|
34
35
|
redirect_to admin_comp_off_request_path(request), alert: "Only pending requests can be decided."
|
|
35
36
|
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# A manager decides for their own reports; HR decides for everyone.
|
|
41
|
+
# Scoped through the relation, so reaching for somebody else's report
|
|
42
|
+
# is a 404 rather than a 403 that confirms the person exists.
|
|
43
|
+
def decidable
|
|
44
|
+
hr_scope(CompOffRequest.all, "leave.approve")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_decidable = decidable.find(params[:id])
|
|
36
48
|
end
|
|
37
49
|
end
|
|
38
50
|
end
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
|
-
# Governing
|
|
4
|
-
# settings, office locations, holidays, employee profiles,
|
|
5
|
-
#
|
|
6
|
-
#
|
|
3
|
+
# Governing screens: policy and people for the whole company — leave
|
|
4
|
+
# types, settings, office locations, holidays, employee profiles,
|
|
5
|
+
# resignations, the audit trail.
|
|
6
|
+
#
|
|
7
|
+
# `profile.manage` at `all` is the key, because governing is exactly the
|
|
8
|
+
# authority to change somebody else's record. Reaching an operations
|
|
9
|
+
# screen is not enough, and neither is managing a team.
|
|
7
10
|
class LeadershipController < BaseController
|
|
8
|
-
skip_before_action :
|
|
9
|
-
before_action :
|
|
11
|
+
skip_before_action :require_operations_access!
|
|
12
|
+
before_action :require_governing_access!
|
|
10
13
|
|
|
11
14
|
private
|
|
12
15
|
|
|
13
|
-
def
|
|
14
|
-
|
|
16
|
+
def require_governing_access!
|
|
17
|
+
hr_require_permission!("profile.manage", scope: :all)
|
|
15
18
|
end
|
|
16
19
|
end
|
|
17
20
|
end
|
|
@@ -23,11 +23,10 @@ module HrLite
|
|
|
23
23
|
|
|
24
24
|
balance = LeaveBalance.adjust!(user, type, year, delta: delta, note: "#{delta.to_f} — #{note}")
|
|
25
25
|
|
|
26
|
-
AuditLog.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"delta" => delta.to_f, "note" => note }
|
|
26
|
+
AuditLog.record!(
|
|
27
|
+
action: "adjust", subject: balance, actor: hr_current_user,
|
|
28
|
+
changes: { "user" => HrLite.display_name(user), "type" => type.code,
|
|
29
|
+
"delta" => delta.to_f, "note" => note }
|
|
31
30
|
)
|
|
32
31
|
redirect_to admin_leave_balances_path(year: year),
|
|
33
32
|
notice: "Balance adjusted for #{HrLite.display_name(user)}."
|
|
@@ -4,16 +4,20 @@ module HrLite
|
|
|
4
4
|
def index
|
|
5
5
|
scope = LeaveRequest.includes(:leave_type, :user).recent_first
|
|
6
6
|
@status = params[:status].presence_in(LeaveRequest::STATUSES) || "pending"
|
|
7
|
-
|
|
7
|
+
# A manager's queue is their own reports. HR's is everybody's. Scoping
|
|
8
|
+
# the LIST as well as the decision matters: an index that shows a row
|
|
9
|
+
# the member action then refuses is a worse screen than one that never
|
|
10
|
+
# showed it.
|
|
11
|
+
@requests = paginate(hr_scope(scope.where(status: @status), "leave.view"))
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def show
|
|
11
|
-
@request =
|
|
15
|
+
@request = decidable.includes(:leave_type).find(params[:id])
|
|
12
16
|
@balance = @request.balance
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def approve
|
|
16
|
-
request =
|
|
20
|
+
request = find_decidable
|
|
17
21
|
if request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
|
|
18
22
|
redirect_to admin_leave_requests_path, notice: "Leave approved."
|
|
19
23
|
else
|
|
@@ -30,7 +34,7 @@ module HrLite
|
|
|
30
34
|
# while the person was unreachable, or after they were offboarded, could
|
|
31
35
|
# not be released at all, and the quota stayed spent.
|
|
32
36
|
def cancel
|
|
33
|
-
request =
|
|
37
|
+
request = find_decidable
|
|
34
38
|
if request.cancellable_by?(hr_current_user)
|
|
35
39
|
request.cancel!(actor: hr_current_user)
|
|
36
40
|
redirect_to admin_leave_requests_path, notice: "Leave cancelled — the balance is released."
|
|
@@ -41,7 +45,7 @@ module HrLite
|
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
def reject
|
|
44
|
-
request =
|
|
48
|
+
request = find_decidable
|
|
45
49
|
note = params[:decision_note].to_s.strip
|
|
46
50
|
if note.blank?
|
|
47
51
|
return redirect_to admin_leave_request_path(request), alert: "A note is required to reject."
|
|
@@ -52,6 +56,18 @@ module HrLite
|
|
|
52
56
|
rescue ActiveRecord::RecordInvalid
|
|
53
57
|
redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
|
|
54
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Requests this person may actually decide. A manager reaching for
|
|
63
|
+
# somebody else's report gets a 404 through the scoped relation rather
|
|
64
|
+
# than a 403 — the same shape every employee-tier screen already uses,
|
|
65
|
+
# and it does not confirm that the other person exists.
|
|
66
|
+
def decidable
|
|
67
|
+
hr_scope(LeaveRequest.all, "leave.approve")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def find_decidable = decidable.find(params[:id])
|
|
55
71
|
end
|
|
56
72
|
end
|
|
57
73
|
end
|
|
@@ -55,8 +55,17 @@ module HrLite
|
|
|
55
55
|
# deliberately leadership-only PII.
|
|
56
56
|
def register
|
|
57
57
|
run = PayrollRun.find(params[:id])
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
csv = register_csv(run)
|
|
59
|
+
# A GET that walks out of the building with every employee's pay and
|
|
60
|
+
# bank account. Nothing else in the module leaks this much in one
|
|
61
|
+
# click, so the download itself is the auditable event.
|
|
62
|
+
AuditLog.record!(
|
|
63
|
+
action: "payroll.register_exported", subject: run, actor: hr_current_user,
|
|
64
|
+
changes: { "period" => run.label, "rows" => run.salary_slips.count,
|
|
65
|
+
"includes_bank_details" => true }
|
|
66
|
+
)
|
|
67
|
+
send_data csv, filename: "payroll-register-#{run.period_month.strftime('%Y-%m')}.csv",
|
|
68
|
+
type: "text/csv"
|
|
60
69
|
end
|
|
61
70
|
|
|
62
71
|
private
|
|
@@ -3,15 +3,16 @@ module HrLite
|
|
|
3
3
|
class RegularizationRequestsController < BaseController
|
|
4
4
|
def index
|
|
5
5
|
@status = params[:status].presence_in(RegularizationRequest::STATUSES) || "pending"
|
|
6
|
-
@requests = paginate(RegularizationRequest.includes(:user).where(status: @status).recent_first
|
|
6
|
+
@requests = paginate(hr_scope(RegularizationRequest.includes(:user).where(status: @status).recent_first,
|
|
7
|
+
"attendance.view"))
|
|
7
8
|
end
|
|
8
9
|
|
|
9
10
|
def show
|
|
10
|
-
@request =
|
|
11
|
+
@request = find_decidable
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def approve
|
|
14
|
-
request =
|
|
15
|
+
request = find_decidable
|
|
15
16
|
request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
|
|
16
17
|
redirect_to admin_regularization_requests_path, notice: "Ticket approved — attendance fixed."
|
|
17
18
|
rescue RegularizationRequest::InvalidMerge => e
|
|
@@ -22,7 +23,7 @@ module HrLite
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def reject
|
|
25
|
-
request =
|
|
26
|
+
request = find_decidable
|
|
26
27
|
note = params[:decision_note].to_s.strip
|
|
27
28
|
if note.blank?
|
|
28
29
|
return redirect_to admin_regularization_request_path(request), alert: "A note is required to reject."
|
|
@@ -33,6 +34,17 @@ module HrLite
|
|
|
33
34
|
rescue ActiveRecord::RecordInvalid
|
|
34
35
|
redirect_to admin_regularization_request_path(request), alert: "Only pending tickets can be decided."
|
|
35
36
|
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# A manager decides for their own reports; HR decides for everyone.
|
|
41
|
+
# Scoped through the relation, so reaching for somebody else's report
|
|
42
|
+
# is a 404 rather than a 403 that confirms the person exists.
|
|
43
|
+
def decidable
|
|
44
|
+
hr_scope(RegularizationRequest.all, "attendance.manage")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_decidable = decidable.find(params[:id])
|
|
36
48
|
end
|
|
37
49
|
end
|
|
38
50
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Putting people into a role and taking them out again. Same gate as the
|
|
4
|
+
# roles themselves: granting somebody payroll is the same act as holding
|
|
5
|
+
# it, so it sits behind `role.manage`.
|
|
6
|
+
class RoleAssignmentsController < LeadershipController
|
|
7
|
+
skip_before_action :require_governing_access!
|
|
8
|
+
before_action :require_role_management!
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
role = Role.find(params[:role_id])
|
|
12
|
+
user = HrLite.user_klass.find(params[:user_id])
|
|
13
|
+
RoleAssignment.create!(role: role, user_id: user.id, granted_by_id: hr_current_user.id)
|
|
14
|
+
|
|
15
|
+
redirect_to edit_admin_role_path(role),
|
|
16
|
+
notice: "#{HrLite.display_name(user)} added to #{role.name}."
|
|
17
|
+
rescue ActiveRecord::RecordInvalid
|
|
18
|
+
redirect_to edit_admin_role_path(params[:role_id]),
|
|
19
|
+
alert: "They already hold that role."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def destroy
|
|
23
|
+
assignment = RoleAssignment.find(params[:id])
|
|
24
|
+
role = assignment.role
|
|
25
|
+
name = HrLite.display_name(assignment.user)
|
|
26
|
+
|
|
27
|
+
# Locking every last person out of role management is not recoverable
|
|
28
|
+
# from inside the app — it would need a console. Refuse instead.
|
|
29
|
+
if last_role_manager?(assignment)
|
|
30
|
+
return redirect_to edit_admin_role_path(role),
|
|
31
|
+
alert: "#{name} is the only person who can manage roles. " \
|
|
32
|
+
"Give somebody else that permission first."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
assignment.destroy!
|
|
36
|
+
redirect_to edit_admin_role_path(role), notice: "#{name} removed from #{role.name}.",
|
|
37
|
+
status: :see_other
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def require_role_management!
|
|
43
|
+
hr_require_permission!("role.manage", scope: :all)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def last_role_manager?(assignment)
|
|
47
|
+
return false unless HrLite.can?(assignment.user, "role.manage", scope: :all)
|
|
48
|
+
|
|
49
|
+
others = HrLite.users_holding("role.manage").where.not(id: assignment.user_id)
|
|
50
|
+
# Another of their OWN roles may still carry it, in which case
|
|
51
|
+
# removing this one changes nothing.
|
|
52
|
+
others.empty? && !still_manages_without?(assignment)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def still_manages_without?(assignment)
|
|
56
|
+
RoleGrant.joins(role: :role_assignments)
|
|
57
|
+
.where(hr_lite_role_assignments: { user_id: assignment.user_id })
|
|
58
|
+
.where.not(hr_lite_role_assignments: { id: assignment.id })
|
|
59
|
+
.exists?(permission_key: "role.manage", scope: "all")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|