hr_lite 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4ea6b78faec5dd0012db2be857b1874707c5085e99c54114d611893122be1a0
4
- data.tar.gz: 0572af1000548da1d1d66699939360827dd8ef6fa1cc420ed236dfb90fd69a25
3
+ metadata.gz: 7d95fb8f07ae1876be368009a6d539b30938e7635d95abcbe9e1bbd404e57d0c
4
+ data.tar.gz: a3f31cbd6ca7936381ce13f3baf2c4ba6532431f5066867345a1c6e4c30a69b0
5
5
  SHA512:
6
- metadata.gz: 79300a2187ebae2eaa26a51a4c0d8845a380cfdb6787dd2df0e3d54a18a0da32b2668586f4cd2c4f4c5d5644ff7c4a5979ab60faf69c94c8a6ee1db7bf18f91a
7
- data.tar.gz: 91442183160220175e78f0951917da321b4c555f1384772dc08cbc9afc48c7ae0fdaaa7d59591181b72bb8358a75b4e20f941283860b1a755b8aa5eddc1e3729
6
+ metadata.gz: 8efffc3c3f5468f3e15c7118ce0f50248e74a1e0b359a009022b9c40376c5b41c755c4cb350fbd68a6d90d734acfa23989f790c0143f2f815440983c52425488
7
+ data.tar.gz: b020fd0ca4df79fba65e8efdc7a99da0c6979ec050405b7b9b1fe63091d62cd421725815f46f03da450edf82635336eb7ac8814ce35807f2369252bb18631bd8
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-07-19
11
+
12
+ ### Added
13
+
14
+ - **Org chart (`/org`)**: everyone-visible reporting tree (who reports to
15
+ whom, names + designations + departments only — never salary or private
16
+ data) plus each viewer's own reporting line labelled L1/L2/... Managers
17
+ are set per employee by leadership ("Reports to"); reporting loops are
18
+ rejected.
19
+ - **Configurable leave year** (`config.leave_year_start_month`, default 1):
20
+ set 7 for a July–June leave year. Balances, accrual, the year-boundary
21
+ split rule, carry-forward rollover and comp-off credits all follow it.
22
+ Balance headings show "2026–27"-style labels for non-calendar years.
23
+ - **Joining-date proration, Keka-style**: entitlement accrues only from
24
+ the month someone joins (joined on/before the 15th → that month counts;
25
+ after → from the next month). Applies to monthly accrual AND
26
+ yearly-upfront grants (upfront = remaining months × monthly rate).
27
+
28
+ ### Changed
29
+
30
+ - `LeaveBalance#year` is now a LeaveYear key; `LeaveYearRolloverJob`
31
+ defaults to the current leave year in the configured HR time zone
32
+ (schedule it on the leave year's first day). The request-split
33
+ validation message says "leave-year boundary".
34
+
35
+ ### Upgrade notes
36
+
37
+ - **Set `leave_year_start_month` once, at install time.** Balance rows
38
+ are keyed by leave year with no stored epoch — changing the start
39
+ month on an install with existing balances silently reinterprets
40
+ every row (carry, adjustments, comp-off credits) and miskeys
41
+ historical requests. The setter validates 1..12 and accepts "7".
42
+ - **Joining-date proration applies to existing data.** Employees who
43
+ joined partway through the CURRENT year previously showed the full
44
+ year's accrual; from 0.4.0 they accrue only from their joining month,
45
+ so their entitlement can drop (and, if they already used more, go
46
+ negative). Where the old number was intended, add a one-off balance
47
+ adjustment with a note.
48
+
10
49
  ## [0.3.0] - 2026-07-19
11
50
 
12
51
  ### 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, :employee_code, :designation, :date_of_birth, :date_of_joining,
96
+ :user_id, :manager_id, :employee_code, :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 : Date.current.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
@@ -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 : Date.current.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
@@ -43,7 +43,7 @@ module HrLite
43
43
  end
44
44
 
45
45
  def balance_cards
46
- year = Date.current.year
46
+ year = LeaveYear.current_key
47
47
  LeaveType.active.where(paid: true).where.not(annual_quota: nil).map do |type|
48
48
  LeaveBalance.for(hr_current_user, type, year)
49
49
  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" ] }
@@ -1,13 +1,25 @@
1
1
  module HrLite
2
- # Jan-1 rollover: materialize carry-forward into the new year's balance
3
- # rows. Idempotent a balance whose carry has already been written is
4
- # never touched again (manual adjustments stay safe).
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: Date.current.year)
9
- previous_year = year - 1
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: Date.new(previous_year, 12, 31))
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 current
104
- # year at approval time) — crediting date_worked.year would strand a
105
- # late-December day approved in January on last year's dead balance.
106
- # The balance row is locked for the increment; two admins approving two
107
- # different requests for the same person cannot lose an update.
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 = [ date_worked.year, Date.current.year ].max
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
@@ -28,6 +31,8 @@ module HrLite
28
31
  allow_blank: true
