hr_lite 0.2.1 → 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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/README.md +2 -1
  4. data/app/assets/stylesheets/hr_lite/hr_lite.css +35 -4
  5. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +38 -0
  6. data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
  7. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +1 -1
  8. data/app/controllers/hr_lite/admin/leave_types_controller.rb +1 -1
  9. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +38 -0
  10. data/app/controllers/hr_lite/comp_off_requests_controller.rb +46 -0
  11. data/app/controllers/hr_lite/leave_balances_controller.rb +1 -1
  12. data/app/controllers/hr_lite/leave_requests_controller.rb +1 -1
  13. data/app/controllers/hr_lite/org_controller.rb +51 -0
  14. data/app/controllers/hr_lite/regularization_requests_controller.rb +36 -0
  15. data/app/controllers/hr_lite/team_controller.rb +11 -0
  16. data/app/helpers/hr_lite/application_helper.rb +62 -3
  17. data/app/jobs/hr_lite/leave_year_rollover_job.rb +18 -6
  18. data/app/models/hr_lite/comp_off_request.rb +177 -0
  19. data/app/models/hr_lite/employee_profile.rb +55 -0
  20. data/app/models/hr_lite/leave_balance.rb +43 -10
  21. data/app/models/hr_lite/leave_request.rb +23 -5
  22. data/app/models/hr_lite/leave_type.rb +17 -0
  23. data/app/models/hr_lite/regularization_request.rb +181 -0
  24. data/app/services/hr_lite/team_day.rb +84 -0
  25. data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +37 -0
  26. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +47 -0
  27. data/app/views/hr_lite/admin/employees/_form.html.erb +7 -0
  28. data/app/views/hr_lite/admin/employees/show.html.erb +1 -0
  29. data/app/views/hr_lite/admin/leave_balances/index.html.erb +1 -1
  30. data/app/views/hr_lite/admin/leave_requests/index.html.erb +2 -0
  31. data/app/views/hr_lite/admin/leave_types/_form.html.erb +4 -0
  32. data/app/views/hr_lite/admin/regularization_requests/index.html.erb +37 -0
  33. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +50 -0
  34. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +11 -0
  35. data/app/views/hr_lite/attendance/show.html.erb +3 -0
  36. data/app/views/hr_lite/comp_off_requests/index.html.erb +42 -0
  37. data/app/views/hr_lite/comp_off_requests/new.html.erb +28 -0
  38. data/app/views/hr_lite/employee_profiles/show.html.erb +3 -0
  39. data/app/views/hr_lite/leave_balances/index.html.erb +1 -1
  40. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +2 -2
  41. data/app/views/hr_lite/leave_requests/index.html.erb +1 -0
  42. data/app/views/hr_lite/org/_node.html.erb +14 -0
  43. data/app/views/hr_lite/org/show.html.erb +38 -0
  44. data/app/views/hr_lite/regularization_requests/index.html.erb +41 -0
  45. data/app/views/hr_lite/regularization_requests/new.html.erb +30 -0
  46. data/app/views/hr_lite/team/show.html.erb +48 -0
  47. data/app/views/layouts/hr_lite/application.html.erb +1 -1
  48. data/config/routes.rb +14 -0
  49. data/db/migrate/20260719104315_add_comp_off_to_hr_lite_leave_types.rb +6 -0
  50. data/db/migrate/20260719104316_create_hr_lite_comp_off_requests.rb +24 -0
  51. data/db/migrate/20260719104317_create_hr_lite_regularization_requests.rb +20 -0
  52. data/db/migrate/20260719162154_add_manager_to_hr_lite_employee_profiles.rb +8 -0
  53. data/lib/hr_lite/configuration.rb +14 -0
  54. data/lib/hr_lite/leave_year.rb +33 -0
  55. data/lib/hr_lite/notifications.rb +14 -2
  56. data/lib/hr_lite/seeds.rb +16 -1
  57. data/lib/hr_lite/version.rb +1 -1
  58. data/lib/hr_lite.rb +8 -0
  59. metadata +27 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b975162e55e723ed0bd24a9e032eba889d18e89c106ce55fc051a8566ce41e0
