hr_lite 0.7.0 → 0.9.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 +100 -1
- data/README.md +3 -2
- data/app/controllers/hr_lite/approvals_controller.rb +31 -0
- data/app/helpers/hr_lite/application_helper.rb +21 -0
- data/app/jobs/hr_lite/approval_escalation_job.rb +39 -0
- data/app/models/concerns/hr_lite/approvable.rb +48 -0
- data/app/models/hr_lite/approval.rb +46 -0
- data/app/models/hr_lite/approval_delegation.rb +38 -0
- data/app/models/hr_lite/approval_flow.rb +39 -0
- data/app/models/hr_lite/approval_step.rb +62 -0
- data/app/models/hr_lite/leave_request.rb +72 -17
- data/app/services/hr_lite/approval_route.rb +109 -0
- data/app/views/hr_lite/approvals/index.html.erb +66 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260818001151_create_hr_lite_approvals.rb +79 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +0 -5
- data/lib/hr_lite/configuration.rb +14 -17
- data/lib/hr_lite/notifications.rb +3 -0
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +13 -36
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42a084897bd2831303410129ecaa25dac81fdb97b711bfe07071cf28496d58d1
|
|
4
|
+
data.tar.gz: 014663d43940503d5fa840f5bb0460b8fd3e20bfbaf07a1aa6821b491798d16d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40177d861f9579695084b3e0bca3fefd4dd9593d430d800c7c1bfed2c88d1de4820fd12c6c816c24cf29f0d70eb8db26efe2c9cc10870ec1f9be15b262c05f1d
|
|
7
|
+
data.tar.gz: 261892abb65e4ebb80f12cdfbdf7763d2e82f9040268efa796870ec6af413cc25079e9e124536e2eb0a4e1a937e0de6a98f98fde84901248a91a3ea944f684bf
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,103 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
A reusable approval engine. **Adds one migration** (flows, steps, approvals,
|
|
13
|
+
delegations). Nothing changes for a module until a flow is configured for it,
|
|
14
|
+
which is what lets the four existing ones migrate one at a time.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Cancelling a leave request left its approval sitting in somebody's
|
|
19
|
+
inbox.** Found by the coverage floor, which flagged `cancel_all!` as
|
|
20
|
+
unreachable — it was written and never called.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `HrLite::ApprovalFlow` / `ApprovalStep` / `Approval` / `ApprovalDelegation`.
|
|
25
|
+
A flow is "leave needs the manager, then HR". A step names its approver by
|
|
26
|
+
RULE — manager, manager's manager, anyone holding a permission, or one named
|
|
27
|
+
person — so a flow survives somebody leaving; the rule resolves against the
|
|
28
|
+
subject when the request is raised.
|
|
29
|
+
- Multi-level, sequential routing, with a `unanimous` step for the rungs where
|
|
30
|
+
everybody has to answer rather than the first person to look.
|
|
31
|
+
- **A rung nobody occupies is skipped**, not left waiting. An employee with no
|
|
32
|
+
manager recorded would otherwise have a request no living person could
|
|
33
|
+
decide.
|
|
34
|
+
- **Delegation.** "I am away until the 14th — Priya decides for me." The
|
|
35
|
+
approval does not move; a stand-in may answer it, and the row records both
|
|
36
|
+
who it was addressed to and who actually decided. Two hops, so a stand-in
|
|
37
|
+
who is also away is covered, and a loop cannot hand somebody their own
|
|
38
|
+
approvals back.
|
|
39
|
+
- **SLA and escalation.** A step may carry a deadline; `ApprovalEscalationJob`
|
|
40
|
+
tells an approver once per overdue row, grouped into one message per person,
|
|
41
|
+
and stamps the rows so a daily run does not nag daily. Escalation TELLS
|
|
42
|
+
somebody — it does not reassign, because a decision made by somebody who
|
|
43
|
+
never saw the request is worse than a late one.
|
|
44
|
+
- **One approval inbox** (`/approvals`) replacing four places to look. Employee
|
|
45
|
+
tier: holding an approval is the authorisation, so a manager, a stand-in and
|
|
46
|
+
a director reach the same screen and each sees only their own rows. It sits
|
|
47
|
+
after Calendar in the nav — the phone tab bar shows the first four, and this
|
|
48
|
+
screen is empty for anybody who is not an approver.
|
|
49
|
+
- `HrLite::Approvable` — opts a model in WITHOUT taking over what its decisions
|
|
50
|
+
mean. `LeaveRequest` keeps its own `approve!`, including the balance lock;
|
|
51
|
+
the concern only answers "is it this person's turn, and does their answer
|
|
52
|
+
settle it". A settling decision runs the ordinary transition, so routing
|
|
53
|
+
never becomes a second path that can drift from the first.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- `LeaveRequest` is the first module routed. With no flow it behaves exactly
|
|
58
|
+
as before, and somebody outside the current rung who holds `leave.approve`
|
|
59
|
+
at `all` can still settle it outright — the routed path is the normal one,
|
|
60
|
+
not the only one.
|
|
61
|
+
- Branch-coverage floor 90.8% -> 90.3%, deliberately, with the arithmetic
|
|
62
|
+
written into `spec_helper.rb`: the engine added ~90 branches at once, and
|
|
63
|
+
holding the percentage would have meant contriving specs for untouched
|
|
64
|
+
files. Line coverage stayed at 100%.
|
|
65
|
+
|
|
66
|
+
### Not yet
|
|
67
|
+
|
|
68
|
+
- Comp-off, regularization and resignation still use their own single
|
|
69
|
+
decision. They are declared approvable and migrate next, one release each.
|
|
70
|
+
|
|
71
|
+
## [0.8.0] - 2026-08-18
|
|
72
|
+
|
|
73
|
+
The deprecation 0.6.0 opened, closed. **Breaking** only for an install still
|
|
74
|
+
setting `config.legacy_tier_checks`; no migration.
|
|
75
|
+
|
|
76
|
+
### Removed
|
|
77
|
+
|
|
78
|
+
- **`config.legacy_tier_checks` and the pre-0.6.0 tier lambdas no longer
|
|
79
|
+
decide access.** `HrLite.admin?`, `.leadership?` and `.superadmin?` read
|
|
80
|
+
roles, full stop. Two of those lambdas matched the user's `email` — mutable,
|
|
81
|
+
host-owned, unverified — and a host that kept the flag on kept that hole
|
|
82
|
+
open.
|
|
83
|
+
- The flag is gone from the configuration object entirely, so an initializer
|
|
84
|
+
that still sets it now fails loudly at boot rather than silently doing
|
|
85
|
+
nothing. That is the intended way to find out.
|
|
86
|
+
|
|
87
|
+
### Changed
|
|
88
|
+
|
|
89
|
+
- `admin_check`, `leadership_emails` / `leadership_check` and
|
|
90
|
+
`superadmin_emails` / `superadmin_check` remain on the configuration, and
|
|
91
|
+
are now **migration input only**: the 0.6.0 upgrade migration reads them to
|
|
92
|
+
derive role assignments. An install jumping 0.5.x → 0.8.0 in one step still
|
|
93
|
+
needs them present for that migration to have something to read, which is
|
|
94
|
+
why they were not deleted outright. Setting them on a current install has no
|
|
95
|
+
effect on who can reach what.
|
|
96
|
+
- `HrLite.leadership_users` and `.admin_users` are one query over the grant
|
|
97
|
+
tables. The legacy paths that instantiated every employee and ran a host
|
|
98
|
+
lambda per row are gone.
|
|
99
|
+
|
|
100
|
+
### Upgrading
|
|
101
|
+
|
|
102
|
+
If you set `legacy_tier_checks = true`, delete that line. Before you do, open
|
|
103
|
+
the Roles screen and confirm the people who need access hold it — the 0.6.0
|
|
104
|
+
migration will have assigned them already unless you had turned the flag on
|
|
105
|
+
before it ran.
|
|
106
|
+
|
|
10
107
|
## [0.7.0] - 2026-08-18
|
|
11
108
|
|
|
12
109
|
Statutory figures stop being code. **Adds one migration** (rate cards and
|
|
@@ -513,7 +610,9 @@ Initial release.
|
|
|
513
610
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
514
611
|
daily leadership digest and an append-only audit trail.
|
|
515
612
|
|
|
516
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
613
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...HEAD
|
|
614
|
+
[0.9.0]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...v0.9.0
|
|
615
|
+
[0.8.0]: https://github.com/kshtzkr/hr_lite/compare/v0.7.0...v0.8.0
|
|
517
616
|
[0.7.0]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...v0.7.0
|
|
518
617
|
[0.6.0]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...v0.6.0
|
|
519
618
|
[0.5.3]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...v0.5.3
|
data/README.md
CHANGED
|
@@ -210,8 +210,9 @@ where they already were: `superadmin_emails` to Super Admin,
|
|
|
210
210
|
It prints who it put where, and it leaves a host that has already assigned
|
|
211
211
|
roles by hand alone.
|
|
212
212
|
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
As of 0.8.0 those lambdas decide nothing. They are read only by that
|
|
214
|
+
migration, so an install jumping several versions at once still has something
|
|
215
|
+
for it to derive from; setting them on a current install has no effect.
|
|
215
216
|
|
|
216
217
|
## Features
|
|
217
218
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Everything waiting on the signed-in person, in one place. Before this,
|
|
3
|
+
# an approver had to visit four screens to find out whether anybody was
|
|
4
|
+
# waiting on them, and nothing told them if a request had gone stale.
|
|
5
|
+
#
|
|
6
|
+
# Employee tier on purpose: holding an approval IS the authorisation. A
|
|
7
|
+
# manager, a stand-in covering for one, and a director all reach the same
|
|
8
|
+
# screen and each sees only their own rows.
|
|
9
|
+
class ApprovalsController < ApplicationController
|
|
10
|
+
def index
|
|
11
|
+
@approvals = paginate(mine.includes(:step, :subject).order(:created_at))
|
|
12
|
+
@delegations = ApprovalDelegation.live_on(Date.current)
|
|
13
|
+
.where(from_user_id: hr_current_user.id)
|
|
14
|
+
.includes(:to_user)
|
|
15
|
+
@covering_for = ApprovalDelegation.live_on(Date.current)
|
|
16
|
+
.where(to_user_id: hr_current_user.id)
|
|
17
|
+
.includes(:from_user)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Rows addressed to this person, plus rows belonging to anybody who has
|
|
23
|
+
# delegated to them while they are away.
|
|
24
|
+
def mine
|
|
25
|
+
standing_in_for = ApprovalDelegation.live_on(Date.current)
|
|
26
|
+
.where(to_user_id: hr_current_user.id)
|
|
27
|
+
.pluck(:from_user_id)
|
|
28
|
+
Approval.pending.where(approver_id: [ hr_current_user.id, *standing_in_for ])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -6,6 +6,12 @@ module HrLite
|
|
|
6
6
|
{ label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances", "/comp_off_requests" ] },
|
|
7
7
|
{ label: "Team", path: :team_path, match: [ "/team" ] },
|
|
8
8
|
{ label: "Calendar", path: :calendar_path, match: [ "/calendar", "/holidays" ] },
|
|
9
|
+
# After Calendar deliberately: the phone tab bar shows the first four
|
|
10
|
+
# and this screen is empty for everybody who is not an approver, so it
|
|
11
|
+
# belongs in "More" rather than displacing something everybody uses.
|
|
12
|
+
# Employee tier all the same — holding an approval IS the authorisation,
|
|
13
|
+
# so a manager and a stand-in covering for one reach the same screen.
|
|
14
|
+
{ label: "Approvals", path: :approvals_path, match: [ "/approvals" ] },
|
|
9
15
|
{ label: "Org", path: :org_path, match: [ "/org" ] },
|
|
10
16
|
{ label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
|
|
11
17
|
{ label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
|
|
@@ -71,6 +77,21 @@ module HrLite
|
|
|
71
77
|
out.html_safe
|
|
72
78
|
end
|
|
73
79
|
|
|
80
|
+
# Where an approval row actually leads. Each subject type has its own
|
|
81
|
+
# decision screen; the inbox only gathers them.
|
|
82
|
+
APPROVAL_PATHS = {
|
|
83
|
+
"HrLite::LeaveRequest" => :admin_leave_request_path,
|
|
84
|
+
"HrLite::CompOffRequest" => :admin_comp_off_request_path,
|
|
85
|
+
"HrLite::RegularizationRequest" => :admin_regularization_request_path
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
def hrl_approval_path(approval)
|
|
89
|
+
helper = APPROVAL_PATHS[approval.subject_type]
|
|
90
|
+
return nil unless helper && hr_lite_route?(helper)
|
|
91
|
+
|
|
92
|
+
hr_lite.public_send(helper, approval.subject_id)
|
|
93
|
+
end
|
|
94
|
+
|
|
74
95
|
def hrl_pagination
|
|
75
96
|
render "hr_lite/shared/pagination"
|
|
76
97
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Nudges approvals that have sat past their step's SLA. Escalation here
|
|
3
|
+
# means TELLING SOMEBODY, not reassigning: silently moving a decision to
|
|
4
|
+
# another person is how an approval ends up made by somebody who never saw
|
|
5
|
+
# the request.
|
|
6
|
+
#
|
|
7
|
+
# Each row is stamped once, so a daily run does not re-nag every day.
|
|
8
|
+
class ApprovalEscalationJob < ApplicationJob
|
|
9
|
+
def perform(now: Time.current)
|
|
10
|
+
overdue = Approval.pending.where(escalated_at: nil).includes(:step, :approver)
|
|
11
|
+
.select { |approval| approval.overdue?(now) }
|
|
12
|
+
return if overdue.empty?
|
|
13
|
+
|
|
14
|
+
overdue.group_by(&:approver_id).each do |approver_id, rows|
|
|
15
|
+
approver = rows.first.approver
|
|
16
|
+
next if approver.nil?
|
|
17
|
+
|
|
18
|
+
Notifications.publish(
|
|
19
|
+
"approval.escalated",
|
|
20
|
+
title: "#{rows.size} approval#{'s' unless rows.size == 1} waiting on you past its deadline",
|
|
21
|
+
lines: rows.map { |row| "#{row.label} — waiting #{waited(row, now)}" },
|
|
22
|
+
path: "/approvals",
|
|
23
|
+
bell_to: [ approver ],
|
|
24
|
+
email_to: [ approver ],
|
|
25
|
+
leadership: { title: "#{HrLite.display_name(approver)} has #{rows.size} overdue approval#{'s' unless rows.size == 1}" }
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Approval.where(id: overdue.map(&:id)).update_all(escalated_at: now) # rubocop:disable Rails/SkipsModelValidations
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def waited(approval, now)
|
|
35
|
+
hours = ((now - approval.created_at) / 1.hour).floor
|
|
36
|
+
hours < 48 ? "#{hours}h" : "#{(hours / 24)} days"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Opts a model into the routed, multi-step approval flow — WITHOUT taking
|
|
3
|
+
# over what its decisions mean.
|
|
4
|
+
#
|
|
5
|
+
# The model keeps its own `approve!` / `reject!`: those are the domain
|
|
6
|
+
# actions, and for leave one of them credits a balance inside a row lock.
|
|
7
|
+
# This concern only asks "is it this person's turn, and does their answer
|
|
8
|
+
# settle it?" and hands back an outcome for the model to act on.
|
|
9
|
+
#
|
|
10
|
+
# A subject type with no active flow behaves exactly as it did before —
|
|
11
|
+
# single decision, whoever holds the permission. That is what lets the four
|
|
12
|
+
# existing modules migrate one at a time instead of all at once.
|
|
13
|
+
module Approvable
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
has_many :approvals, as: :subject, class_name: "HrLite::Approval", dependent: :destroy
|
|
18
|
+
after_create_commit :open_approval_route
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def approval_route = @approval_route ||= ApprovalRoute.new(self)
|
|
22
|
+
|
|
23
|
+
def routed_for_approval? = approval_route.routed?
|
|
24
|
+
|
|
25
|
+
# The rows still waiting, newest rung first — what the show screen lists.
|
|
26
|
+
def pending_approvals = approvals.pending.order(:position)
|
|
27
|
+
|
|
28
|
+
def approval_for(user)
|
|
29
|
+
return nil if user.nil?
|
|
30
|
+
|
|
31
|
+
pending_approvals.detect { |approval| approval.answerable_by?(user) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether this person is the one being waited on. Unrouted subjects fall
|
|
35
|
+
# back to the permission check the module already used.
|
|
36
|
+
def awaiting?(user) = approval_for(user).present?
|
|
37
|
+
|
|
38
|
+
# The whole story, for the approval screen: who decided what, in order,
|
|
39
|
+
# including the rungs that were skipped and why.
|
|
40
|
+
def approval_history = approvals.order(:position, :id)
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def open_approval_route
|
|
45
|
+
approval_route.open! if approval_route.routed?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One person's outstanding decision on one record. The row is the unit the
|
|
3
|
+
# inbox lists and the escalation job reads.
|
|
4
|
+
class Approval < ApplicationRecord
|
|
5
|
+
STATUSES = %w[pending approved rejected returned skipped cancelled].freeze
|
|
6
|
+
|
|
7
|
+
belongs_to :step, class_name: "HrLite::ApprovalStep"
|
|
8
|
+
belongs_to :subject, polymorphic: true
|
|
9
|
+
belongs_to :approver, class_name: HrLite.config.user_class
|
|
10
|
+
belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
|
|
11
|
+
|
|
12
|
+
validates :status, inclusion: { in: STATUSES }
|
|
13
|
+
|
|
14
|
+
scope :pending, -> { where(status: "pending") }
|
|
15
|
+
scope :at_position, ->(position) { where(position: position) }
|
|
16
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
17
|
+
|
|
18
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
19
|
+
|
|
20
|
+
# Whether `user` may answer this — the approver themselves, or somebody
|
|
21
|
+
# they have delegated to while they are away.
|
|
22
|
+
def answerable_by?(user)
|
|
23
|
+
return false if user.nil? || !pending?
|
|
24
|
+
|
|
25
|
+
approver_id == user.id ||
|
|
26
|
+
ApprovalDelegation.stand_ins_for(approver_id).include?(user.id)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def decide!(status:, actor:, note: nil)
|
|
30
|
+
raise ArgumentError, "unknown decision #{status}" unless STATUSES.include?(status.to_s)
|
|
31
|
+
|
|
32
|
+
update!(status: status.to_s, note: note.presence, decided_at: Time.current,
|
|
33
|
+
decided_by_id: actor&.id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# True once this row's SLA has run out. Computed rather than stored so
|
|
37
|
+
# editing a step's SLA takes effect on the rows already waiting.
|
|
38
|
+
def overdue?(now = Time.current)
|
|
39
|
+
return false unless pending? && step.sla_hours
|
|
40
|
+
|
|
41
|
+
created_at + step.sla_hours.hours <= now
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def label = "#{subject_type.demodulize.underscore.humanize} ##{subject_id}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "I am away until the 14th — Priya decides for me." Delegation does not
|
|
3
|
+
# move the approval; it lets somebody else answer it, and the row records
|
|
4
|
+
# who actually did.
|
|
5
|
+
class ApprovalDelegation < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
belongs_to :from_user, class_name: HrLite.config.user_class
|
|
9
|
+
belongs_to :to_user, class_name: HrLite.config.user_class
|
|
10
|
+
|
|
11
|
+
validates :starts_on, :ends_on, presence: true
|
|
12
|
+
validate :ends_after_it_starts
|
|
13
|
+
validate :not_to_themselves
|
|
14
|
+
|
|
15
|
+
scope :live_on, ->(date) { where(starts_on: ..date).where(ends_on: date..) }
|
|
16
|
+
|
|
17
|
+
# Who may answer on `user`'s behalf today, including a chain of two —
|
|
18
|
+
# A delegates to B, B is also away and delegates to C. Capped at two
|
|
19
|
+
# hops: past that it is quicker to reassign the flow than to follow it.
|
|
20
|
+
def self.stand_ins_for(user_id, on: Date.current)
|
|
21
|
+
first = live_on(on).where(from_user_id: user_id).pluck(:to_user_id)
|
|
22
|
+
second = live_on(on).where(from_user_id: first).pluck(:to_user_id)
|
|
23
|
+
(first + second - [ user_id ]).uniq
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def ends_after_it_starts
|
|
29
|
+
return unless starts_on && ends_on
|
|
30
|
+
|
|
31
|
+
errors.add(:ends_on, "must be on or after the start date") if ends_on < starts_on
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def not_to_themselves
|
|
35
|
+
errors.add(:to_user_id, "cannot be the person delegating") if from_user_id == to_user_id
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "Leave needs the manager, then HR." One active flow per subject type;
|
|
3
|
+
# a type with no flow keeps whatever single-decision behaviour it had.
|
|
4
|
+
class ApprovalFlow < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
has_many :approval_steps, -> { order(:position) },
|
|
8
|
+
class_name: "HrLite::ApprovalStep", foreign_key: :flow_id, dependent: :destroy
|
|
9
|
+
accepts_nested_attributes_for :approval_steps, allow_destroy: true
|
|
10
|
+
|
|
11
|
+
validates :subject_type, :name, presence: true
|
|
12
|
+
validate :subject_type_is_approvable
|
|
13
|
+
|
|
14
|
+
scope :active, -> { where(active: true) }
|
|
15
|
+
|
|
16
|
+
def self.for(subject_type)
|
|
17
|
+
active.find_by(subject_type: subject_type.to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Subject types that have opted in. Declared rather than derived so a
|
|
21
|
+
# flow cannot be pointed at a model that has no idea it is approvable.
|
|
22
|
+
def self.approvable_types
|
|
23
|
+
%w[
|
|
24
|
+
HrLite::LeaveRequest HrLite::CompOffRequest
|
|
25
|
+
HrLite::RegularizationRequest HrLite::Resignation
|
|
26
|
+
].freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def subject_label = subject_type.demodulize.underscore.humanize
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def subject_type_is_approvable
|
|
34
|
+
return if self.class.approvable_types.include?(subject_type)
|
|
35
|
+
|
|
36
|
+
errors.add(:subject_type, "is not a type this engine can route for approval")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One rung. The approver is named by RULE, not by person, so a flow
|
|
3
|
+
# survives somebody leaving the company — the rule is resolved against the
|
|
4
|
+
# subject at the moment the request is raised.
|
|
5
|
+
class ApprovalStep < ApplicationRecord
|
|
6
|
+
RULES = %w[manager manager_of_manager permission user].freeze
|
|
7
|
+
|
|
8
|
+
belongs_to :flow, class_name: "HrLite::ApprovalFlow"
|
|
9
|
+
|
|
10
|
+
validates :position, presence: true, uniqueness: { scope: :flow_id }
|
|
11
|
+
validates :approver_rule, inclusion: { in: RULES }
|
|
12
|
+
validates :sla_hours, numericality: { greater_than: 0 }, allow_nil: true
|
|
13
|
+
validate :approver_key_matches_the_rule
|
|
14
|
+
|
|
15
|
+
scope :ordered, -> { order(:position) }
|
|
16
|
+
|
|
17
|
+
# Everybody who must (or may) decide this rung for `subject`. Empty means
|
|
18
|
+
# nobody fits — `ApprovalRoute` treats that as a rung to skip rather than
|
|
19
|
+
# a request nobody can ever answer.
|
|
20
|
+
# Array(...) rather than an `else` arm: `approver_rule` is held to the
|
|
21
|
+
# four RULES by a validation AND a database check constraint, so an
|
|
22
|
+
# unmatched `case` cannot happen — and a defensive branch nothing can
|
|
23
|
+
# reach is a line that never gets tested and quietly rots.
|
|
24
|
+
def approvers_for(subject)
|
|
25
|
+
Array(
|
|
26
|
+
case approver_rule
|
|
27
|
+
when "manager" then manager_of(subject.user_id)
|
|
28
|
+
when "manager_of_manager" then manager_of(manager_of(subject.user_id)&.id)
|
|
29
|
+
when "permission" then HrLite.users_holding(approver_key, scope: :all).to_a
|
|
30
|
+
when "user" then HrLite.user_klass.find_by(id: approver_key)
|
|
31
|
+
end
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def label
|
|
36
|
+
case approver_rule
|
|
37
|
+
when "manager" then "Their manager"
|
|
38
|
+
when "manager_of_manager" then "Their manager's manager"
|
|
39
|
+
when "permission" then "Anyone who can #{Permissions.description(approver_key).downcase}"
|
|
40
|
+
when "user" then HrLite.display_name(HrLite.user_klass.find_by(id: approver_key))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def manager_of(user_id)
|
|
47
|
+
return nil if user_id.nil?
|
|
48
|
+
|
|
49
|
+
manager_id = EmployeeProfile.where(user_id: user_id).pick(:manager_id)
|
|
50
|
+
manager_id && HrLite.user_klass.find_by(id: manager_id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def approver_key_matches_the_rule
|
|
54
|
+
case approver_rule
|
|
55
|
+
when "permission"
|
|
56
|
+
errors.add(:approver_key, "must be a known permission") unless Permissions.valid?(approver_key.to_s)
|
|
57
|
+
when "user"
|
|
58
|
+
errors.add(:approver_key, "must name a user") if approver_key.blank?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
class LeaveRequest < ApplicationRecord
|
|
3
|
+
include Approvable
|
|
4
|
+
|
|
3
5
|
STATUSES = %w[pending approved rejected cancelled].freeze
|
|
4
6
|
|
|
5
7
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
@@ -38,30 +40,23 @@ module HrLite
|
|
|
38
40
|
|
|
39
41
|
# --- transitions -------------------------------------------------------
|
|
40
42
|
|
|
43
|
+
# With a flow configured this is ONE RUNG: the request becomes `approved`
|
|
44
|
+
# only once the last rung is satisfied. With no flow — or from somebody
|
|
45
|
+
# the flow is not waiting on, such as HR overriding — the first approval
|
|
46
|
+
# settles it, exactly as before.
|
|
47
|
+
#
|
|
41
48
|
# Returns false (leaving the request pending) when the balance no longer
|
|
42
|
-
# covers it
|
|
49
|
+
# covers it; the re-check runs inside the row lock so two concurrent
|
|
43
50
|
# approvals cannot overdraw one balance.
|
|
44
51
|
def approve!(actor:, note: nil)
|
|
45
|
-
|
|
46
|
-
transition!("approved", actor, note) do
|
|
47
|
-
# Serialize on the BALANCE row. `transition!`'s own lock is on this
|
|
48
|
-
# request, and two pending requests are two different rows — so both
|
|
49
|
-
# approvals could read the same untouched balance and overdraw it.
|
|
50
|
-
LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)) unless leave_type.unlimited?
|
|
51
|
-
|
|
52
|
-
if insufficient_balance_now?
|
|
53
|
-
insufficient = true
|
|
54
|
-
raise ActiveRecord::Rollback
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
return false if insufficient
|
|
52
|
+
return record_routed_decision!(actor, note, :approved) if awaiting?(actor)
|
|
58
53
|
|
|
59
|
-
|
|
60
|
-
notify_team
|
|
61
|
-
true
|
|
54
|
+
approve_outright!(actor: actor, note: note)
|
|
62
55
|
end
|
|
63
56
|
|
|
64
57
|
def reject!(actor:, note:)
|
|
58
|
+
return record_routed_decision!(actor, note, :rejected) if awaiting?(actor)
|
|
59
|
+
|
|
65
60
|
transition!("rejected", actor, note)
|
|
66
61
|
notify_decision("Leave rejected")
|
|
67
62
|
true
|
|
@@ -86,6 +81,9 @@ module HrLite
|
|
|
86
81
|
# Cancelling an APPROVED leave hands the days back to the balance, so
|
|
87
82
|
# it is a quota change as much as a status change.
|
|
88
83
|
audit!("cancelled", actor, was_approved ? "was approved" : nil)
|
|
84
|
+
# And nobody should still be asked to decide a request that has been
|
|
85
|
+
# called off — it would sit in their inbox for ever.
|
|
86
|
+
approval_route.cancel_all!
|
|
89
87
|
end
|
|
90
88
|
|
|
91
89
|
Notifications.publish(
|
|
@@ -112,6 +110,63 @@ module HrLite
|
|
|
112
110
|
|
|
113
111
|
private
|
|
114
112
|
|
|
113
|
+
# One rung answered. The route says whether that settles the request; if
|
|
114
|
+
# it does, the ordinary transition runs and does the real work — crediting
|
|
115
|
+
# or releasing balance, notifying, auditing — so routing never becomes a
|
|
116
|
+
# second path that can drift from the first.
|
|
117
|
+
def record_routed_decision!(actor, note, intent)
|
|
118
|
+
approval = approval_for(actor)
|
|
119
|
+
result = approval_route.decide!(approval, status: intent.to_s, actor: actor, note: note)
|
|
120
|
+
|
|
121
|
+
case result.outcome
|
|
122
|
+
when :approved then approve_outright!(actor: actor, note: note)
|
|
123
|
+
when :rejected then reject_outright!(actor: actor, note: note.presence || "Rejected")
|
|
124
|
+
else
|
|
125
|
+
notify_pending_approvers
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def approve_outright!(actor:, note:)
|
|
131
|
+
insufficient = false
|
|
132
|
+
transition!("approved", actor, note) do
|
|
133
|
+
# Serialize on the BALANCE row. `transition!`'s own lock is on this
|
|
134
|
+
# request, and two pending requests are two different rows — so both
|
|
135
|
+
# approvals could read the same untouched balance and overdraw it.
|
|
136
|
+
LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)) unless leave_type.unlimited?
|
|
137
|
+
|
|
138
|
+
if insufficient_balance_now?
|
|
139
|
+
insufficient = true
|
|
140
|
+
raise ActiveRecord::Rollback
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
return false if insufficient
|
|
144
|
+
|
|
145
|
+
notify_decision("Leave approved")
|
|
146
|
+
notify_team
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def reject_outright!(actor:, note:)
|
|
151
|
+
transition!("rejected", actor, note)
|
|
152
|
+
notify_decision("Leave rejected")
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The next rung has just opened; tell the people it opened on.
|
|
157
|
+
def notify_pending_approvers
|
|
158
|
+
approvers = pending_approvals.map(&:approver).compact.uniq
|
|
159
|
+
return if approvers.empty?
|
|
160
|
+
|
|
161
|
+
Notifications.publish(
|
|
162
|
+
"leave.requested",
|
|
163
|
+
title: "#{HrLite.display_name(user)} — #{leave_type.name} (#{date_range_label}) needs your approval",
|
|
164
|
+
body: reason.presence,
|
|
165
|
+
path: "/admin/leave_requests/#{id}",
|
|
166
|
+
bell_to: approvers
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
115
170
|
def transition!(new_status, actor, note)
|
|
116
171
|
with_lock do
|
|
117
172
|
raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Drives one record through its flow: opens the first rung, advances when a
|
|
3
|
+
# rung is satisfied, and reports when the whole thing is settled.
|
|
4
|
+
#
|
|
5
|
+
# It decides NOTHING about the subject itself. `Approvable` owns what
|
|
6
|
+
# "approved" means for a leave request; this class only knows whose turn it
|
|
7
|
+
# is. That split is why adding expenses later needs no change here.
|
|
8
|
+
class ApprovalRoute
|
|
9
|
+
Result = Struct.new(:outcome, :approval, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def initialize(subject)
|
|
12
|
+
@subject = subject
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :subject
|
|
16
|
+
|
|
17
|
+
def flow = @flow ||= ApprovalFlow.for(subject.class.name)
|
|
18
|
+
|
|
19
|
+
def routed? = flow.present?
|
|
20
|
+
|
|
21
|
+
# Opens the first rung that has anybody on it. A rung whose rule resolves
|
|
22
|
+
# to nobody — no manager recorded, say — is SKIPPED rather than left
|
|
23
|
+
# waiting for a person who does not exist.
|
|
24
|
+
def open!
|
|
25
|
+
return Result.new(outcome: :unrouted) unless routed?
|
|
26
|
+
|
|
27
|
+
flow.approval_steps.ordered.each do |step|
|
|
28
|
+
approvers = step.approvers_for(subject)
|
|
29
|
+
next skip!(step) if approvers.empty?
|
|
30
|
+
|
|
31
|
+
create_rows(step, approvers)
|
|
32
|
+
return Result.new(outcome: :pending)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Every rung skipped: nobody in the flow can decide, so the request
|
|
36
|
+
# carries itself. Better than a request that can never be answered.
|
|
37
|
+
Result.new(outcome: :approved)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pending_rows = Approval.pending.where(subject: subject)
|
|
41
|
+
|
|
42
|
+
def current_position = pending_rows.minimum(:position)
|
|
43
|
+
|
|
44
|
+
# Records one decision and works out what it means for the record.
|
|
45
|
+
#
|
|
46
|
+
# :pending — the rung still needs somebody
|
|
47
|
+
# :approved — every rung is satisfied
|
|
48
|
+
# :rejected — one refusal ends it
|
|
49
|
+
# :returned — sent back to the requester for correction
|
|
50
|
+
def decide!(approval, status:, actor:, note: nil)
|
|
51
|
+
approval.decide!(status: status, actor: actor, note: note)
|
|
52
|
+
|
|
53
|
+
case status.to_s
|
|
54
|
+
when "rejected", "returned"
|
|
55
|
+
cancel_remaining!(approval.position)
|
|
56
|
+
Result.new(outcome: status.to_sym, approval: approval)
|
|
57
|
+
else
|
|
58
|
+
advance_from(approval)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cancel_all!
|
|
63
|
+
pending_rows.update_all(status: "cancelled", decided_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def advance_from(approval)
|
|
69
|
+
rung = Approval.where(subject: subject).at_position(approval.position)
|
|
70
|
+
# A unanimous rung waits for everybody; otherwise the first answer
|
|
71
|
+
# settles it and the rest are stood down.
|
|
72
|
+
if approval.step.unanimous && rung.pending.exists?
|
|
73
|
+
return Result.new(outcome: :pending, approval: approval)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
rung.pending.each { |other| other.decide!(status: "skipped", actor: nil) }
|
|
77
|
+
open_next_after(approval.position) || Result.new(outcome: :approved, approval: approval)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def open_next_after(position)
|
|
81
|
+
flow.approval_steps.ordered.each do |step|
|
|
82
|
+
next if step.position <= position
|
|
83
|
+
|
|
84
|
+
approvers = step.approvers_for(subject)
|
|
85
|
+
next skip!(step) if approvers.empty?
|
|
86
|
+
|
|
87
|
+
create_rows(step, approvers)
|
|
88
|
+
return Result.new(outcome: :pending)
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def create_rows(step, approvers)
|
|
94
|
+
approvers.uniq.each do |approver|
|
|
95
|
+
Approval.create!(subject: subject, step: step, position: step.position,
|
|
96
|
+
approver_id: approver.id)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Nothing to record: a rung nobody occupies never happened. Returning nil
|
|
101
|
+
# keeps `each`'s `next` readable at the call sites.
|
|
102
|
+
def skip!(_step) = nil
|
|
103
|
+
|
|
104
|
+
def cancel_remaining!(position)
|
|
105
|
+
Approval.pending.where(subject: subject).where("position >= ?", position)
|
|
106
|
+
.each { |row| row.decide!(status: "cancelled", actor: nil) }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Waiting on you" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Waiting on you</h1>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<% if @covering_for.any? %>
|
|
7
|
+
<section class="hrl-card">
|
|
8
|
+
<p class="hrl-small hrl-muted">
|
|
9
|
+
You are covering for
|
|
10
|
+
<%= @covering_for.map { |d| hr_display_name(d.from_user) }.to_sentence %>.
|
|
11
|
+
Their approvals appear below alongside your own.
|
|
12
|
+
</p>
|
|
13
|
+
</section>
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
<section class="hrl-card">
|
|
17
|
+
<% if @approvals.empty? %>
|
|
18
|
+
<p class="hrl-muted">Nothing is waiting on you.</p>
|
|
19
|
+
<% else %>
|
|
20
|
+
<div class="hrl-table-wrap">
|
|
21
|
+
<table class="hrl-table hrl-table--stack">
|
|
22
|
+
<thead><tr><th>Request</th><th>From</th><th>Waiting</th><th></th></tr></thead>
|
|
23
|
+
<tbody>
|
|
24
|
+
<% @approvals.each do |approval| %>
|
|
25
|
+
<% subject = approval.subject %>
|
|
26
|
+
<tr>
|
|
27
|
+
<td data-label="Request">
|
|
28
|
+
<strong><%= approval.label %></strong>
|
|
29
|
+
<% if approval.overdue? %>
|
|
30
|
+
<span class="hrl-badge hrl-badge--bad">Overdue</span>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% if approval.approver_id != hr_current_user.id %>
|
|
33
|
+
<span class="hrl-badge hrl-badge--muted">
|
|
34
|
+
for <%= hr_display_name(approval.approver) %>
|
|
35
|
+
</span>
|
|
36
|
+
<% end %>
|
|
37
|
+
</td>
|
|
38
|
+
<td data-label="From"><%= hr_display_name(subject&.user) %></td>
|
|
39
|
+
<td data-label="Waiting"><%= time_ago_in_words(approval.created_at) %></td>
|
|
40
|
+
<td data-label="">
|
|
41
|
+
<% if (path = hrl_approval_path(approval)) %>
|
|
42
|
+
<a class="hrl-small" href="<%= path %>">Open</a>
|
|
43
|
+
<% end %>
|
|
44
|
+
</td>
|
|
45
|
+
</tr>
|
|
46
|
+
<% end %>
|
|
47
|
+
</tbody>
|
|
48
|
+
</table>
|
|
49
|
+
</div>
|
|
50
|
+
<%= hrl_pagination %>
|
|
51
|
+
<% end %>
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
<% if @delegations.any? %>
|
|
55
|
+
<section class="hrl-card">
|
|
56
|
+
<h2 class="hrl-card__title">While you are away</h2>
|
|
57
|
+
<ul>
|
|
58
|
+
<% @delegations.each do |delegation| %>
|
|
59
|
+
<li class="hrl-small">
|
|
60
|
+
<%= hr_display_name(delegation.to_user) %> decides for you,
|
|
61
|
+
<%= delegation.starts_on.strftime("%d %b") %> – <%= delegation.ends_on.strftime("%d %b %Y") %>
|
|
62
|
+
</li>
|
|
63
|
+
<% end %>
|
|
64
|
+
</ul>
|
|
65
|
+
</section>
|
|
66
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -19,6 +19,7 @@ HrLite::Engine.routes.draw do
|
|
|
19
19
|
resources :regularization_requests, only: %i[index new create] do
|
|
20
20
|
member { post :cancel }
|
|
21
21
|
end
|
|
22
|
+
resources :approvals, only: :index
|
|
22
23
|
get "team", to: "team#show"
|
|
23
24
|
get "org", to: "org#show"
|
|
24
25
|
resources :holidays, only: :index
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
class CreateHrLiteApprovals < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Leave, comp-off, regularization and resignation each grew their own
|
|
3
|
+
# approve/reject/cancel, with their own transition guard and their own
|
|
4
|
+
# notification. Four copies of one idea, and every module still to come
|
|
5
|
+
# (expenses, loans, salary revisions, document verification) would have
|
|
6
|
+
# been a fifth.
|
|
7
|
+
#
|
|
8
|
+
# A FLOW is the definition — "leave needs the manager, then HR". A STEP is
|
|
9
|
+
# one rung of it, naming an approver by RULE rather than by person, so the
|
|
10
|
+
# flow survives somebody leaving. An APPROVAL is one live decision on one
|
|
11
|
+
# record.
|
|
12
|
+
def change
|
|
13
|
+
create_table :hr_lite_approval_flows do |t|
|
|
14
|
+
# The model this flow decides on, e.g. "HrLite::LeaveRequest".
|
|
15
|
+
t.string :subject_type, null: false
|
|
16
|
+
t.string :name, null: false
|
|
17
|
+
t.boolean :active, null: false, default: true
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
add_index :hr_lite_approval_flows, :subject_type,
|
|
21
|
+
unique: true, where: "active", name: "index_hr_lite_approval_flows_one_active_per_type"
|
|
22
|
+
|
|
23
|
+
create_table :hr_lite_approval_steps do |t|
|
|
24
|
+
t.references :flow, null: false, index: false,
|
|
25
|
+
foreign_key: { to_table: :hr_lite_approval_flows, on_delete: :cascade }
|
|
26
|
+
t.integer :position, null: false
|
|
27
|
+
# manager | manager_of_manager | permission | user
|
|
28
|
+
t.string :approver_rule, null: false
|
|
29
|
+
# permission key for `permission`, user id for `user`, unused otherwise
|
|
30
|
+
t.string :approver_key
|
|
31
|
+
# Everybody on this rung must decide, rather than the first to answer.
|
|
32
|
+
t.boolean :unanimous, null: false, default: false
|
|
33
|
+
# Hours before the step is escalated. Null = never.
|
|
34
|
+
t.integer :sla_hours
|
|
35
|
+
t.timestamps
|
|
36
|
+
end
|
|
37
|
+
add_index :hr_lite_approval_steps, %i[flow_id position], unique: true
|
|
38
|
+
add_check_constraint :hr_lite_approval_steps,
|
|
39
|
+
"approver_rule IN ('manager', 'manager_of_manager', 'permission', 'user')",
|
|
40
|
+
name: "hr_lite_approval_steps_rule_check"
|
|
41
|
+
|
|
42
|
+
create_table :hr_lite_approvals do |t|
|
|
43
|
+
t.string :subject_type, null: false
|
|
44
|
+
t.bigint :subject_id, null: false
|
|
45
|
+
t.references :step, null: false, index: false,
|
|
46
|
+
foreign_key: { to_table: :hr_lite_approval_steps, on_delete: :cascade }
|
|
47
|
+
t.integer :position, null: false
|
|
48
|
+
t.bigint :approver_id, null: false
|
|
49
|
+
# pending | approved | rejected | returned | skipped | cancelled
|
|
50
|
+
t.string :status, null: false, default: "pending"
|
|
51
|
+
t.text :note
|
|
52
|
+
t.datetime :decided_at
|
|
53
|
+
# Who decided in the approver's place, when they had delegated.
|
|
54
|
+
t.bigint :decided_by_id
|
|
55
|
+
t.datetime :escalated_at
|
|
56
|
+
t.timestamps
|
|
57
|
+
end
|
|
58
|
+
add_index :hr_lite_approvals, %i[subject_type subject_id position]
|
|
59
|
+
# One live row per approver per rung: two would let the same person
|
|
60
|
+
# decide twice, or a retry double-count a unanimous step.
|
|
61
|
+
add_index :hr_lite_approvals, %i[subject_type subject_id position approver_id],
|
|
62
|
+
unique: true, name: "index_hr_lite_approvals_one_per_approver_per_step"
|
|
63
|
+
add_index :hr_lite_approvals, :approver_id
|
|
64
|
+
add_check_constraint :hr_lite_approvals,
|
|
65
|
+
"status IN ('pending', 'approved', 'rejected', 'returned', 'skipped', 'cancelled')",
|
|
66
|
+
name: "hr_lite_approvals_status_check"
|
|
67
|
+
|
|
68
|
+
create_table :hr_lite_approval_delegations do |t|
|
|
69
|
+
t.bigint :from_user_id, null: false
|
|
70
|
+
t.bigint :to_user_id, null: false
|
|
71
|
+
t.date :starts_on, null: false
|
|
72
|
+
t.date :ends_on, null: false
|
|
73
|
+
t.string :reason
|
|
74
|
+
t.timestamps
|
|
75
|
+
end
|
|
76
|
+
add_index :hr_lite_approval_delegations, %i[from_user_id starts_on ends_on],
|
|
77
|
+
name: "index_hr_lite_delegations_on_from_and_dates"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -15,11 +15,6 @@ HrLite.configure do |c|
|
|
|
15
15
|
# HrLite::RoleAssignment.create!(
|
|
16
16
|
# user_id: User.find_by!(email: "you@example.com").id,
|
|
17
17
|
# role: HrLite::Role.find_by!(name: HrLite::Role::SUPER_ADMIN))
|
|
18
|
-
#
|
|
19
|
-
# Upgrading from pre-0.6.0 and not ready to move? Uncomment this and keep
|
|
20
|
-
# your leadership_emails / superadmin_emails / admin_check as they were.
|
|
21
|
-
# It is honoured until 0.8.0.
|
|
22
|
-
# c.legacy_tier_checks = true
|
|
23
18
|
|
|
24
19
|
# Where the portal is reachable (subdomain or path). Enables email link
|
|
25
20
|
# buttons and HrLite.public_url / HrLite.public_url? for deep links.
|
|
@@ -7,11 +7,9 @@ module HrLite
|
|
|
7
7
|
:authenticate_method, :admin_check, :display_name_method,
|
|
8
8
|
:employees_scope, :mentionable_users, :notify, :render_pdf, :company,
|
|
9
9
|
:time_zone, :currency_symbol, :on_designation_change,
|
|
10
|
-
:leadership_emails, :
|
|
11
|
-
:superadmin_emails, :superadmin_check,
|
|
10
|
+
:leadership_emails, :extra_stylesheets, :superadmin_emails,
|
|
12
11
|
:mailer_from, :public_url_base, :notification_matrix, :back_link,
|
|
13
|
-
:onboard_user, :offboard_user, :invite_url_for
|
|
14
|
-
:legacy_tier_checks
|
|
12
|
+
:onboard_user, :offboard_user, :invite_url_for
|
|
15
13
|
|
|
16
14
|
attr_reader :leave_year_start_month
|
|
17
15
|
|
|
@@ -46,21 +44,20 @@ module HrLite
|
|
|
46
44
|
@time_zone = "Asia/Kolkata"
|
|
47
45
|
@currency_symbol = "₹"
|
|
48
46
|
@on_designation_change = ->(user, designation) { }
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
47
|
+
# --- pre-0.6.0 access, now MIGRATION INPUT ONLY ------------------------
|
|
48
|
+
#
|
|
49
|
+
# These three governed access until 0.6.0, and two of them matched the
|
|
50
|
+
# user's EMAIL — a mutable, host-owned, unverified column that every
|
|
51
|
+
# host then had to remember never to let anybody edit. They decide
|
|
52
|
+
# nothing now: `HrLite.admin?` and friends read roles.
|
|
53
|
+
#
|
|
54
|
+
# They are still read in exactly one place — the 0.6.0 upgrade
|
|
55
|
+
# migration, which derives role assignments from whatever a 0.5.x host
|
|
56
|
+
# had configured. An install upgrading across several versions at once
|
|
57
|
+
# needs them present for that migration to have anything to read, which
|
|
58
|
+
# is why they were not deleted outright.
|
|
55
59
|
@leadership_emails = []
|
|
56
|
-
@leadership_check = ->(user) { HrLite.email_listed?(user, HrLite.config.leadership_emails) }
|
|
57
|
-
# Money tier: salary structures, payroll, slips, appraisals. Empty
|
|
58
|
-
# list means "same as leadership" (pre-0.5.0 behaviour).
|
|
59
60
|
@superadmin_emails = []
|
|
60
|
-
@superadmin_check = ->(user) do
|
|
61
|
-
list = HrLite.normalize_email_list(HrLite.config.superadmin_emails)
|
|
62
|
-
list.empty? ? HrLite.leadership?(user) : HrLite.email_listed?(user, list)
|
|
63
|
-
end
|
|
64
61
|
@extra_stylesheets = [] # host stylesheets linked AFTER hr_lite.css (CSS-var overrides)
|
|
65
62
|
@mailer_from = "hr@example.com"
|
|
66
63
|
@public_url_base = nil # e.g. "https://hr.example.com" — enables email links + HrLite.public_url
|
|
@@ -24,6 +24,9 @@ module HrLite
|
|
|
24
24
|
"appraisal.shared" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
25
25
|
"promotion.recorded" => { bell: true, email: true, leadership_email: true, leadership_bell: true },
|
|
26
26
|
"policy.changed" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
|
|
27
|
+
# An approval past its deadline. Leadership hears too — a decision
|
|
28
|
+
# nobody is making is a process problem, not just one person's inbox.
|
|
29
|
+
"approval.escalated" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
27
30
|
"digest.daily" => { bell: false, email: false, leadership_email: true, leadership_bell: false },
|
|
28
31
|
"resignation.submitted" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
|
|
29
32
|
"resignation.accepted" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
data/lib/hr_lite/version.rb
CHANGED
data/lib/hr_lite.rb
CHANGED
|
@@ -51,62 +51,43 @@ module HrLite
|
|
|
51
51
|
|
|
52
52
|
def access_for(user) = Access.for(user)
|
|
53
53
|
|
|
54
|
-
# ---
|
|
54
|
+
# --- tier predicates ----------------------------------------------------
|
|
55
55
|
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
56
|
+
# Shorthands over the permissions, kept because views, hosts and the
|
|
57
|
+
# notification fan-out all read better asking "is this person leadership"
|
|
58
|
+
# than "do they hold profile.manage at all scope". They are ROLES all the
|
|
59
|
+
# way down; the pre-0.6.0 lambdas no longer decide anything.
|
|
60
60
|
|
|
61
61
|
def admin?(user)
|
|
62
62
|
return false if user.blank?
|
|
63
|
-
return !!config.admin_check.call(user) if config.legacy_tier_checks
|
|
64
63
|
|
|
65
64
|
can?(user, "leave.approve", scope: :all) || can?(user, "attendance.manage", scope: :all)
|
|
66
65
|
end
|
|
67
66
|
|
|
68
67
|
def leadership?(user)
|
|
69
68
|
return false if user.blank?
|
|
70
|
-
return !!config.leadership_check.call(user) if config.legacy_tier_checks
|
|
71
69
|
|
|
72
70
|
can?(user, "profile.manage", scope: :all)
|
|
73
71
|
end
|
|
74
72
|
|
|
75
73
|
def superadmin?(user)
|
|
76
74
|
return false if user.blank?
|
|
77
|
-
return !!config.superadmin_check.call(user) if config.legacy_tier_checks
|
|
78
75
|
|
|
79
76
|
can?(user, "payroll.manage", scope: :all)
|
|
80
77
|
end
|
|
81
78
|
|
|
82
|
-
# An access list, cleaned
|
|
83
|
-
# comma in an ENV var
|
|
84
|
-
# email was blank then MATCHED it and was handed
|
|
79
|
+
# An access list, cleaned — read now only by the 0.6.0 upgrade migration.
|
|
80
|
+
# One stray comma in an ENV var ("a@x.com,,b@x.com") used to put "" in the
|
|
81
|
+
# list, and a user whose email was blank then MATCHED it and was handed
|
|
82
|
+
# the tier. Kept honest here so the migration cannot repeat that.
|
|
85
83
|
def normalize_email_list(emails)
|
|
86
84
|
Array(emails).map { |e| e.to_s.downcase.strip }.reject(&:empty?)
|
|
87
85
|
end
|
|
88
86
|
|
|
89
|
-
# Whether a user's own email is on a configured access list. A blank
|
|
90
|
-
# email is never on any list, however the list is spelled.
|
|
91
|
-
def email_listed?(user, emails)
|
|
92
|
-
address = user.respond_to?(:email) ? user.email.to_s.downcase.strip : ""
|
|
93
|
-
return false if address.empty?
|
|
94
|
-
|
|
95
|
-
normalize_email_list(emails).include?(address)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
87
|
# Leadership members resolvable to actual user records (for bell
|
|
99
|
-
# notifications).
|
|
100
|
-
# user table are still reachable by email — see Notifications.
|
|
88
|
+
# notifications).
|
|
101
89
|
def leadership_users
|
|
102
|
-
|
|
103
|
-
return users_holding("profile.manage", scope: :all)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
emails = normalize_email_list(config.leadership_emails)
|
|
107
|
-
return user_klass.none if emails.empty?
|
|
108
|
-
|
|
109
|
-
user_klass.where("LOWER(#{user_klass.table_name}.email) IN (?)", emails)
|
|
90
|
+
users_holding("profile.manage", scope: :all)
|
|
110
91
|
end
|
|
111
92
|
|
|
112
93
|
# Everyone whose roles grant `key` at `scope` or wider. One query over
|
|
@@ -121,13 +102,9 @@ module HrLite
|
|
|
121
102
|
user_klass.where(id: ids)
|
|
122
103
|
end
|
|
123
104
|
|
|
124
|
-
# Every domain event that bells the admins calls this.
|
|
125
|
-
#
|
|
126
|
-
# host's lambda per row, which is why it starts from employees_scope
|
|
127
|
-
# (narrowed to real staff) rather than every row that exists.
|
|
105
|
+
# Every domain event that bells the admins calls this. One query over the
|
|
106
|
+
# grant tables — it used to instantiate every employee and ask each one.
|
|
128
107
|
def admin_users
|
|
129
|
-
return employees.select { |u| admin?(u) } if config.legacy_tier_checks
|
|
130
|
-
|
|
131
108
|
users_holding("leave.approve", scope: :all).sort_by { |u| display_name(u).downcase }
|
|
132
109
|
end
|
|
133
110
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hr_lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -81,6 +81,7 @@ files:
|
|
|
81
81
|
- app/controllers/hr_lite/admin/superadmin_controller.rb
|
|
82
82
|
- app/controllers/hr_lite/application_controller.rb
|
|
83
83
|
- app/controllers/hr_lite/appraisals_controller.rb
|
|
84
|
+
- app/controllers/hr_lite/approvals_controller.rb
|
|
84
85
|
- app/controllers/hr_lite/attendance_controller.rb
|
|
85
86
|
- app/controllers/hr_lite/calendar_controller.rb
|
|
86
87
|
- app/controllers/hr_lite/career_controller.rb
|
|
@@ -99,15 +100,21 @@ files:
|
|
|
99
100
|
- app/controllers/hr_lite/users_controller.rb
|
|
100
101
|
- app/helpers/hr_lite/application_helper.rb
|
|
101
102
|
- app/jobs/hr_lite/application_job.rb
|
|
103
|
+
- app/jobs/hr_lite/approval_escalation_job.rb
|
|
102
104
|
- app/jobs/hr_lite/daily_digest_job.rb
|
|
103
105
|
- app/jobs/hr_lite/leave_year_rollover_job.rb
|
|
104
106
|
- app/jobs/hr_lite/payroll_auto_draft_job.rb
|
|
105
107
|
- app/mailers/hr_lite/application_mailer.rb
|
|
106
108
|
- app/mailers/hr_lite/event_mailer.rb
|
|
109
|
+
- app/models/concerns/hr_lite/approvable.rb
|
|
107
110
|
- app/models/concerns/hr_lite/audited.rb
|
|
108
111
|
- app/models/concerns/hr_lite/encrypted_money.rb
|
|
109
112
|
- app/models/hr_lite/application_record.rb
|
|
110
113
|
- app/models/hr_lite/appraisal.rb
|
|
114
|
+
- app/models/hr_lite/approval.rb
|
|
115
|
+
- app/models/hr_lite/approval_delegation.rb
|
|
116
|
+
- app/models/hr_lite/approval_flow.rb
|
|
117
|
+
- app/models/hr_lite/approval_step.rb
|
|
111
118
|
- app/models/hr_lite/attendance_record.rb
|
|
112
119
|
- app/models/hr_lite/audit_log.rb
|
|
113
120
|
- app/models/hr_lite/comp_off_request.rb
|
|
@@ -132,6 +139,7 @@ files:
|
|
|
132
139
|
- app/models/hr_lite/setting.rb
|
|
133
140
|
- app/models/hr_lite/statutory_rate_card_record.rb
|
|
134
141
|
- app/services/hr_lite/access.rb
|
|
142
|
+
- app/services/hr_lite/approval_route.rb
|
|
135
143
|
- app/services/hr_lite/attendance_puncher.rb
|
|
136
144
|
- app/services/hr_lite/attendance_summary.rb
|
|
137
145
|
- app/services/hr_lite/calculators/esi.rb
|
|
@@ -195,6 +203,7 @@ files:
|
|
|
195
203
|
- app/views/hr_lite/admin/statutory_rate_cards/new.html.erb
|
|
196
204
|
- app/views/hr_lite/appraisals/index.html.erb
|
|
197
205
|
- app/views/hr_lite/appraisals/show.html.erb
|
|
206
|
+
- app/views/hr_lite/approvals/index.html.erb
|
|
198
207
|
- app/views/hr_lite/attendance/_month_grid.html.erb
|
|
199
208
|
- app/views/hr_lite/attendance/_punch_card.html.erb
|
|
200
209
|
- app/views/hr_lite/attendance/show.html.erb
|
|
@@ -263,6 +272,7 @@ files:
|
|
|
263
272
|
- db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb
|
|
264
273
|
- db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb
|
|
265
274
|
- db/migrate/20260817190159_create_hr_lite_statutory_rate_cards.rb
|
|
275
|
+
- db/migrate/20260818001151_create_hr_lite_approvals.rb
|
|
266
276
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
267
277
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
268
278
|
- lib/generators/hr_lite/install/templates/initializer.rb
|