29
32
  validates :pf_uan, format: { with: /\A\d{12}\z/, message: "must be 12 digits" }, allow_blank: true
30
33
  validate :exit_after_joining
34
+ validate :manager_chain_acyclic
35
+ validate :manager_is_active_staff, if: :manager_id_changed?
31
36
 
32
37
  scope :active_for, ->(month) {
33
38
  where(date_of_joining: ..month.end_of_month)
@@ -44,6 +49,31 @@ module HrLite
44
49
  from <= to ? (from..to) : nil
45
50
  end
46
51
 
52
+ # The manager as shown to the employee — an exited manager reads as
53
+ # "no manager" until leadership reassigns (matches the org chart).
54
+ def active_manager
55
+ return nil if manager.nil?
56
+
57
+ boss_exit = EmployeeProfile.where(user_id: manager_id).pick(:date_of_exit)
58
+ boss_exit && boss_exit < Date.current ? nil : manager
59
+ end
60
+
61
+ # [L1 user, L2 user, ...] walking manager_id upward. Cycle-safe.
62
+ def reporting_chain
63
+ chain = []
64
+ seen = { user_id => true }
65
+ current = manager_id
66
+ while current && !seen[current]
67
+ seen[current] = true
68
+ boss = HrLite.user_klass.find_by(id: current)
69
+ break if boss.nil?
70
+
71
+ chain << boss
72
+ current = EmployeeProfile.where(user_id: boss.id).pick(:manager_id)
73
+ end
74
+ chain
75
+ end
76
+
47
77
  def masked_pan
48
78
  mask_middle(pan_number)
49
79
  end
@@ -59,6 +89,31 @@ module HrLite
59
89
 
60
90
  private
61
91
 
92
+ # A newly-assigned manager must be a real, still-employed staff member.
93
+ def manager_is_active_staff
94
+ return if manager_id.nil?
95
+ return errors.add(:manager_id, "is not a staff account") if HrLite.user_klass.find_by(id: manager_id).nil?
96
+
97
+ boss_exit = EmployeeProfile.where(user_id: manager_id).pick(:date_of_exit)
98
+ errors.add(:manager_id, "has already exited") if boss_exit && boss_exit < Date.current
99
+ end
100
+
101
+ # Walking up from the proposed manager must never reach this person.
102
+ def manager_chain_acyclic
103
+ return if manager_id.nil?
104
+ return errors.add(:manager_id, "cannot be yourself") if manager_id == user_id
105
+
106
+ seen = {}
107
+ current = manager_id
108
+ while current
109
+ return errors.add(:manager_id, "creates a reporting loop") if current == user_id
110
+ break if seen[current]
111
+
112
+ seen[current] = true
113
+ current = EmployeeProfile.where(user_id: current).pick(:manager_id)
114
+ end
115
+ end
116
+
62
117
  def mask_middle(value)
63
118
  return nil if value.blank?
64
119
  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
- quota = leave_type.annual_quota
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: Date.new(year, 1, 1)..Date.new(year, 12, 31))
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 :same_calendar_year
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.year)
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 same_calendar_year
175
+ def same_leave_year
176
176
  return unless start_date && end_date
177
177
 
178
- if start_date.year != end_date.year
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
 
@@ -30,6 +30,13 @@
30
30
  <% end %>
31
31
  <div class="hrl-field"><%= f.label :employee_code %><%= f.text_field :employee_code %></div>
32
32
  <div class="hrl-field"><%= f.label :designation %><%= f.text_field :designation %></div>
33
+ <div class="hrl-field">
34
+ <%= f.label :manager_id, "Reports to (their L1)" %>
35
+ <%= f.select :manager_id,
36
+ HrLite.active_employees.reject { |u| u.id == profile.user_id }
37
+ .map { |u| [ HrLite.display_name(u), u.id ] },
38
+ include_blank: "— nobody (top of the org) —" %>
39
+ </div>
33
40
  <div class="hrl-field"><%= f.label :department %><%= f.text_field :department %></div>
34
41
  <div class="hrl-field"><%= f.label :work_location %><%= f.text_field :work_location %></div>
35
42
  <div class="hrl-field"><%= f.label :date_of_birth %><%= f.date_field :date_of_birth %></div>
@@ -56,6 +56,7 @@
56
56
  <h2 class="hrl-card__title">Profile</h2>
57
57
  <dl class="hrl-deflist">
58
58
  <dt>Designation</dt><dd><%= @profile.designation.presence || "—" %></dd>