4
- data.tar.gz: 2a0bd3e239011f037e300f26f3e8d18e3fb9a4fe7ca17b1317e70d3318a93844
3
+ metadata.gz: 7d95fb8f07ae1876be368009a6d539b30938e7635d95abcbe9e1bbd404e57d0c
4
+ data.tar.gz: a3f31cbd6ca7936381ce13f3baf2c4ba6532431f5066867345a1c6e4c30a69b0
5
5
  SHA512:
6
- metadata.gz: 2405b7818d6a8028375fb614ec0d187faf01088996e8d0754b3e7882ebc4adb2846cd4bf026de0aba86504c63264a8cdbbaaad740b610ce12661718581b45e04
7
- data.tar.gz: 632fc1794614fa739375e0fe1a4c8b55195f9a51e7b4d5d74f65eadafc157f14b8e391eb7846c84a299634f1fd1e6f5abe64e5b18c253fa0ad276ce495da2c43
6
+ metadata.gz: 8efffc3c3f5468f3e15c7118ce0f50248e74a1e0b359a009022b9c40376c5b41c755c4cb350fbd68a6d90d734acfa23989f790c0143f2f815440983c52425488
7
+ data.tar.gz: b020fd0ca4df79fba65e8efdc7a99da0c6979ec050405b7b9b1fe63091d62cd421725815f46f03da450edf82635336eb7ac8814ce35807f2369252bb18631bd8
data/CHANGELOG.md CHANGED
@@ -7,6 +7,102 @@ 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
+
49
+ ## [0.3.0] - 2026-07-19
50
+
51
+ ### Added
52
+
53
+ - **Comp-off requests**: employees request a credit for working a weekend
54
+ or holiday; admin approval credits the comp-off leave type's balance
55
+ (mark the type under Settings — seeds flag `CO`). New events
56
+ `comp_off.requested/approved/rejected/cancelled`.
57
+ - **Regularization tickets**: forgot to punch? Employees propose the actual
58
+ times with a reason; admin approval writes them onto the day's attendance
59
+ record with the full regularization trail. New events
60
+ `regularization.requested/approved/rejected/cancelled`.
61
+ - **Team board (`/team`)**: everyone-visible who's-in/who's-out for any
62
+ date — punch times, leave badges, hours worked that day and that month.
63
+ - **Team leave notices**: approving a leave now bells + emails the whole
64
+ team ("X is on leave …", matrix row `leave.team_notice`; reasons are
65
+ never broadcast).
66
+ - Approvals screens grew Leaves / Comp-off / Regularization tabs with
67
+ pending counts.
68
+
69
+ ### Fixed
70
+
71
+ - Checkboxes rendered 100%-wide with misplaced labels (the bare
72
+ `input[type]` width rule out-ranked `.hrl-field--check`); checkbox rows
73
+ now size naturally and pick up the accent colour.
74
+ - Hardening from adversarial review: comp-off credits land in the year
75
+ they can be spent (a December Sunday approved in January no longer
76
+ strands the credit on last year's balance); the balance increment locks
77
+ the balance row (no lost update when two admins approve concurrently);
78
+ a partial unique index guarantees one live comp-off request per person
79
+ per date; approval re-checks the calendar (StaleOffDay) after holiday
80
+ edits; regularization approval refuses merges that would corrupt the
81
+ record (checkout with no check-in, checkout before the genuine
82
+ check-in) with the real reason surfaced to the admin, keeps GPS flags,
83
+ and writes an AuditLog row like the manual fix path; tickets cannot
84
+ target a day covered by approved leave; only one leave type can carry
85
+ the comp-off flag; team notices skip exited staff; a host
86
+ notification-matrix override pinned on an older version no longer
87
+ silently drops events it doesn't know (defaults merge underneath).
88
+
89
+ ### Changed
90
+
91
+ - `Seeds.run!` no longer re-flags CO as comp-off on every deploy (an
92
+ operator disabling comp-off stays disabled); pre-0.3.0 installs run
93
+ `Seeds.seed_comp_off_flag!` once or tick the box under Settings.
94
+
95
+ ## [0.2.2] - 2026-07-19
96
+
97
+ ### Fixed
98
+
99
+ - Link-styled controls were unreadable: the global `.hrl-body a` colour
100
+ rule out-ranked component classes, so primary action links ("New
101
+ structure", "New run", "Add office", "Apply", "Download PDF", active
102
+ filter chips) rendered accent-on-accent with invisible text, and the
103
+ side-nav links lost their muted colour. The rule is now wrapped in
104
+ `:where()` (zero specificity) so every `.hrl-*` component wins.
105
+
10
106
  ## [0.2.1] - 2026-07-19
11
107
 
12
108
  ### Added
data/README.md CHANGED
@@ -5,7 +5,8 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](MIT-LICENSE)
6
6
 
