hr_lite 0.3.0 → 0.5.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 +55 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +18 -0
- data/app/controllers/hr_lite/admin/appraisals_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/designation_changes_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/salary_structures_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +16 -0
- data/app/controllers/hr_lite/application_controller.rb +5 -1
- data/app/controllers/hr_lite/leave_balances_controller.rb +1 -1
- data/app/controllers/hr_lite/leave_requests_controller.rb +1 -1
- data/app/controllers/hr_lite/org_controller.rb +51 -0
- data/app/helpers/hr_lite/application_helper.rb +6 -1
- data/app/jobs/hr_lite/leave_year_rollover_job.rb +18 -6
- data/app/models/hr_lite/comp_off_request.rb +6 -6
- data/app/models/hr_lite/employee_profile.rb +76 -0
- data/app/models/hr_lite/leave_balance.rb +43 -10
- data/app/models/hr_lite/leave_request.rb +5 -5
- data/app/models/hr_lite/setting.rb +2 -0
- data/app/views/hr_lite/admin/employees/_form.html.erb +17 -1
- data/app/views/hr_lite/admin/employees/index.html.erb +3 -0
- data/app/views/hr_lite/admin/employees/show.html.erb +10 -3
- data/app/views/hr_lite/admin/leave_balances/index.html.erb +1 -1
- data/app/views/hr_lite/admin/settings/edit.html.erb +7 -0
- data/app/views/hr_lite/employee_profiles/show.html.erb +3 -0
- data/app/views/hr_lite/leave_balances/index.html.erb +1 -1
- data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +2 -2
- data/app/views/hr_lite/org/_node.html.erb +14 -0
- data/app/views/hr_lite/org/show.html.erb +38 -0
- data/app/views/layouts/hr_lite/application.html.erb +5 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260719162154_add_manager_to_hr_lite_employee_profiles.rb +8 -0
- data/db/migrate/20260720094454_add_employee_code_prefix_to_hr_lite_settings.rb +7 -0
- data/lib/hr_lite/configuration.rb +22 -0
- data/lib/hr_lite/leave_year.rb +33 -0
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +5 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00cc5c346c88ddc803a5336a1c14a07dd4609fc887213f99e9b11654f26fa109
|
|
4
|
+
data.tar.gz: 8306411151a38ea5b1fe0fe9c7b4bd3572e16917a1defc1e15d4e823ab7f9c1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f9be2057c4023881d956fc8feadb18d2917bb38cd92746b7a448a60fedea0fc94d335b22d36a627a8ce888e36a7520ca6118ad77e6f5956b96fdec695f813dc
|
|
7
|
+
data.tar.gz: 95c8048ecd82dc4b92d9e4e5b79240bed1385e297206383280c76e010d141f666e57eb150eac9defdbe556978226d7b4a5fff21ff8132103e04e09862a36f3ad
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-07-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Superadmin (money) tier** — `config.superadmin_emails`: only these
|
|
15
|
+
people reach salary structures, payroll runs, slips administration,
|
|
16
|
+
appraisals and promotions, or see salary/appraisal data on the
|
|
17
|
+
employee page and the Payroll nav item. Ordinary leadership keeps
|
|
18
|
+
governing people and policy. Empty list (default) = leadership keeps
|
|
19
|
+
the money tier, exactly as before.
|
|
20
|
+
- **System-assigned employee codes**: prefix (Settings, default "EMP")
|
|
21
|
+
+ zero-padded sequence — EMP001, EMP002, … Forms no longer accept a
|
|
22
|
+
code; changing the prefix starts a fresh sequence; explicitly-set
|
|
23
|
+
codes (imports/seeds) are never overwritten.
|
|
24
|
+
- "New employee" button on the Employees screen.
|
|
25
|
+
|
|
26
|
+
## [0.4.0] - 2026-07-19
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **Org chart (`/org`)**: everyone-visible reporting tree (who reports to
|
|
31
|
+
whom, names + designations + departments only — never salary or private
|
|
32
|
+
data) plus each viewer's own reporting line labelled L1/L2/... Managers
|
|
33
|
+
are set per employee by leadership ("Reports to"); reporting loops are
|
|
34
|
+
rejected.
|
|
35
|
+
- **Configurable leave year** (`config.leave_year_start_month`, default 1):
|
|
36
|
+
set 7 for a July–June leave year. Balances, accrual, the year-boundary
|
|
37
|
+
split rule, carry-forward rollover and comp-off credits all follow it.
|
|
38
|
+
Balance headings show "2026–27"-style labels for non-calendar years.
|
|
39
|
+
- **Joining-date proration, Keka-style**: entitlement accrues only from
|
|
40
|
+
the month someone joins (joined on/before the 15th → that month counts;
|
|
41
|
+
after → from the next month). Applies to monthly accrual AND
|
|
42
|
+
yearly-upfront grants (upfront = remaining months × monthly rate).
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- `LeaveBalance#year` is now a LeaveYear key; `LeaveYearRolloverJob`
|
|
47
|
+
defaults to the current leave year in the configured HR time zone
|
|
48
|
+
(schedule it on the leave year's first day). The request-split
|
|
49
|
+
validation message says "leave-year boundary".
|
|
50
|
+
|
|
51
|
+
### Upgrade notes
|
|
52
|
+
|
|
53
|
+
- **Set `leave_year_start_month` once, at install time.** Balance rows
|
|
54
|
+
are keyed by leave year with no stored epoch — changing the start
|
|
55
|
+
month on an install with existing balances silently reinterprets
|
|
56
|
+
every row (carry, adjustments, comp-off credits) and miskeys
|
|
57
|
+
historical requests. The setter validates 1..12 and accepts "7".
|
|
58
|
+
- **Joining-date proration applies to existing data.** Employees who
|
|
59
|
+
joined partway through the CURRENT year previously showed the full
|
|
60
|
+
year's accrual; from 0.4.0 they accrue only from their joining month,
|
|
61
|
+
so their entitlement can drop (and, if they already used more, go
|
|
62
|
+
negative). Where the old number was intended, add a one-off balance
|
|
63
|
+
adjustment with a note.
|
|
64
|
+
|
|
10
65
|
## [0.3.0] - 2026-07-19
|
|
11
66
|
|
|
12
67
|
### Added
|
|
@@ -241,6 +241,24 @@
|
|
|
241
241
|
.hrl-pager { display: flex; align-items: center; justify-content: center; gap: .8rem; margin: 1rem 0; font-size: .88rem; }
|
|
242
242
|
.hrl-pager__info { color: var(--hrl-muted); }
|
|
243
243
|
|
|
244
|
+
/* Org chart tree + reporting-line chain */
|
|
245
|
+
.hrl-tree { list-style: none; margin: 0; padding-left: 0; }
|
|
246
|
+
.hrl-tree .hrl-tree { padding-left: 1.1rem; border-left: 2px solid var(--hrl-line); margin-left: .55rem; }
|
|
247
|
+
.hrl-tree__node { margin: .3rem 0; }
|
|
248
|
+
.hrl-tree__card {
|
|
249
|
+
display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: .45rem;
|
|
250
|
+
background: var(--hrl-bg); border: 1px solid var(--hrl-line);
|
|
251
|
+
border-radius: var(--hrl-radius-sm); padding: .4rem .7rem; max-width: 100%;
|
|
252
|
+
}
|
|
253
|
+
.hrl-tree__card--me { border-color: var(--hrl-accent); background: var(--hrl-info-bg); }
|
|
254
|
+
.hrl-tree__name, .hrl-tree__role { min-width: 0; overflow-wrap: anywhere; }
|
|
255
|
+
.hrl-tree__name { font-weight: 650; font-size: .9rem; }
|
|
256
|
+
.hrl-tree__role { color: var(--hrl-muted); font-size: .8rem; }
|
|
257
|
+
.hrl-chainrow { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
|
258
|
+
.hrl-chainrow__step { display: inline-flex; align-items: center; gap: .4rem; min-width: 0; }
|
|
259
|
+
.hrl-chainrow .hrl-badge { white-space: normal; overflow-wrap: anywhere; }
|
|
260
|
+
.hrl-chainrow__arrow { color: var(--hrl-muted); }
|
|
261
|
+
|
|
244
262
|
/* Definition list */
|
|
245
263
|
.hrl-deflist { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: .35rem .9rem; font-size: .9rem; }
|
|
246
264
|
.hrl-deflist dt { color: var(--hrl-muted); }
|
|
@@ -93,7 +93,7 @@ module HrLite
|
|
|
93
93
|
|
|
94
94
|
def profile_params
|
|
95
95
|
params.require(:employee_profile).permit(
|
|
96
|
-
:user_id, :
|
|
96
|
+
:user_id, :manager_id, :designation, :date_of_birth, :date_of_joining,
|
|
97
97
|
:date_of_exit, :department, :work_location, :pan_number, :pf_uan, :esi_number,
|
|
98
98
|
:bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions
|
|
99
99
|
)
|
|
@@ -3,7 +3,7 @@ module HrLite
|
|
|
3
3
|
class LeaveBalancesController < BaseController
|
|
4
4
|
def index
|
|
5
5
|
year = params[:year].to_i
|
|
6
|
-
@year = year.between?(2000, 2100) ? year :
|
|
6
|
+
@year = year.between?(2000, 2100) ? year : LeaveYear.current_key
|
|
7
7
|
@types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
|
|
8
8
|
@employees = HrLite.employees
|
|
9
9
|
end
|
|
@@ -7,7 +7,7 @@ module HrLite
|
|
|
7
7
|
|
|
8
8
|
def update
|
|
9
9
|
@setting = Setting.instance
|
|
10
|
-
if @setting.update(params.require(:setting).permit(:weekend_policy))
|
|
10
|
+
if @setting.update(params.require(:setting).permit(:weekend_policy, :employee_code_prefix))
|
|
11
11
|
redirect_to edit_admin_setting_path, notice: "Settings saved."
|
|
12
12
|
else
|
|
13
13
|
render :edit, status: :unprocessable_entity
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Money tier: salary structures, payroll, slips, appraisals and
|
|
4
|
+
# promotions. Only config.superadmin_emails — ordinary leadership can
|
|
5
|
+
# govern policy and people but never sees another person's pay.
|
|
6
|
+
class SuperadminController < LeadershipController
|
|
7
|
+
before_action :require_hr_superadmin!
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def require_hr_superadmin!
|
|
12
|
+
hr_access_denied unless hr_superadmin?
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -10,7 +10,7 @@ module HrLite
|
|
|
10
10
|
before_action :hr_set_current_actor
|
|
11
11
|
around_action :hr_use_time_zone
|
|
12
12
|
|
|
13
|
-
helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_display_name
|
|
13
|
+
helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
@@ -26,6 +26,10 @@ module HrLite
|
|
|
26
26
|
HrLite.admin?(hr_current_user)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def hr_superadmin?
|
|
30
|
+
HrLite.superadmin?(hr_current_user)
|
|
31
|
+
end
|
|
32
|
+
|
|
29
33
|
def hr_leadership?
|
|
30
34
|
HrLite.leadership?(hr_current_user)
|
|
31
35
|
end
|
|
@@ -2,7 +2,7 @@ module HrLite
|
|
|
2
2
|
class LeaveBalancesController < ApplicationController
|
|
3
3
|
def index
|
|
4
4
|
year = params[:year].to_i
|
|
5
|
-
@year = year.between?(2000, 2100) ? year :
|
|
5
|
+
@year = year.between?(2000, 2100) ? year : LeaveYear.current_key
|
|
6
6
|
@balances = LeaveType.active.where(paid: true).where.not(annual_quota: nil).map do |type|
|
|
7
7
|
LeaveBalance.for(hr_current_user, type, @year)
|
|
8
8
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The everyone-visible org chart: who reports to whom, plus the viewer's
|
|
3
|
+
# own reporting line labelled L1/L2/... Only names, designations and
|
|
4
|
+
# departments — never salary, identity numbers or any other private data.
|
|
5
|
+
class OrgController < ApplicationController
|
|
6
|
+
def show
|
|
7
|
+
profiles = EmployeeProfile.includes(:user)
|
|
8
|
+
.where("date_of_exit IS NULL OR date_of_exit >= ?", Date.current)
|
|
9
|
+
.to_a
|
|
10
|
+
@by_user = profiles.index_by(&:user_id)
|
|
11
|
+
build_tree(profiles)
|
|
12
|
+
@own_profile = @by_user[hr_current_user.id]
|
|
13
|
+
exited = EmployeeProfile.where(date_of_exit: ...Date.current).pluck(:user_id).to_set
|
|
14
|
+
@own_chain = (@own_profile&.reporting_chain || []).reject { |boss| exited.include?(boss.id) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# BFS from the natural roots, then promote anything unreached (a
|
|
20
|
+
# manager cycle that slipped past validation must degrade to extra
|
|
21
|
+
# roots, not silently vanish). @tree_children only ever contains
|
|
22
|
+
# forward edges, so the recursive partial always terminates and every
|
|
23
|
+
# active profile renders exactly once.
|
|
24
|
+
def build_tree(profiles)
|
|
25
|
+
children = profiles.group_by(&:manager_id)
|
|
26
|
+
@roots = profiles.select { |p| p.manager_id.nil? || !@by_user.key?(p.manager_id) }
|
|
27
|
+
.sort_by { |p| HrLite.display_name(p.user).downcase }
|
|
28
|
+
@tree_children = Hash.new { |hash, key| hash[key] = [] }
|
|
29
|
+
visited = {}
|
|
30
|
+
walk = lambda do |node|
|
|
31
|
+
(children[node.user_id] || []).each do |child|
|
|
32
|
+
next if visited[child.user_id]
|
|
33
|
+
|
|
34
|
+
visited[child.user_id] = true
|
|
35
|
+
@tree_children[node.user_id] << child
|
|
36
|
+
walk.call(child)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
@roots.each { |root| visited[root.user_id] = true }
|
|
40
|
+
@roots.each { |root| walk.call(root) }
|
|
41
|
+
|
|
42
|
+
profiles.sort_by(&:id).each do |profile|
|
|
43
|
+
next if visited[profile.user_id]
|
|
44
|
+
|
|
45
|
+
visited[profile.user_id] = true
|
|
46
|
+
@roots << profile
|
|
47
|
+
walk.call(profile)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -6,6 +6,7 @@ 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
|
+
{ label: "Org", path: :org_path, match: [ "/org" ] },
|
|
9
10
|
{ label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
|
|
10
11
|
{ label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
|
|
11
12
|
{ label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] }
|
|
@@ -19,11 +20,15 @@ module HrLite
|
|
|
19
20
|
|
|
20
21
|
LEADERSHIP_NAV_ITEMS = [
|
|
21
22
|
{ label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
|
|
22
|
-
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
|
|
23
23
|
{ label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
|
|
24
24
|
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] }
|
|
25
25
|
].freeze
|
|
26
26
|
|
|
27
|
+
# Money tier — only config.superadmin_emails see these.
|
|
28
|
+
SUPERADMIN_NAV_ITEMS = [
|
|
29
|
+
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] }
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
27
32
|
# Only items whose routes exist yet (the nav grows with each phase).
|
|
28
33
|
def hrl_nav_items(items)
|
|
29
34
|
items.select { |item| hr_lite_route?(item[:path]) }
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
module HrLite
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# Leave-year rollover (host schedules it on the leave year's first day —
|
|
3
|
+
# Jan 1 for calendar years, Jul 1 for July–June years): materialize
|
|
4
|
+
# carry-forward into the new year's balance rows. Idempotent — a balance
|
|
5
|
+
# whose carry has already been written is never touched again (manual
|
|
6
|
+
# adjustments stay safe).
|
|
5
7
|
class LeaveYearRolloverJob < ActiveJob::Base
|
|
6
8
|
queue_as :default
|
|
7
9
|
|
|
8
|
-
def perform(year:
|
|
9
|
-
|
|
10
|
+
def perform(year: nil)
|
|
11
|
+
# Resolve "today" in the HR time zone — cron often fires around the
|
|
12
|
+
# boundary midnight, and the host process may run in UTC.
|
|
13
|
+
Time.use_zone(HrLite.config.time_zone) do
|
|
14
|
+
year ||= LeaveYear.current_key
|
|
15
|
+
previous_year = year - 1
|
|
16
|
+
roll(year, previous_year)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
10
21
|
|
|
22
|
+
def roll(year, previous_year)
|
|
11
23
|
LeaveType.active.where(paid: true).where.not(annual_quota: nil).find_each do |type|
|
|
12
24
|
next unless type.carry_forward_cap.positive?
|
|
13
25
|
|
|
@@ -16,7 +28,7 @@ module HrLite
|
|
|
16
28
|
next if balance.persisted? && balance.carried_forward.positive?
|
|
17
29
|
|
|
18
30
|
carry = LeaveBalance.for(user, type, previous_year)
|
|
19
|
-
.available(as_of:
|
|
31
|
+
.available(as_of: LeaveYear.range(previous_year).last)
|
|
20
32
|
carry = [ [ carry, type.carry_forward_cap ].min, 0 ].max
|
|
21
33
|
next if carry.zero? && balance.persisted?
|
|
22
34
|
|
|
@@ -100,13 +100,13 @@ module HrLite
|
|
|
100
100
|
|
|
101
101
|
private
|
|
102
102
|
|
|
103
|
-
# The credit goes into the year it can actually be SPENT (the
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
# The balance row is locked for the increment; two admins
|
|
107
|
-
# different requests for
|
|
103
|
+
# The credit goes into the leave year it can actually be SPENT (the
|
|
104
|
+
# current one at approval time) — crediting date_worked's leave year
|
|
105
|
+
# would strand a year-end day approved after rollover on a dead
|
|
106
|
+
# balance. The balance row is locked for the increment; two admins
|
|
107
|
+
# approving two different requests for one person cannot lose an update.
|
|
108
108
|
def credit_balance!(type)
|
|
109
|
-
year =
|
|
109
|
+
year = LeaveYear.current_key
|
|
110
110
|
balance = LeaveBalance.for(user, type, year)
|
|
111
111
|
if balance.new_record?
|
|
112
112
|
begin
|
|
@@ -8,6 +8,9 @@ module HrLite
|
|
|
8
8
|
include Audited
|
|
9
9
|
|
|
10
10
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
11
|
+
# Reporting line (L1). Optional; the org chart treats manager-less
|
|
12
|
+
# profiles as roots (founders/directors).
|
|
13
|
+
belongs_to :manager, class_name: HrLite.config.user_class, optional: true
|
|
11
14
|
|
|
12
15
|
encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc
|
|
13
16
|
encrypted_money :declared_annual_deductions
|
|
@@ -18,6 +21,8 @@ module HrLite
|
|
|
18
21
|
# config.onboard_user; never persisted, never audited).
|
|
19
22
|
attr_accessor :new_user_name, :new_user_email, :new_user_password
|
|
20
23
|
|
|
24
|
+
before_validation :assign_employee_code, on: :create
|
|
25
|
+
|
|
21
26
|
validates :employee_code, presence: true, uniqueness: true
|
|
22
27
|
validates :user_id, uniqueness: true
|
|
23
28
|
validates :date_of_joining, presence: true
|
|
@@ -28,6 +33,8 @@ module HrLite
|
|
|
28
33
|
allow_blank: true
|
|
29
34
|
validates :pf_uan, format: { with: /\A\d{12}\z/, message: "must be 12 digits" }, allow_blank: true
|
|
30
35
|
validate :exit_after_joining
|
|
36
|
+
validate :manager_chain_acyclic
|
|
37
|
+
validate :manager_is_active_staff, if: :manager_id_changed?
|
|
31
38
|
|
|
32
39
|
scope :active_for, ->(month) {
|
|
33
40
|
where(date_of_joining: ..month.end_of_month)
|
|
@@ -44,6 +51,31 @@ module HrLite
|
|
|
44
51
|
from <= to ? (from..to) : nil
|
|
45
52
|
end
|
|
46
53
|
|
|
54
|
+
# The manager as shown to the employee — an exited manager reads as
|
|
55
|
+
# "no manager" until leadership reassigns (matches the org chart).
|
|
56
|
+
def active_manager
|
|
57
|
+
return nil if manager.nil?
|
|
58
|
+
|
|
59
|
+
boss_exit = EmployeeProfile.where(user_id: manager_id).pick(:date_of_exit)
|
|
60
|
+
boss_exit && boss_exit < Date.current ? nil : manager
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# [L1 user, L2 user, ...] walking manager_id upward. Cycle-safe.
|
|
64
|
+
def reporting_chain
|
|
65
|
+
chain = []
|
|
66
|
+
seen = { user_id => true }
|
|
67
|
+
current = manager_id
|
|
68
|
+
while current && !seen[current]
|
|
69
|
+
seen[current] = true
|
|
70
|
+
boss = HrLite.user_klass.find_by(id: current)
|
|
71
|
+
break if boss.nil?
|
|
72
|
+
|
|
73
|
+
chain << boss
|
|
74
|
+
current = EmployeeProfile.where(user_id: boss.id).pick(:manager_id)
|
|
75
|
+
end
|
|
76
|
+
chain
|
|
77
|
+
end
|
|
78
|
+
|
|
47
79
|
def masked_pan
|
|
48
80
|
mask_middle(pan_number)
|
|
49
81
|
end
|
|
@@ -59,6 +91,50 @@ module HrLite
|
|
|
59
91
|
|
|
60
92
|
private
|
|
61
93
|
|
|
94
|
+
# Codes are system-assigned: Settings prefix + zero-padded next number
|
|
95
|
+
# (EMP001, EMP002, ...). Scans the highest existing suffix for the
|
|
96
|
+
# CURRENT prefix so changing the prefix restarts a fresh sequence
|
|
97
|
+
# without colliding with history.
|
|
98
|
+
def assign_employee_code
|
|
99
|
+
return if employee_code.present?
|
|
100
|
+
|
|
101
|
+
prefix = Setting.instance.employee_code_prefix
|
|
102
|
+
last = self.class.where("employee_code LIKE ?", "#{sanitize_sql_like(prefix)}%")
|
|
103
|
+
.pluck(:employee_code)
|
|
104
|
+
.filter_map { |code| code.delete_prefix(prefix)[/\A\d+\z/]&.to_i }
|
|
105
|
+
.max || 0
|
|
106
|
+
self.employee_code = format("%s%03d", prefix, last + 1)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def sanitize_sql_like(value)
|
|
110
|
+
self.class.sanitize_sql_like(value)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A newly-assigned manager must be a real, still-employed staff member.
|
|
114
|
+
def manager_is_active_staff
|
|
115
|
+
return if manager_id.nil?
|
|
116
|
+
return errors.add(:manager_id, "is not a staff account") if HrLite.user_klass.find_by(id: manager_id).nil?
|
|
117
|
+
|
|
118
|
+
boss_exit = EmployeeProfile.where(user_id: manager_id).pick(:date_of_exit)
|
|
119
|
+
errors.add(:manager_id, "has already exited") if boss_exit && boss_exit < Date.current
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Walking up from the proposed manager must never reach this person.
|
|
123
|
+
def manager_chain_acyclic
|
|
124
|
+
return if manager_id.nil?
|
|
125
|
+
return errors.add(:manager_id, "cannot be yourself") if manager_id == user_id
|
|
126
|
+
|
|
127
|
+
seen = {}
|
|
128
|
+
current = manager_id
|
|
129
|
+
while current
|
|
130
|
+
return errors.add(:manager_id, "creates a reporting loop") if current == user_id
|
|
131
|
+
break if seen[current]
|
|
132
|
+
|
|
133
|
+
seen[current] = true
|
|
134
|
+
current = EmployeeProfile.where(user_id: current).pick(:manager_id)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
62
138
|
def mask_middle(value)
|
|
63
139
|
return nil if value.blank?
|
|
64
140
|
return "•" * value.length if value.length <= 4
|
|
@@ -3,6 +3,11 @@ module HrLite
|
|
|
3
3
|
# entitlement accrues as a pure function of the policy and `used` is
|
|
4
4
|
# recomputed live from approved requests — so a holiday added after an
|
|
5
5
|
# approval self-heals both the quota and payroll.
|
|
6
|
+
#
|
|
7
|
+
# `year` is a LeaveYear key (calendar year by default; with
|
|
8
|
+
# config.leave_year_start_month = 7, key 2026 = Jul 2026 – Jun 2027).
|
|
9
|
+
# Entitlement prorates from the joining date, Keka-style: joined on or
|
|
10
|
+
# before the 15th → that month counts; after the 15th → from next month.
|
|
6
11
|
class LeaveBalance < ApplicationRecord
|
|
7
12
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
8
13
|
belongs_to :leave_type
|
|
@@ -17,26 +22,54 @@ module HrLite
|
|
|
17
22
|
def entitled(as_of: Date.current)
|
|
18
23
|
return Float::INFINITY if leave_type.unlimited?
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
base =
|
|
22
|
-
if leave_type.accrual == "monthly"
|
|
23
|
-
months = as_of.year == year ? as_of.month : (as_of.year > year ? 12 : 0)
|
|
24
|
-
((quota / 12) * months).round(1)
|
|
25
|
-
else
|
|
26
|
-
quota
|
|
27
|
-
end
|
|
28
|
-
base + carried_forward + adjustment
|
|
25
|
+
(accrued_base(as_of) + carried_forward + adjustment).round(1)
|
|
29
26
|
end
|
|
30
27
|
|
|
31
28
|
def used
|
|
29
|
+
range = LeaveYear.range(year)
|
|
32
30
|
requests = LeaveRequest.approved
|
|
33
31
|
.where(user_id: user_id, leave_type_id: leave_type_id)
|
|
34
|
-
.where(start_date:
|
|
32
|
+
.where(start_date: range)
|
|
35
33
|
requests.sum { |request| LeaveDayCounter.count(request) }
|
|
36
34
|
end
|
|
37
35
|
|
|
38
36
|
def available(as_of: Date.current)
|
|
39
37
|
entitled(as_of: as_of) - used
|
|
40
38
|
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Quota earned by as_of, before carry/adjustments. Monthly accrual
|
|
43
|
+
# drips quota/12 per month from the accrual start (leave-year start,
|
|
44
|
+
# or the prorated joining month for mid-year joiners); yearly_upfront
|
|
45
|
+
# grants all months from the accrual start to the year's end at once.
|
|
46
|
+
def accrued_base(as_of)
|
|
47
|
+
range = LeaveYear.range(year)
|
|
48
|
+
start = accrual_start(range)
|
|
49
|
+
return 0 if start.nil?
|
|
50
|
+
|
|
51
|
+
monthly_rate = leave_type.annual_quota / 12
|
|
52
|
+
months =
|
|
53
|
+
if leave_type.accrual == "monthly"
|
|
54
|
+
cap = [ as_of, range.last ].min
|
|
55
|
+
start > cap ? 0 : months_between(start, cap)
|
|
56
|
+
else
|
|
57
|
+
months_between(start, range.last)
|
|
58
|
+
end
|
|
59
|
+
monthly_rate * months
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# nil when the person joins only after this leave year ends.
|
|
63
|
+
def accrual_start(range)
|
|
64
|
+
doj = EmployeeProfile.where(user_id: user_id).pick(:date_of_joining)
|
|
65
|
+
return range.first if doj.nil? || doj <= range.first
|
|
66
|
+
|
|
67
|
+
start = doj.day <= 15 ? doj.beginning_of_month : doj.next_month.beginning_of_month
|
|
68
|
+
start > range.last ? nil : start
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def months_between(from, to)
|
|
72
|
+
(to.year * 12 + to.month) - (from.year * 12 + from.month) + 1
|
|
73
|
+
end
|
|
41
74
|
end
|
|
42
75
|
end
|
|
@@ -11,7 +11,7 @@ module HrLite
|
|
|
11
11
|
|
|
12
12
|
with_options on: :create do
|
|
13
13
|
validate :end_after_start
|
|
14
|
-
validate :
|
|
14
|
+
validate :same_leave_year
|
|
15
15
|
validate :half_day_single_day_only
|
|
16
16
|
validate :consumes_at_least_half_a_day
|
|
17
17
|
validate :no_overlap_with_own_requests
|
|
@@ -97,7 +97,7 @@ module HrLite
|
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
def balance
|
|
100
|
-
LeaveBalance.for(user, leave_type, start_date
|
|
100
|
+
LeaveBalance.for(user, leave_type, LeaveYear.key_for(start_date))
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
private
|
|
@@ -172,11 +172,11 @@ module HrLite
|
|
|
172
172
|
errors.add(:end_date, "must be on or after the start date") if end_date < start_date
|
|
173
173
|
end
|
|
174
174
|
|
|
175
|
-
def
|
|
175
|
+
def same_leave_year
|
|
176
176
|
return unless start_date && end_date
|
|
177
177
|
|
|
178
|
-
if start_date
|
|
179
|
-
errors.add(:base, "Split requests at the year boundary")
|
|
178
|
+
if LeaveYear.key_for(start_date) != LeaveYear.key_for(end_date)
|
|
179
|
+
errors.add(:base, "Split requests at the leave-year boundary")
|
|
180
180
|
end
|
|
181
181
|
end
|
|
182
182
|
|
|
@@ -6,6 +6,8 @@ module HrLite
|
|
|
6
6
|
WEEKEND_POLICIES = %w[sun_only sat_sun second_fourth_sat_sun].freeze
|
|
7
7
|
|
|
8
8
|
validates :weekend_policy, inclusion: { in: WEEKEND_POLICIES }
|
|
9
|
+
validates :employee_code_prefix, presence: true,
|
|
10
|
+
format: { with: /\A[A-Za-z]{1,10}\z/, message: "letters only, max 10" }
|
|
9
11
|
|
|
10
12
|
def self.instance
|
|
11
13
|
first_or_create!
|
|
@@ -28,8 +28,24 @@
|
|
|
28
28
|
</div>
|
|
29
29
|
</fieldset>
|
|
30
30
|
<% end %>
|
|
31
|
-
|
|
31
|
+
<% if profile.persisted? %>
|
|
32
|
+
<div class="hrl-field">
|
|
33
|
+
<%= f.label :employee_code %>
|
|
34
|
+
<input class="hrl-input" type="text" value="<%= profile.employee_code %>" disabled>
|
|
35
|
+
<p class="hrl-hint">System-assigned; the prefix lives under Settings.</p>
|
|
36
|
+
</div>
|
|
37
|
+
<% else %>
|
|
38
|
+
<p class="hrl-hint">The employee code is assigned automatically
|
|
39
|
+
(<%= HrLite::Setting.instance.employee_code_prefix %> + next number).</p>
|
|
40
|
+
<% end %>
|
|
32
41
|
<div class="hrl-field"><%= f.label :designation %><%= f.text_field :designation %></div>
|
|
42
|
+
<div class="hrl-field">
|
|
43
|
+
<%= f.label :manager_id, "Reports to (their L1)" %>
|
|
44
|
+
<%= f.select :manager_id,
|
|
45
|
+
HrLite.active_employees.reject { |u| u.id == profile.user_id }
|
|
46
|
+
.map { |u| [ HrLite.display_name(u), u.id ] },
|
|
47
|
+
include_blank: "— nobody (top of the org) —" %>
|
|
48
|
+
</div>
|
|
33
49
|
<div class="hrl-field"><%= f.label :department %><%= f.text_field :department %></div>
|
|
34
50
|
<div class="hrl-field"><%= f.label :work_location %><%= f.text_field :work_location %></div>
|
|
35
51
|
<div class="hrl-field"><%= f.label :date_of_birth %><%= f.date_field :date_of_birth %></div>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<% content_for(:page_title) { "Employees" } %>
|
|
2
2
|
<div class="hrl-page__head">
|
|
3
3
|
<h1 class="hrl-page__title">Employees</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_employee_path %>">New employee</a>
|
|
6
|
+
</div>
|
|
4
7
|
</div>
|
|
5
8
|
|
|
6
9
|
<section class="hrl-card">
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
<h1 class="hrl-page__title"><%= hr_display_name(@profile.user) %> <span class="hrl-muted">(<%= @profile.employee_code %>)</span></h1>
|
|
4
4
|
<div class="hrl-page__actions">
|
|
5
5
|
<a class="hrl-btn" href="<%= hr_lite.edit_admin_employee_path(@profile) %>">Edit</a>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
<% if hr_superadmin? %>
|
|
7
|
+
<a class="hrl-btn" href="<%= hr_lite.new_admin_employee_appraisal_path(@profile) %>">New appraisal</a>
|
|
8
|
+
<a class="hrl-btn" href="<%= hr_lite.new_admin_employee_designation_change_path(@profile) %>">Role change</a>
|
|
9
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_employee_salary_structure_path(@profile) %>">New structure</a>
|
|
10
|
+
<% end %>
|
|
9
11
|
</div>
|
|
10
12
|
</div>
|
|
11
13
|
|
|
@@ -56,6 +58,7 @@
|
|
|
56
58
|
<h2 class="hrl-card__title">Profile</h2>
|
|
57
59
|
<dl class="hrl-deflist">
|
|
58
60
|
<dt>Designation</dt><dd><%= @profile.designation.presence || "—" %></dd>
|
|
61
|
+
<dt>Reports to</dt><dd><%= @profile.manager ? hr_display_name(@profile.manager) : "—" %></dd>
|
|
59
62
|
<dt>Department</dt><dd><%= @profile.department.presence || "—" %></dd>
|
|
60
63
|
<dt>Joined</dt><dd><%= @profile.date_of_joining.strftime("%d %b %Y") %></dd>
|
|
61
64
|
<% if @profile.date_of_exit %><dt>Exited</dt><dd><%= @profile.date_of_exit.strftime("%d %b %Y") %></dd><% end %>
|
|
@@ -67,6 +70,7 @@
|
|
|
67
70
|
</dl>
|
|
68
71
|
</section>
|
|
69
72
|
|
|
73
|
+
<% if hr_superadmin? %>
|
|
70
74
|
<section class="hrl-card">
|
|
71
75
|
<h2 class="hrl-card__title">Salary structures</h2>
|
|
72
76
|
<% if @structures.any? %>
|
|
@@ -89,8 +93,10 @@
|
|
|
89
93
|
<p class="hrl-muted hrl-small">No structure yet — payroll will skip this employee.</p>
|
|
90
94
|
<% end %>
|
|
91
95
|
</section>
|
|
96
|
+
<% end %>
|
|
92
97
|
</div>
|
|
93
98
|
|
|
99
|
+
<% if hr_superadmin? %>
|
|
94
100
|
<section class="hrl-card">
|
|
95
101
|
<h2 class="hrl-card__title">Appraisals</h2>
|
|
96
102
|
<% appraisals = HrLite::Appraisal.where(user_id: @profile.user_id).recent_first %>
|
|
@@ -115,3 +121,4 @@
|
|
|
115
121
|
<p class="hrl-muted hrl-small">No appraisals yet.</p>
|
|
116
122
|
<% end %>
|
|
117
123
|
</section>
|
|
124
|
+
<% end %>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<% content_for(:page_title) { "Team balances" } %>
|
|
2
2
|
<div class="hrl-page__head">
|
|
3
|
-
<h1 class="hrl-page__title">Team leave balances <%= @year %></h1>
|
|
3
|
+
<h1 class="hrl-page__title">Team leave balances <%= HrLite::LeaveYear.label(@year) %></h1>
|
|
4
4
|
</div>
|
|
5
5
|
|
|
6
6
|
<section class="hrl-card">
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
<%= f.select :weekend_policy, HrLite::Setting::WEEKEND_POLICIES.map { |p| [ p.humanize, p ] } %>
|
|
12
12
|
<p class="hrl-hint">Drives working-day math everywhere: leave counting, attendance, payroll.</p>
|
|
13
13
|
</div>
|
|
14
|
+
<div class="hrl-field">
|
|
15
|
+
<%= f.label :employee_code_prefix, "Employee code prefix" %>
|
|
16
|
+
<%= f.text_field :employee_code_prefix %>
|
|
17
|
+
<p class="hrl-hint">Codes are assigned automatically as
|
|
18
|
+
<%= @setting.employee_code_prefix %>001, <%= @setting.employee_code_prefix %>002, …
|
|
19
|
+
— you only choose the prefix.</p>
|
|
20
|
+
</div>
|
|
14
21
|
<div class="hrl-form-actions">
|
|
15
22
|
<%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
|
|
16
23
|
</div>
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
<dl class="hrl-deflist">
|
|
9
9
|
<dt>Employee code</dt><dd><%= @profile.employee_code %></dd>
|
|
10
10
|
<dt>Designation</dt><dd><%= @profile.designation.presence || "—" %></dd>
|
|
11
|
+
<dt>Reports to</dt>
|
|
12
|
+
<dd><%= (boss = @profile.active_manager) ? "#{hr_display_name(boss)} (your L1)" : "—" %>
|
|
13
|
+
· <a href="<%= hr_lite.org_path %>">Org chart</a></dd>
|
|
11
14
|
<dt>Department</dt><dd><%= @profile.department.presence || "—" %></dd>
|
|
12
15
|
<dt>Joined</dt><dd><%= @profile.date_of_joining.strftime("%d %B %Y") %></dd>
|
|
13
16
|
<dt>Tax regime</dt><dd><%= @profile.tax_regime.humanize %></dd>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<% content_for(:page_title) { "Leave balances" } %>
|
|
2
2
|
<div class="hrl-page__head">
|
|
3
|
-
<h1 class="hrl-page__title">Leave balances <%= @year %></h1>
|
|
3
|
+
<h1 class="hrl-page__title">Leave balances <%= HrLite::LeaveYear.label(@year) %></h1>
|
|
4
4
|
</div>
|
|
5
5
|
|
|
6
6
|
<div class="hrl-grid hrl-grid--2">
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<section class="hrl-card">
|
|
2
|
-
<h2 class="hrl-card__title">Leave balance <%=
|
|
2
|
+
<h2 class="hrl-card__title">Leave balance <%= HrLite::LeaveYear.label(HrLite::LeaveYear.current_key) %></h2>
|
|
3
3
|
<% types = HrLite::LeaveType.active.where(paid: true).where.not(annual_quota: nil) %>
|
|
4
4
|
<% if types.any? %>
|
|
5
5
|
<div class="hrl-row">
|
|
6
6
|
<% types.each do |type| %>
|
|
7
|
-
<% balance = HrLite::LeaveBalance.for(hr_current_user, type,
|
|
7
|
+
<% balance = HrLite::LeaveBalance.for(hr_current_user, type, HrLite::LeaveYear.current_key) %>
|
|
8
8
|
<span class="hrl-badge" style="background: <%= type.color %>1f; color: <%= type.color %>;">
|
|
9
9
|
<%= type.code %> <%= balance.available.to_f %>/<%= balance.entitled.to_f %>
|
|
10
10
|
</span>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# locals: profile — recursive org-chart node; @children / hr_current_user from OrgController %>
|
|
2
|
+
<li class="hrl-tree__node">
|
|
3
|
+
<div class="hrl-tree__card <%= 'hrl-tree__card--me' if profile.user_id == hr_current_user.id %>">
|
|
4
|
+
<span class="hrl-tree__name"><%= hr_display_name(profile.user) %></span>
|
|
5
|
+
<% if profile.designation.present? %><span class="hrl-tree__role"><%= profile.designation %></span><% end %>
|
|
6
|
+
<% if profile.department.present? %><span class="hrl-small hrl-muted"><%= profile.department %></span><% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<% reports = @tree_children[profile.user_id].sort_by { |child| HrLite.display_name(child.user).downcase } %>
|
|
9
|
+
<% if reports.any? %>
|
|
10
|
+
<ul class="hrl-tree">
|
|
11
|
+
<%= render partial: "hr_lite/org/node", collection: reports, as: :profile %>
|
|
12
|
+
</ul>
|
|
13
|
+
<% end %>
|
|
14
|
+
</li>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Org chart" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Organisation</h1>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<% if @own_chain.any? %>
|
|
7
|
+
<section class="hrl-card">
|
|
8
|
+
<h2 class="hrl-card__title">Your reporting line</h2>
|
|
9
|
+
<div class="hrl-chainrow">
|
|
10
|
+
<span class="hrl-badge hrl-badge--ok">You</span>
|
|
11
|
+
<% @own_chain.each_with_index do |boss, index| %>
|
|
12
|
+
<span class="hrl-chainrow__step">
|
|
13
|
+
<span class="hrl-chainrow__arrow">→</span>
|
|
14
|
+
<span class="hrl-badge">
|
|
15
|
+
L<%= index + 1 %> · <%= hr_display_name(boss) %><% if (p = @by_user[boss.id]) && p.designation.present? %> (<%= p.designation %>)<% end %>
|
|
16
|
+
</span>
|
|
17
|
+
</span>
|
|
18
|
+
<% end %>
|
|
19
|
+
</div>
|
|
20
|
+
<p class="hrl-hint">L1 is your direct manager; the chain ends at the top of the organisation.</p>
|
|
21
|
+
</section>
|
|
22
|
+
<% elsif @own_profile %>
|
|
23
|
+
<section class="hrl-card">
|
|
24
|
+
<h2 class="hrl-card__title">Your reporting line</h2>
|
|
25
|
+
<p class="hrl-small hrl-muted">You are at the top of the organisation.</p>
|
|
26
|
+
</section>
|
|
27
|
+
<% end %>
|
|
28
|
+
|
|
29
|
+
<section class="hrl-card">
|
|
30
|
+
<h2 class="hrl-card__title">Who reports to whom</h2>
|
|
31
|
+
<% if @roots.any? %>
|
|
32
|
+
<ul class="hrl-tree">
|
|
33
|
+
<%= render partial: "hr_lite/org/node", collection: @roots, as: :profile %>
|
|
34
|
+
</ul>
|
|
35
|
+
<% else %>
|
|
36
|
+
<p class="hrl-card--empty">No employee profiles yet — leadership adds them under Employees.</p>
|
|
37
|
+
<% end %>
|
|
38
|
+
</section>
|
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
<% hrl_nav_items(HrLite::ApplicationHelper::LEADERSHIP_NAV_ITEMS).each do |item| %>
|
|
43
43
|
<%= hrl_nav_link(item) %>
|
|
44
44
|
<% end %>
|
|
45
|
+
<% if hr_superadmin? %>
|
|
46
|
+
<% hrl_nav_items(HrLite::ApplicationHelper::SUPERADMIN_NAV_ITEMS).each do |item| %>
|
|
47
|
+
<%= hrl_nav_link(item) %>
|
|
48
|
+
<% end %>
|
|
49
|
+
<% end %>
|
|
45
50
|
<% end %>
|
|
46
51
|
</nav>
|
|
47
52
|
|
data/config/routes.rb
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddManagerToHrLiteEmployeeProfiles < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# Reporting line: the user this employee reports to (L1). The chain
|
|
4
|
+
# upward gives L2, L3, ... and powers the everyone-visible org chart.
|
|
5
|
+
add_column :hr_lite_employee_profiles, :manager_id, :bigint
|
|
6
|
+
add_index :hr_lite_employee_profiles, :manager_id
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddEmployeeCodePrefixToHrLiteSettings < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# Employee codes are system-assigned: prefix + zero-padded sequence
|
|
4
|
+
# (EMP001, EMP002, ...). Leadership edits the prefix under Settings.
|
|
5
|
+
add_column :hr_lite_settings, :employee_code_prefix, :string, null: false, default: "EMP"
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -8,9 +8,23 @@ module HrLite
|
|
|
8
8
|
:employees_scope, :mentionable_users, :notify, :render_pdf, :company,
|
|
9
9
|
:time_zone, :currency_symbol, :on_designation_change,
|
|
10
10
|
:leadership_emails, :leadership_check, :extra_stylesheets,
|
|
11
|
+
:superadmin_emails, :superadmin_check,
|
|
11
12
|
:mailer_from, :public_url_base, :notification_matrix, :back_link,
|
|
12
13
|
:onboard_user, :offboard_user, :invite_url_for
|
|
13
14
|
|
|
15
|
+
attr_reader :leave_year_start_month
|
|
16
|
+
|
|
17
|
+
# Misconfiguration must fail at boot, not as production 500s on every
|
|
18
|
+
# balance screen. Accepts "7" (ENV-friendly) and validates 1..12.
|
|
19
|
+
# Changing this on an install with EXISTING balance rows reinterprets
|
|
20
|
+
# them — set it once at install time (see docs/CONFIGURATION.md).
|
|
21
|
+
def leave_year_start_month=(value)
|
|
22
|
+
month = Integer(value)
|
|
23
|
+
raise ArgumentError, "leave_year_start_month must be 1..12, got #{value.inspect}" unless (1..12).cover?(month)
|
|
24
|
+
|
|
25
|
+
@leave_year_start_month = month
|
|
26
|
+
end
|
|
27
|
+
|
|
14
28
|
# 0.1.0 pre-release name for public_url_base; kept as an alias so early
|
|
15
29
|
# adopters' initializers don't break.
|
|
16
30
|
alias_method :mail_link_base, :public_url_base
|
|
@@ -36,11 +50,19 @@ module HrLite
|
|
|
36
50
|
emails = HrLite.config.leadership_emails.map { |e| e.to_s.downcase.strip }
|
|
37
51
|
emails.include?(user.email.to_s.downcase)
|
|
38
52
|
end
|
|
53
|
+
# Money tier: salary structures, payroll, slips, appraisals. Empty
|
|
54
|
+
# list means "same as leadership" (pre-0.5.0 behaviour).
|
|
55
|
+
@superadmin_emails = []
|
|
56
|
+
@superadmin_check = ->(user) do
|
|
57
|
+
emails = HrLite.config.superadmin_emails.map { |e| e.to_s.downcase.strip }.reject(&:empty?)
|
|
58
|
+
emails.empty? ? HrLite.leadership?(user) : emails.include?(user.email.to_s.downcase)
|
|
59
|
+
end
|
|
39
60
|
@extra_stylesheets = [] # host stylesheets linked AFTER hr_lite.css (CSS-var overrides)
|
|
40
61
|
@mailer_from = "hr@example.com"
|
|
41
62
|
@public_url_base = nil # e.g. "https://hr.example.com" — enables email links + HrLite.public_url
|
|
42
63
|
@notification_matrix = nil # resolved lazily to Notifications::DEFAULT_MATRIX
|
|
43
64
|
@back_link = nil # optional {label:, url:} for the shell nav
|
|
65
|
+
@leave_year_start_month = 1 # 1 = calendar year; 7 = July–June leave year
|
|
44
66
|
|
|
45
67
|
# Leadership onboarding/offboarding. onboard_user must return a saved
|
|
46
68
|
# user record (default: create on user_class with whatever of
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The leave year is the 12-month window balances live in. It starts on
|
|
3
|
+
# config.leave_year_start_month (1 = calendar year, 7 = July–June, the
|
|
4
|
+
# Indian travel-industry pattern). A year is identified by the calendar
|
|
5
|
+
# year its FIRST month falls in: with July starts, key 2026 spans
|
|
6
|
+
# 1 Jul 2026 – 30 Jun 2027.
|
|
7
|
+
module LeaveYear
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def start_month
|
|
11
|
+
HrLite.config.leave_year_start_month
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The key (integer label) of the leave year containing `date`.
|
|
15
|
+
def key_for(date)
|
|
16
|
+
date.month >= start_month ? date.year : date.year - 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def current_key
|
|
20
|
+
key_for(Date.current)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def range(key)
|
|
24
|
+
start = Date.new(key, start_month, 1)
|
|
25
|
+
start..(start >> 12) - 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# "2026" for calendar years, "2026–27" otherwise.
|
|
29
|
+
def label(key)
|
|
30
|
+
start_month == 1 ? key.to_s : format("%d–%02d", key, (key + 1) % 100)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/hr_lite/version.rb
CHANGED
data/lib/hr_lite.rb
CHANGED
|
@@ -2,6 +2,7 @@ require "hr_lite/version"
|
|
|
2
2
|
require "hr_lite/engine"
|
|
3
3
|
require "hr_lite/configuration"
|
|
4
4
|
require "hr_lite/current"
|
|
5
|
+
require "hr_lite/leave_year"
|
|
5
6
|
require "hr_lite/mention_parser"
|
|
6
7
|
require "hr_lite/notifications"
|
|
7
8
|
require "hr_lite/seeds"
|
|
@@ -37,6 +38,10 @@ module HrLite
|
|
|
37
38
|
user.present? && !!config.leadership_check.call(user)
|
|
38
39
|
end
|
|
39
40
|
|
|
41
|
+
def superadmin?(user)
|
|
42
|
+
user.present? && !!config.superadmin_check.call(user)
|
|
43
|
+
end
|
|
44
|
+
|
|
40
45
|
# Leadership members resolvable to actual user records (for bell
|
|
41
46
|
# notifications). Emails configured but absent from the user table are
|
|
42
47
|
# still reachable by email — see Notifications.
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -74,6 +74,7 @@ files:
|
|
|
74
74
|
- app/controllers/hr_lite/admin/salary_slips_controller.rb
|
|
75
75
|
- app/controllers/hr_lite/admin/salary_structures_controller.rb
|
|
76
76
|
- app/controllers/hr_lite/admin/settings_controller.rb
|
|
77
|
+
- app/controllers/hr_lite/admin/superadmin_controller.rb
|
|
77
78
|
- app/controllers/hr_lite/application_controller.rb
|
|
78
79
|
- app/controllers/hr_lite/appraisals_controller.rb
|
|
79
80
|
- app/controllers/hr_lite/attendance_controller.rb
|
|
@@ -86,6 +87,7 @@ files:
|
|
|
86
87
|
- app/controllers/hr_lite/kudos_controller.rb
|
|
87
88
|
- app/controllers/hr_lite/leave_balances_controller.rb
|
|
88
89
|
- app/controllers/hr_lite/leave_requests_controller.rb
|
|
90
|
+
- app/controllers/hr_lite/org_controller.rb
|
|
89
91
|
- app/controllers/hr_lite/regularization_requests_controller.rb
|
|
90
92
|
- app/controllers/hr_lite/resignations_controller.rb
|
|
91
93
|
- app/controllers/hr_lite/salary_slips_controller.rb
|
|
@@ -197,6 +199,8 @@ files:
|
|
|
197
199
|
- app/views/hr_lite/leave_requests/index.html.erb
|
|
198
200
|
- app/views/hr_lite/leave_requests/new.html.erb
|
|
199
201
|
- app/views/hr_lite/leave_requests/show.html.erb
|
|
202
|
+
- app/views/hr_lite/org/_node.html.erb
|
|
203
|
+
- app/views/hr_lite/org/show.html.erb
|
|
200
204
|
- app/views/hr_lite/regularization_requests/index.html.erb
|
|
201
205
|
- app/views/hr_lite/regularization_requests/new.html.erb
|
|
202
206
|
- app/views/hr_lite/resignations/show.html.erb
|
|
@@ -232,6 +236,8 @@ files:
|
|
|
232
236
|
- db/migrate/20260719104315_add_comp_off_to_hr_lite_leave_types.rb
|
|
233
237
|
- db/migrate/20260719104316_create_hr_lite_comp_off_requests.rb
|
|
234
238
|
- db/migrate/20260719104317_create_hr_lite_regularization_requests.rb
|
|
239
|
+
- db/migrate/20260719162154_add_manager_to_hr_lite_employee_profiles.rb
|
|
240
|
+
- db/migrate/20260720094454_add_employee_code_prefix_to_hr_lite_settings.rb
|
|
235
241
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
236
242
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
237
243
|
- lib/generators/hr_lite/install/templates/initializer.rb
|
|
@@ -241,6 +247,7 @@ files:
|
|
|
241
247
|
- lib/hr_lite/current.rb
|
|
242
248
|
- lib/hr_lite/engine.rb
|
|
243
249
|
- lib/hr_lite/geo.rb
|
|
250
|
+
- lib/hr_lite/leave_year.rb
|
|
244
251
|
- lib/hr_lite/mention_parser.rb
|
|
245
252
|
- lib/hr_lite/money.rb
|
|
246
253
|
- lib/hr_lite/notifications.rb
|