59
+ <dt>Reports to</dt><dd><%= @profile.manager ? hr_display_name(@profile.manager) : "—" %></dd>
59
60
  <dt>Department</dt><dd><%= @profile.department.presence || "—" %></dd>
60
61
  <dt>Joined</dt><dd><%= @profile.date_of_joining.strftime("%d %b %Y") %></dd>
61
62
  <% if @profile.date_of_exit %><dt>Exited</dt><dd><%= @profile.date_of_exit.strftime("%d %b %Y") %></dd><% 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">
@@ -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 <%= Date.current.year %></h2>
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, Date.current.year) %>
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">&rarr;</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>
data/config/routes.rb CHANGED
@@ -20,6 +20,7 @@ HrLite::Engine.routes.draw do
20
20
  member { post :cancel }
21
21
  end
22
22
  get "team", to: "team#show"
23
+ get "org", to: "org#show"
23
24
  resources :holidays, only: :index
24
25
  get "calendar", to: "calendar#show"
25
26
  resources :salary_slips, only: %i[index show]
@@ -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
@@ -11,6 +11,19 @@ module HrLite
11
11
  :mailer_from, :public_url_base, :notification_matrix, :back_link,
12
12
  :onboard_user, :offboard_user, :invite_url_for
13
13
 
14
+ attr_reader :leave_year_start_month
15
+
16
+ # Misconfiguration must fail at boot, not as production 500s on every
17
+ # balance screen. Accepts "7" (ENV-friendly) and validates 1..12.
18
+ # Changing this on an install with EXISTING balance rows reinterprets
19
+ # them — set it once at install time (see docs/CONFIGURATION.md).
20
+ def leave_year_start_month=(value)
21
+ month = Integer(value)
22
+ raise ArgumentError, "leave_year_start_month must be 1..12, got #{value.inspect}" unless (1..12).cover?(month)
23
+
24
+ @leave_year_start_month = month
25
+ end
26
+
14
27
  # 0.1.0 pre-release name for public_url_base; kept as an alias so early
15
28
  # adopters' initializers don't break.
16
29
  alias_method :mail_link_base, :public_url_base
@@ -41,6 +54,7 @@ module HrLite
41
54
  @public_url_base = nil # e.g. "https://hr.example.com" — enables email links + HrLite.public_url
42
55
  @notification_matrix = nil # resolved lazily to Notifications::DEFAULT_MATRIX
43
56
  @back_link = nil # optional {label:, url:} for the shell nav
57
+ @leave_year_start_month = 1 # 1 = calendar year; 7 = July–June leave year
44
58
 
45
59
  # Leadership onboarding/offboarding. onboard_user must return a saved
46
60
  # 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
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
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"
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -86,6 +86,7 @@ files:
86
86
  - app/controllers/hr_lite/kudos_controller.rb
87
87
  - app/controllers/hr_lite/leave_balances_controller.rb
88
88
  - app/controllers/hr_lite/leave_requests_controller.rb
89
+ - app/controllers/hr_lite/org_controller.rb
89
90
  - app/controllers/hr_lite/regularization_requests_controller.rb
90
91
  - app/controllers/hr_lite/resignations_controller.rb
91
92
  - app/controllers/hr_lite/salary_slips_controller.rb
@@ -197,6 +198,8 @@ files:
197
198
  - app/views/hr_lite/leave_requests/index.html.erb
198
199
  - app/views/hr_lite/leave_requests/new.html.erb
199
200
  - app/views/hr_lite/leave_requests/show.html.erb
201
+ - app/views/hr_lite/org/_node.html.erb
202
+ - app/views/hr_lite/org/show.html.erb
200
203
  - app/views/hr_lite/regularization_requests/index.html.erb
201
204
  - app/views/hr_lite/regularization_requests/new.html.erb
202
205
  - app/views/hr_lite/resignations/show.html.erb
@@ -232,6 +235,7 @@ files:
232
235
  - db/migrate/20260719104315_add_comp_off_to_hr_lite_leave_types.rb
233
236
  - db/migrate/20260719104316_create_hr_lite_comp_off_requests.rb
234
237
  - db/migrate/20260719104317_create_hr_lite_regularization_requests.rb
238
+ - db/migrate/20260719162154_add_manager_to_hr_lite_employee_profiles.rb
235
239
  - lib/generators/hr_lite/install/install_generator.rb
236
240
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
237
241
  - lib/generators/hr_lite/install/templates/initializer.rb
@@ -241,6 +245,7 @@ files:
241
245
  - lib/hr_lite/current.rb
242
246
  - lib/hr_lite/engine.rb
243
247
  - lib/hr_lite/geo.rb
248
+ - lib/hr_lite/leave_year.rb
244
249
  - lib/hr_lite/mention_parser.rb
245
250
  - lib/hr_lite/money.rb
246
251
  - lib/hr_lite/notifications.rb