7
7
  A lightweight, self-contained HRMS engine for Rails — attendance with
8
- geolocation, leave management, holiday calendar, full Indian payroll
8
+ geolocation, leave management with comp-off requests, regularization tickets,
9
+ an everyone-visible team board, holiday calendar, full Indian payroll
9
10
  (PF/ESI/PT/TDS) with salary-slip PDFs, kudos with @mentions, appraisals and
10
11
  promotions. Think Keka-lite, mounted inside your existing app in a few lines.
11
12
 
@@ -32,7 +32,10 @@
32
32
  -webkit-font-smoothing: antialiased;
33
33
  overflow-x: hidden;
34
34
  }
35
- .hrl-body a { color: var(--hrl-accent); text-decoration: none; }
35
+ /* :where() keeps this at zero specificity so any component class
36
+ (.hrl-btn--primary, .hrl-nav__link, ...) can set its own link colour —
37
+ a plain `.hrl-body a` would out-rank them and paint accent-on-accent. */
38
+ :where(.hrl-body) a { color: var(--hrl-accent); text-decoration: none; }
36
39
  .hrl-shell { display: flex; min-height: 100vh; }
37
40
  .hrl-content { flex: 1; min-width: 0; }
38
41
  .hrl-main {
@@ -170,20 +173,30 @@
170
173
  .hrl-table--stack td { display: flex; justify-content: space-between; gap: .8rem; border-bottom: 1px dashed var(--hrl-line); }
171
174
  .hrl-table--stack td::before { content: attr(data-label); color: var(--hrl-muted); font-size: .78rem; }
172
175
  .hrl-table--stack td.hrl-num { text-align: left; }
176
+ .hrl-table--stack td.hrl-cell--void { display: none; }
173
177
  }
174
178
 
175
179
  /* Forms */
176
180
  .hrl-field { margin-bottom: .8rem; }
177
181
  .hrl-field label { display: block; font-size: .82rem; font-weight: 600; margin-bottom: .3rem; }
178
182
  .hrl-field .hrl-hint { color: var(--hrl-muted); font-size: .76rem; margin-top: .25rem; }
179
- .hrl-input, .hrl-field input[type], .hrl-field select, .hrl-field textarea {
183
+ /* Full-width text-ish controls only checkboxes/radios/hidden must keep
184
+ their natural size (a bare [type] selector out-ranked .hrl-field--check
185
+ and blew checkboxes up to 100% width). */
186
+ .hrl-input,
187
+ .hrl-field input[type]:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
188
+ .hrl-field select, .hrl-field textarea {
180
189
  width: 100%; min-height: 44px; padding: .55rem .7rem; font: inherit;
181
190
  border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius-sm);
182
191
  background: var(--hrl-card); color: var(--hrl-ink);
183
192
  }
184
193
  .hrl-field textarea { min-height: 90px; resize: vertical; }
185
- .hrl-field--check { display: flex; align-items: center; gap: .5rem; }
186
- .hrl-field--check input { width: 1.15rem; height: 1.15rem; min-height: 0; }
194
+ .hrl-field--check { display: flex; align-items: center; gap: .55rem; min-height: 44px; }
195
+ .hrl-field--check input {
196
+ width: 1.15rem; height: 1.15rem; min-height: 0; flex: 0 0 auto;
197
+ accent-color: var(--hrl-accent);
198
+ }
199
+ .hrl-field--check label { margin-bottom: 0; font-weight: 600; }
187
200
  .hrl-errors { background: var(--hrl-bad-bg); color: var(--hrl-bad); border-radius: var(--hrl-radius-sm); padding: .7rem .9rem; margin-bottom: .85rem; font-size: .86rem; }
188
201
  .hrl-errors ul { margin: .3rem 0 0; padding-left: 1.1rem; }
