hr_lite 0.5.3 → 0.7.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 +107 -1
- data/README.md +67 -31
- data/app/controllers/hr_lite/admin/attendances_controller.rb +18 -2
- 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_requests_controller.rb +21 -5
- 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/statutory_rate_cards_controller.rb +97 -0
- 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 +6 -2
- data/app/models/hr_lite/professional_tax_slab.rb +41 -0
- 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/statutory_rate_card_record.rb +95 -0
- data/app/services/hr_lite/access.rb +111 -0
- data/app/services/hr_lite/calculators/professional_tax.rb +34 -5
- data/app/services/hr_lite/payroll_run_processor.rb +17 -0
- 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/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +109 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/edit.html.erb +4 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/index.html.erb +59 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/new.html.erb +4 -0
- data/config/routes.rb +4 -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/db/migrate/20260817190159_create_hr_lite_statutory_rate_cards.rb +44 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
- data/lib/hr_lite/configuration.rb +8 -1
- data/lib/hr_lite/current.rb +4 -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 +55 -10
- data/lib/hr_lite/statutory_seeds.rb +71 -0
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +64 -10
- data/lib/tasks/hr_lite_tasks.rake +18 -2
- metadata +24 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1716d66b8a6859e70f044dc0025b2052a8085ad83c82567540cbad04a216be9d
|
|
4
|
+
data.tar.gz: bc4d26d3c69be4f280e55820672892fac8ae6a93c190be3b6b4272adda7754b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a0ce5511c8c763eac022ee7f900d3c92a0267057c0f6805e49d753076c9c07f6a842285c6d2f034cd3ee6fe40a2343865a4295fd696ef2f0ab4e2a44291c2ffe
|
|
7
|
+
data.tar.gz: bdfac21210c1a26383b2d9bed8782f093f651c479e5fe150fad94ad46a012a3612057098d4ea78a8f79f1a80652099c214e47954901cd546a8dd4b340e726b84
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
Statutory figures stop being code. **Adds one migration** (rate cards and
|
|
13
|
+
professional-tax slabs) and seeds it from the figures the gem already
|
|
14
|
+
shipped — no number changes on upgrade.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Every state except Karnataka deducted ₹0 professional tax, silently.** PT
|
|
19
|
+
is a state levy and only Karnataka had bands in code; every other state
|
|
20
|
+
resolved to an empty slab list and deducted nothing, which is a wrong answer
|
|
21
|
+
delivered confidently. Slabs are now per state and effective-dated, and a
|
|
22
|
+
run whose employees sit in a state nobody has configured says so — once per
|
|
23
|
+
state, not once per employee.
|
|
24
|
+
- **Adding a financial year needed a gem release.** Every April was a deadline
|
|
25
|
+
the gem controlled and the company did not; 0.5.3 shipped a warning about
|
|
26
|
+
exactly that. Cards are rows now, entered on a screen.
|
|
27
|
+
- A tax regime saved with an empty slab list validated on key presence and
|
|
28
|
+
then taxed every salary at zero. Refused.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `hr_lite_statutory_rate_cards` — PF, ESI and both regimes' slabs per
|
|
33
|
+
financial year, effective-dated. A card must start on 1 April (a mid-year
|
|
34
|
+
card would mean two slab sets inside one year, and the TDS projector works
|
|
35
|
+
on an annual figure), and must carry every figure payroll reads — a card
|
|
36
|
+
missing one fails at save time rather than half way through computing
|
|
37
|
+
somebody's salary.
|
|
38
|
+
- `hr_lite_professional_tax_slabs` — per state, per date, with the February
|
|
39
|
+
top-up the states that levy one need.
|
|
40
|
+
- A rate-card screen (money tier). Adding a year copies the newest card, since
|
|
41
|
+
most years carry PF and ESI over untouched and move only the slabs — a blank
|
|
42
|
+
form would invite retyping eight numbers that were already right.
|
|
43
|
+
- **Sign-off.** A card records who checked it and when, and payroll warns on
|
|
44
|
+
every run computed against one nobody has confirmed. Ranked below the
|
|
45
|
+
wrong-financial-year warning: bad figures matter more than missing paperwork.
|
|
46
|
+
- `rake hr_lite:statutory`, and `hr_lite:seed` copies the shipped figures in.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- `StatutoryRateCard.for` reads the table, falling back to the shipped hash
|
|
51
|
+
for a host that has upgraded the gem but not yet run `db:migrate` — payroll
|
|
52
|
+
must not 500 on a missing table.
|
|
53
|
+
- Branch-coverage floor 90.5% -> 90.8%.
|
|
54
|
+
|
|
55
|
+
### Deferred
|
|
56
|
+
|
|
57
|
+
- **`config.legacy_tier_checks` and the pre-0.6.0 tier lambdas are still
|
|
58
|
+
here.** 0.6.0 said they would go in this release. They have not: removing an
|
|
59
|
+
access mechanism in the same release that rewrites where statutory figures
|
|
60
|
+
live puts two unrelated upgrade risks in one step, and this release is
|
|
61
|
+
otherwise purely additive. They go in **0.8.0**, on their own. If you are
|
|
62
|
+
still relying on the flag, the 0.6.0 migration has already created the
|
|
63
|
+
equivalent role assignments — turn it off and check the Roles screen.
|
|
64
|
+
|
|
65
|
+
## [0.6.0] - 2026-08-18
|
|
66
|
+
|
|
67
|
+
Access stops being three lambdas and becomes a role table. **Breaking**, with
|
|
68
|
+
an upgrade path that is designed not to be: the migration reads your own
|
|
69
|
+
configuration and lands everybody where they already were.
|
|
70
|
+
|
|
71
|
+
**Adds two migrations.** The first creates the role tables; the second seeds
|
|
72
|
+
the built-in roles and derives assignments from your existing email lists,
|
|
73
|
+
printing who it put where.
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- **Any admin could approve anybody's leave.** Every admin screen gated on a
|
|
78
|
+
tier and then acted on `Model.find(params[:id])`, so whoever could decide
|
|
79
|
+
leave could decide everyone's. `manager_id` sat on the profile driving
|
|
80
|
+
nothing but the org chart. There is now a Manager role at `team` scope,
|
|
81
|
+
every list is narrowed through the relation, and every member action
|
|
82
|
+
re-checks the row.
|
|
83
|
+
- **Two of the three tiers were a string match on `user.email`** — a mutable,
|
|
84
|
+
host-owned, unverified column that each host had to remember never to let
|
|
85
|
+
anybody edit. Roles are rows now.
|
|
86
|
+
|
|
87
|
+
### Added
|
|
88
|
+
|
|
89
|
+
- `HrLite::Permissions` — the declared vocabulary. A grant is a (role, key,
|
|
90
|
+
scope) row, so an unknown key cannot be stored, and asking about one raises
|
|
91
|
+
rather than quietly answering "no".
|
|
92
|
+
- `HrLite::Access` — resolves what somebody may do and whose rows it reaches,
|
|
93
|
+
memoized per request. `can?`, `reaches?`, `visible_user_ids`,
|
|
94
|
+
`scope_relation`.
|
|
95
|
+
- Controller helpers `hr_can?`, `hr_reaches?`, `hr_require_permission!`,
|
|
96
|
+
`hr_require_reach!`, `hr_scope`.
|
|
97
|
+
- `HrLite.can?`, `HrLite.reaches?`, `HrLite.users_holding` for host code.
|
|
98
|
+
- A Roles screen (`/admin/roles`, behind `role.manage`) for grants and
|
|
99
|
+
assignments. Built-in roles cannot be renamed or deleted, and the last
|
|
100
|
+
person able to manage roles cannot be removed from the role that lets them.
|
|
101
|
+
- `rake hr_lite:roles`, and `hr_lite:seed` now seeds roles too.
|
|
102
|
+
|
|
103
|
+
### Changed
|
|
104
|
+
|
|
105
|
+
- `HrLite.admin?`, `.leadership?` and `.superadmin?` read off roles. They
|
|
106
|
+
still exist because views, hosts and the notification fan-out call them.
|
|
107
|
+
- `config.legacy_tier_checks` (default `false`) hands authority back to the
|
|
108
|
+
pre-0.6.0 lambdas for a host mid-migration. Honoured for one minor version;
|
|
109
|
+
**removed in 0.7.0**.
|
|
110
|
+
- `HrLite.admin_users` is one query over the grant tables rather than
|
|
111
|
+
instantiating every employee and asking.
|
|
112
|
+
- Branch-coverage floor 90% -> 90.5%.
|
|
113
|
+
|
|
10
114
|
## [0.5.3] - 2026-08-17
|
|
11
115
|
|
|
12
116
|
Correctness and traceability pass, ahead of the permissions work in 0.6.0.
|
|
@@ -409,7 +513,9 @@ Initial release.
|
|
|
409
513
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
410
514
|
daily leadership digest and an append-only audit trail.
|
|
411
515
|
|
|
412
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
516
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.7.0...HEAD
|
|
517
|
+
[0.7.0]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...v0.7.0
|
|
518
|
+
[0.6.0]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...v0.6.0
|
|
413
519
|
[0.5.3]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...v0.5.3
|
|
414
520
|
[0.5.2]: https://github.com/kshtzkr/hr_lite/compare/v0.5.1...v0.5.2
|
|
415
521
|
[0.5.1]: https://github.com/kshtzkr/hr_lite/compare/v0.5.0...v0.5.1
|
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.8.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,6 +78,18 @@ 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
94
|
AuditLog.record!(
|
|
79
95
|
action: removed ? "destroy" : "regularize", subject: record, actor: hr_current_user,
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Who may do what. Gated on `role.manage`, which by default only the
|
|
4
|
+
# money tier holds — the authority to grant yourself payroll is the
|
|
5
|
+
# authority to read everyone's pay, so it lives with the pay.
|
|
6
|
+
class RolesController < LeadershipController
|
|
7
|
+
skip_before_action :require_governing_access!
|
|
8
|
+
before_action :require_role_management!
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@roles = Role.alphabetical.includes(:role_grants, :role_assignments)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def new
|
|
15
|
+
@role = Role.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
@role = Role.new(role_params)
|
|
20
|
+
if @role.save
|
|
21
|
+
@role.replace_grants!(grant_params)
|
|
22
|
+
redirect_to admin_roles_path, notice: "#{@role.name} created."
|
|
23
|
+
else
|
|
24
|
+
render :new, status: :unprocessable_entity
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def edit
|
|
29
|
+
@role = Role.find(params[:id])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def update
|
|
33
|
+
@role = Role.find(params[:id])
|
|
34
|
+
if @role.update(role_params)
|
|
35
|
+
@role.replace_grants!(grant_params)
|
|
36
|
+
redirect_to admin_roles_path, notice: "#{@role.name} updated."
|
|
37
|
+
else
|
|
38
|
+
render :edit, status: :unprocessable_entity
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def destroy
|
|
43
|
+
role = Role.find(params[:id])
|
|
44
|
+
if role.destroy
|
|
45
|
+
redirect_to admin_roles_path, notice: "#{role.name} deleted.", status: :see_other
|
|
46
|
+
else
|
|
47
|
+
redirect_to admin_roles_path, alert: role.errors.full_messages.to_sentence,
|
|
48
|
+
status: :see_other
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def require_role_management!
|
|
55
|
+
hr_require_permission!("role.manage", scope: :all)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def role_params
|
|
59
|
+
permitted = params.require(:role).permit(:name, :description)
|
|
60
|
+
# A built-in role's NAME is what the seed, the upgrade migration and
|
|
61
|
+
# the specs all identify it by; the model refuses a rename, and not
|
|
62
|
+
# sending one keeps the form from failing on an untouched field.
|
|
63
|
+
@role&.system? ? permitted.except(:name) : permitted
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The form posts the COMPLETE picture, so a permission absent from it is
|
|
67
|
+
# one being taken away. "none" is how the form says "not granted".
|
|
68
|
+
def grant_params
|
|
69
|
+
params.fetch(:grants, {}).permit(Permissions::KEYS).to_h
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|