189
202
  .hrl-form-actions { display: flex; gap: .6rem; margin-top: 1rem; }
@@ -228,6 +241,24 @@
228
241
  .hrl-pager { display: flex; align-items: center; justify-content: center; gap: .8rem; margin: 1rem 0; font-size: .88rem; }
229
242
  .hrl-pager__info { color: var(--hrl-muted); }
230
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
+
231
262
  /* Definition list */
232
263
  .hrl-deflist { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: .35rem .9rem; font-size: .9rem; }
233
264
  .hrl-deflist dt { color: var(--hrl-muted); }
@@ -0,0 +1,38 @@
1
+ module HrLite
2
+ module Admin
3
+ class CompOffRequestsController < BaseController
4
+ def index
5
+ @status = params[:status].presence_in(CompOffRequest::STATUSES) || "pending"
6
+ @requests = paginate(CompOffRequest.includes(:user).where(status: @status).recent_first)
7
+ end
8
+
9
+ def show
10
+ @request = CompOffRequest.find(params[:id])
11
+ end
12
+
13
+ def approve
14
+ request = CompOffRequest.find(params[:id])
15
+ request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
16
+ redirect_to admin_comp_off_requests_path,
17
+ notice: "Approved — #{request.credit_days.to_f} day credited."
18
+ rescue CompOffRequest::MissingCompOffType, CompOffRequest::StaleOffDay => e
19
+ redirect_to admin_comp_off_request_path(request), alert: e.message
20
+ rescue ActiveRecord::RecordInvalid
21
+ redirect_to admin_comp_off_request_path(request), alert: "Only pending requests can be decided."
22
+ end
23
+
24
+ def reject
25
+ request = CompOffRequest.find(params[:id])
26
+ note = params[:decision_note].to_s.strip
27
+ if note.blank?
28
+ return redirect_to admin_comp_off_request_path(request), alert: "A note is required to reject."
29
+ end
30
+
31
+ request.reject!(actor: hr_current_user, note: note)
32
+ redirect_to admin_comp_off_requests_path, notice: "Request rejected."
33
+ rescue ActiveRecord::RecordInvalid
34
+ redirect_to admin_comp_off_request_path(request), alert: "Only pending requests can be decided."
35
+ end
36
+ end
37
+ end
38
+ end
@@ -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
@@ -46,7 +46,7 @@ module HrLite
46
46
 
47
47
  def leave_type_params
48
48
  params.require(:leave_type)
49
- .permit(:name, :code, :color, :paid, :annual_quota, :accrual,
49
+ .permit(:name, :code, :color, :paid, :comp_off, :annual_quota, :accrual,
50
50
  :carry_forward_cap, :active, :position)
51
51
  end
52
52
  end
@@ -0,0 +1,38 @@
1
+ module HrLite
2
+ module Admin
3
+ class RegularizationRequestsController < BaseController
4
+ def index
5
+ @status = params[:status].presence_in(RegularizationRequest::STATUSES) || "pending"
6
+ @requests = paginate(RegularizationRequest.includes(:user).where(status: @status).recent_first)
7
+ end
8
+
9
+ def show
10
+ @request = RegularizationRequest.find(params[:id])
11
+ end
12
+
13
+ def approve
14
+ request = RegularizationRequest.find(params[:id])
15
+ request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
16
+ redirect_to admin_regularization_requests_path, notice: "Ticket approved — attendance fixed."
17
+ rescue RegularizationRequest::InvalidMerge => e
18
+ redirect_to admin_regularization_request_path(request),
19
+ alert: "Cannot apply — #{e.message}. Fix the day manually or reject with a note."
20
+ rescue ActiveRecord::RecordInvalid
21
+ redirect_to admin_regularization_request_path(request), alert: "Only pending tickets can be decided."
22
+ end
23
+
24
+ def reject
25
+ request = RegularizationRequest.find(params[:id])
26
+ note = params[:decision_note].to_s.strip
27
+ if note.blank?
28
+ return redirect_to admin_regularization_request_path(request), alert: "A note is required to reject."
29
+ end
30
+
31
+ request.reject!(actor: hr_current_user, note: note)
32
+ redirect_to admin_regularization_requests_path, notice: "Ticket rejected."
33
+ rescue ActiveRecord::RecordInvalid
34
+ redirect_to admin_regularization_request_path(request), alert: "Only pending tickets can be decided."
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,46 @@
1
+ module HrLite
2
+ # Employee-side comp-off: request a credit for working a weekend/holiday.
3
+ # Scoping to hr_current_user IS the authorization.
4
+ class CompOffRequestsController < ApplicationController
5
+ def index
6
+ @requests = paginate(CompOffRequest.where(user_id: hr_current_user.id).recent_first)
7
+ @comp_off_type = LeaveType.comp_off_type
8
+ end
9
+
10
+ def new
11
+ @request = CompOffRequest.new(date_worked: default_date)
12
+ end
13
+
14
+ def create
15
+ @request = CompOffRequest.new(request_params.merge(user_id: hr_current_user.id))
16
+ if @request.save
17
+ redirect_to comp_off_requests_path, notice: "Comp-off request sent for approval."
18
+ else
19
+ render :new, status: :unprocessable_entity
20
+ end
21
+ rescue ActiveRecord::RecordNotUnique
22
+ @request.errors.add(:date_worked, "already has a comp-off request")
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+
26
+ def cancel
27
+ request = CompOffRequest.where(user_id: hr_current_user.id).find(params[:id])
28
+ request.cancel!(actor: hr_current_user)
29
+ redirect_to comp_off_requests_path, notice: "Request cancelled."
30
+ rescue ActiveRecord::RecordInvalid
31
+ redirect_to comp_off_requests_path, alert: "Only pending requests can be cancelled."
32
+ end
33
+
34
+ private
35
+
36
+ def request_params
37
+ params.require(:comp_off_request).permit(:date_worked, :half_day, :reason)
38
+ end
39
+
40
+ # Most recent non-working day — the day they most likely worked extra.
41
+ def default_date
42
+ calendar = WorkingCalendar.new((Date.current - 14)..Date.current)
43
+ (0..14).map { |i| Date.current - i }.find { |d| !calendar.working_day?(d) } || Date.current
44
+ end
45
+ end
46
+ 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
@@ -0,0 +1,36 @@
1
+ module HrLite
2
+ # Employee-side regularization tickets: propose punch times for a day you
3
+ # forgot to check in/out. Scoping to hr_current_user IS the authorization.
4
+ class RegularizationRequestsController < ApplicationController
5
+ def index
6
+ @requests = paginate(RegularizationRequest.where(user_id: hr_current_user.id).recent_first)
7
+ end
8
+
9
+ def new
10
+ @request = RegularizationRequest.new(date: parse_date_param(params[:date]))
11
+ end
12
+
13
+ def create
14
+ @request = RegularizationRequest.new(request_params.merge(user_id: hr_current_user.id))
15
+ if @request.save
16
+ redirect_to regularization_requests_path, notice: "Ticket raised — an admin will review it."
17
+ else
18
+ render :new, status: :unprocessable_entity
19
+ end
20
+ end
21
+
22
+ def cancel
23
+ request = RegularizationRequest.where(user_id: hr_current_user.id).find(params[:id])
24
+ request.cancel!(actor: hr_current_user)
25
+ redirect_to regularization_requests_path, notice: "Ticket cancelled."
26
+ rescue ActiveRecord::RecordInvalid
27
+ redirect_to regularization_requests_path, alert: "Only pending tickets can be cancelled."
28
+ end
29
+
30
+ private
31
+
32
+ def request_params
33
+ params.require(:regularization_request).permit(:date, :check_in_at, :check_out_at, :reason)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,11 @@
1
+ module HrLite
2
+ # The everyone-visible team board: who's in, who's out, who's on leave,
3
+ # hours worked — for any date. Transparency by design; shows punch times
4
+ # and leave types only, never reasons or personal data.
5
+ class TeamController < ApplicationController
6
+ def show
7
+ @date = parse_date_param(params[:date])
8
+ @board = TeamDay.new(date: @date)
9
+ end
10
+ end
11
+ end
@@ -2,9 +2,11 @@ module HrLite
2
2
  module ApplicationHelper
3
3
  NAV_ITEMS = [
4
4
  { label: "Home", path: :root_path, match: [ "/" ] },
5
- { label: "Attendance", path: :attendance_path, match: [ "/attendance" ] },
6
- { label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances" ] },
5
+ { label: "Attendance", path: :attendance_path, match: [ "/attendance", "/regularization_requests" ] },
6
+ { label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances", "/comp_off_requests" ] },
7
+ { label: "Team", path: :team_path, match: [ "/team" ] },
7
8
  { label: "Calendar", path: :calendar_path, match: [ "/calendar", "/holidays" ] },
9
+ { label: "Org", path: :org_path, match: [ "/org" ] },
8
10
  { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
9
11
  { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
10
12
  { label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] }
@@ -13,7 +15,7 @@ module HrLite
13
15
  ADMIN_NAV_ITEMS = [
14
16
  { label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
15
17
  { label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
16
- { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances" ] }
18
+ { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] }
17
19
  ].freeze
18
20
 
19
21
  LEADERSHIP_NAV_ITEMS = [
@@ -85,5 +87,62 @@ module HrLite
85
87
  def hrl_amount_in_words(amount)
86
88
  HrLite::AmountInWords.words(amount)
87
89
  end
90
+
91
+ def hrl_duration(seconds)
92
+ return "\u2014" if seconds.nil?
93
+
94
+ total = seconds.to_i
95
+ format("%dh %02dm", total / 3600, (total % 3600) / 60)
96
+ end
97
+
98
+ # Status badge for a TeamDay row — punch times over vague labels.
99
+ def hrl_team_status(row)
100
+ case row.kind
101
+ when :holiday then hrl_status_badge("Holiday", "hrl-badge--muted", worked_hint(row))
102
+ when :weekend then hrl_status_badge("Weekend", "hrl-badge--muted", worked_hint(row))
103
+ when :leave then hrl_status_badge("On leave (#{row.leave.leave_type.code})", "hrl-badge--warn")
104
+ when :half_day_leave then hrl_status_badge("Half-day leave (#{row.leave.leave_type.code})", "hrl-badge--warn", worked_hint(row))
105
+ when :upcoming then hrl_status_badge("\u2014", "hrl-badge--muted")
106
+ when :absent
107
+ label = row.date_today? ? "Not in yet" : "Absent"
108
+ hrl_status_badge(label, "hrl-badge--bad")
109
+ else # :present / :half_day punch
110
+ record = row.record
111
+ if record.check_out_at
112
+ hrl_status_badge("Done #{record.check_in_at.strftime('%H:%M')}\u2013#{record.check_out_at.strftime('%H:%M')}", "hrl-badge--ok")
113
+ elsif row.date_today?
114
+ hrl_status_badge("In since #{record.check_in_at.strftime('%H:%M')}", "hrl-badge--ok")
115
+ else
116
+ hrl_status_badge("In #{record.check_in_at.strftime('%H:%M')}, no check-out", "hrl-badge--warn")
117
+ end
118
+ end
119
+ end
120
+
121
+ # Status pill for a request lifecycle string — one colour map for every
122
+ # list and show screen.
123
+ def hrl_request_status_badge(status)
124
+ css = { "approved" => "hrl-badge--ok", "rejected" => "hrl-badge--bad",
125
+ "cancelled" => "hrl-badge--muted" }[status]
126
+ content_tag(:span, status.humanize, class: "hrl-badge #{css}")
127
+ end
128
+
129
+ private
130
+
131
+ # Always ONE root node — these land inside stacked-table flex cells,
132
+ # where a second sibling would be flung to the far edge.
133
+ def hrl_status_badge(label, css, hint = nil)
134
+ badge = content_tag(:span, label, class: "hrl-badge #{css}")
135
+ return badge unless hint
136
+
137
+ content_tag(:span, safe_join([ badge, " ", content_tag(:span, hint, class: "hrl-small hrl-muted") ]))
138
+ end
139
+
140
+ # A punch on a holiday/weekend/half-day-leave is worth surfacing —
141
+ # that's exactly what comp-off requests point at.
142
+ def worked_hint(row)
143
+ return nil unless row.record&.check_in_at
144
+
145
+ "worked #{row.record.check_in_at.strftime('%H:%M')}#{row.record.check_out_at ? "\u2013#{row.record.check_out_at.strftime('%H:%M')}" : ""}"
146
+ end
88
147
  end
89
148
  end
@@ -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