hr_lite 0.2.0 → 0.3.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +2 -1
  4. data/app/assets/stylesheets/hr_lite/hr_lite.css +17 -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 +4 -1
  7. data/app/controllers/hr_lite/admin/leave_types_controller.rb +1 -1
  8. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +38 -0
  9. data/app/controllers/hr_lite/comp_off_requests_controller.rb +46 -0
  10. data/app/controllers/hr_lite/regularization_requests_controller.rb +36 -0
  11. data/app/controllers/hr_lite/team_controller.rb +11 -0
  12. data/app/helpers/hr_lite/application_helper.rb +61 -3
  13. data/app/mailers/hr_lite/event_mailer.rb +2 -2
  14. data/app/models/hr_lite/comp_off_request.rb +177 -0
  15. data/app/models/hr_lite/leave_request.rb +18 -0
  16. data/app/models/hr_lite/leave_type.rb +17 -0
  17. data/app/models/hr_lite/regularization_request.rb +181 -0
  18. data/app/services/hr_lite/team_day.rb +84 -0
  19. data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +37 -0
  20. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +47 -0
  21. data/app/views/hr_lite/admin/employees/_form.html.erb +3 -1
  22. data/app/views/hr_lite/admin/leave_requests/index.html.erb +2 -0
  23. data/app/views/hr_lite/admin/leave_types/_form.html.erb +4 -0
  24. data/app/views/hr_lite/admin/regularization_requests/index.html.erb +37 -0
  25. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +50 -0
  26. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +11 -0
  27. data/app/views/hr_lite/attendance/show.html.erb +3 -0
  28. data/app/views/hr_lite/comp_off_requests/index.html.erb +42 -0
  29. data/app/views/hr_lite/comp_off_requests/new.html.erb +28 -0
  30. data/app/views/hr_lite/leave_requests/index.html.erb +1 -0
  31. data/app/views/hr_lite/regularization_requests/index.html.erb +41 -0
  32. data/app/views/hr_lite/regularization_requests/new.html.erb +30 -0
  33. data/app/views/hr_lite/team/show.html.erb +48 -0
  34. data/app/views/layouts/hr_lite/application.html.erb +1 -1
  35. data/config/routes.rb +13 -0
  36. data/db/migrate/20260719104315_add_comp_off_to_hr_lite_leave_types.rb +6 -0
  37. data/db/migrate/20260719104316_create_hr_lite_comp_off_requests.rb +24 -0
  38. data/db/migrate/20260719104317_create_hr_lite_regularization_requests.rb +20 -0
  39. data/lib/hr_lite/configuration.rb +5 -1
  40. data/lib/hr_lite/notifications.rb +19 -6
  41. data/lib/hr_lite/seeds.rb +16 -1
  42. data/lib/hr_lite/version.rb +1 -1
  43. data/lib/hr_lite.rb +7 -0
  44. metadata +22 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a85f50e1151c664825892bd973f34842d1330e4878e39bb4984233880258c446
4
- data.tar.gz: 95012fa0d0b7332e35e1fdef90ace239a95b415cbdc9d3101cb86fac18de9f7c
3
+ metadata.gz: d4ea6b78faec5dd0012db2be857b1874707c5085e99c54114d611893122be1a0
4
+ data.tar.gz: 0572af1000548da1d1d66699939360827dd8ef6fa1cc420ed236dfb90fd69a25
5
5
  SHA512:
6
- metadata.gz: 6b532c9f0fe3b5408eea1e847c40329ab28da2a4c996d7cb229d1d745187fe33b5ace2fb17b8d1ea1ec1fc2e326cfc91dfd8881d296197a9d80bb40ded90a6f2
7
- data.tar.gz: 64dfd72f81d71bd48ce01a4815c95360994dfb6b06235933ba893bb8245bdf4bd79ab384d7f7ca441fcec3ab158861c73d9bf8485b2478811e7df90d67443bb9
6
+ metadata.gz: 79300a2187ebae2eaa26a51a4c0d8845a380cfdb6787dd2df0e3d54a18a0da32b2668586f4cd2c4f4c5d5644ff7c4a5979ab60faf69c94c8a6ee1db7bf18f91a
7
+ data.tar.gz: 91442183160220175e78f0951917da321b4c555f1384772dc08cbc9afc48c7ae0fdaaa7d59591181b72bb8358a75b4e20f941283860b1a755b8aa5eddc1e3729
data/CHANGELOG.md CHANGED
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-07-19
11
+
12
+ ### Added
13
+
14
+ - **Comp-off requests**: employees request a credit for working a weekend
15
+ or holiday; admin approval credits the comp-off leave type's balance
16
+ (mark the type under Settings — seeds flag `CO`). New events
17
+ `comp_off.requested/approved/rejected/cancelled`.
18
+ - **Regularization tickets**: forgot to punch? Employees propose the actual
19
+ times with a reason; admin approval writes them onto the day's attendance
20
+ record with the full regularization trail. New events
21
+ `regularization.requested/approved/rejected/cancelled`.
22
+ - **Team board (`/team`)**: everyone-visible who's-in/who's-out for any
23
+ date — punch times, leave badges, hours worked that day and that month.
24
+ - **Team leave notices**: approving a leave now bells + emails the whole
25
+ team ("X is on leave …", matrix row `leave.team_notice`; reasons are
26
+ never broadcast).
27
+ - Approvals screens grew Leaves / Comp-off / Regularization tabs with
28
+ pending counts.
29
+
30
+ ### Fixed
31
+
32
+ - Checkboxes rendered 100%-wide with misplaced labels (the bare
33
+ `input[type]` width rule out-ranked `.hrl-field--check`); checkbox rows
34
+ now size naturally and pick up the accent colour.
35
+ - Hardening from adversarial review: comp-off credits land in the year
36
+ they can be spent (a December Sunday approved in January no longer
37
+ strands the credit on last year's balance); the balance increment locks
38
+ the balance row (no lost update when two admins approve concurrently);
39
+ a partial unique index guarantees one live comp-off request per person
40
+ per date; approval re-checks the calendar (StaleOffDay) after holiday
41
+ edits; regularization approval refuses merges that would corrupt the
42
+ record (checkout with no check-in, checkout before the genuine
43
+ check-in) with the real reason surfaced to the admin, keeps GPS flags,
44
+ and writes an AuditLog row like the manual fix path; tickets cannot
45
+ target a day covered by approved leave; only one leave type can carry
46
+ the comp-off flag; team notices skip exited staff; a host
47
+ notification-matrix override pinned on an older version no longer
48
+ silently drops events it doesn't know (defaults merge underneath).
49
+
50
+ ### Changed
51
+
52
+ - `Seeds.run!` no longer re-flags CO as comp-off on every deploy (an
53
+ operator disabling comp-off stays disabled); pre-0.3.0 installs run
54
+ `Seeds.seed_comp_off_flag!` once or tick the box under Settings.
55
+
56
+ ## [0.2.2] - 2026-07-19
57
+
58
+ ### Fixed
59
+
60
+ - Link-styled controls were unreadable: the global `.hrl-body a` colour
61
+ rule out-ranked component classes, so primary action links ("New
62
+ structure", "New run", "Add office", "Apply", "Download PDF", active
63
+ filter chips) rendered accent-on-accent with invisible text, and the
64
+ side-nav links lost their muted colour. The rule is now wrapped in
65
+ `:where()` (zero specificity) so every `.hrl-*` component wins.
66
+
67
+ ## [0.2.1] - 2026-07-19
68
+
69
+ ### Added
70
+
71
+ - Email-invite onboarding: leave the starting password blank and the
72
+ welcome email carries a set-your-password link from the host's
73
+ `invite_url_for` hook; `Notifications.publish`/`EventMailer.event`
74
+ accept an absolute `link_url` for tokenized URLs. (Intended for 0.2.0;
75
+ missed the merge window.)
76
+
10
77
  ## [0.2.0] - 2026-07-19
11
78
 
12
79
  ### 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; }
@@ -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
@@ -36,11 +36,14 @@ module HrLite
36
36
 
37
37
  if @profile.save
38
38
  if new_login
39
+ invite_url = HrLite.config.invite_url_for&.call(@profile.user)
39
40
  Notifications.publish(
40
41
  "employee.onboarded",
41
42
  title: "Welcome aboard — your HR account is ready",
42
- body: "Sign in with your email; your manager has your starting password.",
43
+ body: invite_url ? "Set your password with the button below, then sign in with your email."
44
+ : "Sign in with your email; your manager has your starting password.",
43
45
  path: "/",
46
+ link_url: invite_url,
44
47
  bell_to: [ @profile.user ],
45
48
  email_to: [ @profile.user ]
46
49
  )
@@ -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
@@ -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,8 +2,9 @@ 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" ] },
8
9
  { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
9
10
  { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
@@ -13,7 +14,7 @@ module HrLite
13
14
  ADMIN_NAV_ITEMS = [
14
15
  { label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
15
16
  { 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" ] }
17
+ { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] }
17
18
  ].freeze
18
19
 
19
20
  LEADERSHIP_NAV_ITEMS = [
@@ -85,5 +86,62 @@ module HrLite
85
86
  def hrl_amount_in_words(amount)
86
87
  HrLite::AmountInWords.words(amount)
87
88
  end
89
+
90
+ def hrl_duration(seconds)
91
+ return "\u2014" if seconds.nil?
92
+
93
+ total = seconds.to_i
94
+ format("%dh %02dm", total / 3600, (total % 3600) / 60)
95
+ end
96
+
97
+ # Status badge for a TeamDay row — punch times over vague labels.
98
+ def hrl_team_status(row)
99
+ case row.kind
100
+ when :holiday then hrl_status_badge("Holiday", "hrl-badge--muted", worked_hint(row))
101
+ when :weekend then hrl_status_badge("Weekend", "hrl-badge--muted", worked_hint(row))
102
+ when :leave then hrl_status_badge("On leave (#{row.leave.leave_type.code})", "hrl-badge--warn")
103
+ when :half_day_leave then hrl_status_badge("Half-day leave (#{row.leave.leave_type.code})", "hrl-badge--warn", worked_hint(row))
104
+ when :upcoming then hrl_status_badge("\u2014", "hrl-badge--muted")
105
+ when :absent
106
+ label = row.date_today? ? "Not in yet" : "Absent"
107
+ hrl_status_badge(label, "hrl-badge--bad")
108
+ else # :present / :half_day punch
109
+ record = row.record
110
+ if record.check_out_at
111
+ hrl_status_badge("Done #{record.check_in_at.strftime('%H:%M')}\u2013#{record.check_out_at.strftime('%H:%M')}", "hrl-badge--ok")
112
+ elsif row.date_today?
113
+ hrl_status_badge("In since #{record.check_in_at.strftime('%H:%M')}", "hrl-badge--ok")
114
+ else
115
+ hrl_status_badge("In #{record.check_in_at.strftime('%H:%M')}, no check-out", "hrl-badge--warn")
116
+ end
117
+ end
118
+ end
119
+
120
+ # Status pill for a request lifecycle string — one colour map for every
121
+ # list and show screen.
122
+ def hrl_request_status_badge(status)
123
+ css = { "approved" => "hrl-badge--ok", "rejected" => "hrl-badge--bad",
124
+ "cancelled" => "hrl-badge--muted" }[status]
125
+ content_tag(:span, status.humanize, class: "hrl-badge #{css}")
126
+ end
127
+
128
+ private
129
+
130
+ # Always ONE root node — these land inside stacked-table flex cells,
131
+ # where a second sibling would be flung to the far edge.
132
+ def hrl_status_badge(label, css, hint = nil)
133
+ badge = content_tag(:span, label, class: "hrl-badge #{css}")
134
+ return badge unless hint
135
+
136
+ content_tag(:span, safe_join([ badge, " ", content_tag(:span, hint, class: "hrl-small hrl-muted") ]))
137
+ end
138
+
139
+ # A punch on a holiday/weekend/half-day-leave is worth surfacing —
140
+ # that's exactly what comp-off requests point at.
141
+ def worked_hint(row)
142
+ return nil unless row.record&.check_in_at
143
+
144
+ "worked #{row.record.check_in_at.strftime('%H:%M')}#{row.record.check_out_at ? "\u2013#{row.record.check_out_at.strftime('%H:%M')}" : ""}"
145
+ end
88
146
  end
89
147
  end
@@ -3,11 +3,11 @@ module HrLite
3
3
  # body, detail lines, diff table) is assembled by the Notifications bus so
4
4
  # adding an event never means adding a template.
5
5
  class EventMailer < ApplicationMailer
6
- def event(to:, subject:, heading:, body: nil, lines: [], path: nil)
6
+ def event(to:, subject:, heading:, body: nil, lines: [], path: nil, link_url: nil)
7
7
  @heading = heading
8
8
  @body = body
9
9
  @lines = Array(lines)
10
- @cta_url = HrLite::EventMailer.link_for(path)
10
+ @cta_url = link_url.presence || HrLite::EventMailer.link_for(path)
11
11
  mail(to: to, from: HrLite.config.mailer_from, subject: subject)
12
12
  end
13
13
 
@@ -0,0 +1,177 @@
1
+ module HrLite
2
+ # "I worked on a weekend/holiday — credit me a day off." Approval credits
3
+ # the comp-off leave type's balance (an adjustment, so it shows up in the
4
+ # normal balance chips and can be spent through the ordinary leave flow).
5
+ class CompOffRequest < ApplicationRecord
6
+ STATUSES = %w[pending approved rejected cancelled].freeze
7
+
8
+ belongs_to :user, class_name: HrLite.config.user_class
9
+ belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
10
+
11
+ validates :date_worked, :reason, presence: true
12
+ validates :status, inclusion: { in: STATUSES }
13
+
14
+ with_options on: :create do
15
+ validate :date_not_in_future
16
+ validate :date_was_an_off_day
17
+ validate :no_duplicate_for_date
18
+ end
19
+
20
+ after_create :notify_requested
21
+
22
+ scope :pending, -> { where(status: "pending") }
23
+ scope :recent_first, -> { order(date_worked: :desc, id: :desc) }
24
+
25
+ STATUSES.each do |s|
26
+ define_method("#{s}?") { status == s }
27
+ end
28
+
29
+ def credit_days
30
+ half_day ? BigDecimal("0.5") : BigDecimal("1")
31
+ end
32
+
33
+ # Shown to the approver: proof the person actually punched that day.
34
+ def punch
35
+ AttendanceRecord.find_by(user_id: user_id, date: date_worked)
36
+ end
37
+
38
+ # --- transitions -------------------------------------------------------
39
+
40
+ # Credits the comp-off balance inside the row lock, so a double-tap
41
+ # cannot credit twice. Fails loudly when no comp-off type is configured
42
+ # or the day stopped being an off day (holiday edited away).
43
+ def approve!(actor:, note: nil)
44
+ type = LeaveType.comp_off_type
45
+ raise MissingCompOffType if type.nil?
46
+
47
+ transition!("approved", actor, note) do
48
+ raise StaleOffDay if WorkingCalendar.new(date_worked..date_worked).working_day?(date_worked)
49
+
50
+ # (A duplicate live request for the same date cannot exist — the
51
+ # partial unique index on [user_id, date_worked] guarantees it.)
52
+ credit_balance!(type)
53
+ end
54
+
55
+ Notifications.publish(
56
+ "comp_off.approved",
57
+ title: "Comp-off approved — #{credit_days.to_f} day#{'s' if credit_days > 1} credited for #{date_worked.strftime('%d %b')}",
58
+ body: decision_note.presence,
59
+ path: "/comp_off_requests",
60
+ bell_to: [ user ], email_to: [ user ]
61
+ )
62
+ true
63
+ end
64
+
65
+ def reject!(actor:, note:)
66
+ transition!("rejected", actor, note)
67
+ Notifications.publish(
68
+ "comp_off.rejected",
69
+ title: "Comp-off rejected — #{date_worked.strftime('%d %b')}",
70
+ body: decision_note.presence,
71
+ path: "/comp_off_requests",
72
+ bell_to: [ user ], email_to: [ user ]
73
+ )
74
+ true
75
+ end
76
+
77
+ def cancel!(actor:)
78
+ transition!("cancelled", actor, nil)
79
+ Notifications.publish(
80
+ "comp_off.cancelled",
81
+ title: "#{HrLite.display_name(user)} cancelled a comp-off request (#{date_worked.strftime('%d %b')})",
82
+ path: "/admin/comp_off_requests",
83
+ bell_to: HrLite.admin_users
84
+ )
85
+ true
86
+ end
87
+
88
+ class MissingCompOffType < StandardError
89
+ def message
90
+ "No active leave type is marked as comp-off — enable one under Settings → Leave types first."
91
+ end
92
+ end
93
+
94
+ class StaleOffDay < StandardError
95
+ def message
96
+ "That date is now a regular working day (the calendar changed since the request) — " \
97
+ "reject it and ask them to re-file if it still applies."
98
+ end
99
+ end
100
+
101
+ private
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.
108
+ def credit_balance!(type)
109
+ year = [ date_worked.year, Date.current.year ].max
110
+ balance = LeaveBalance.for(user, type, year)
111
+ if balance.new_record?
112
+ begin
113
+ balance.save!
114
+ rescue ActiveRecord::RecordNotUnique
115
+ balance = LeaveBalance.for(user, type, year)
116
+ end
117
+ end
118
+ balance.with_lock do
119
+ balance.adjustment += credit_days
120
+ balance.adjustment_note = [ balance.adjustment_note.presence,
121
+ "+#{credit_days.to_f} comp-off for #{date_worked} (request ##{id})" ].compact.join("; ")
122
+ balance.save!
123
+ end
124
+ end
125
+
126
+ def transition!(new_status, actor, note)
127
+ with_lock do
128
+ raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
129
+
130
+ yield if block_given?
131
+ self.status = new_status
132
+ self.decided_by_id = actor.id
133
+ self.decided_at = Time.current
134
+ self.decision_note = note
135
+ save!
136
+ end
137
+ end
138
+
139
+ def date_not_in_future
140
+ return unless date_worked
141
+
142
+ errors.add(:date_worked, "cannot be in the future") if date_worked > Date.current
143
+ end
144
+
145
+ # Comp-off is for working on a day you were NOT supposed to — a weekend
146
+ # or a holiday (per the company calendar). Extra hours on a working day
147
+ # are not comp-off under the policy.
148
+ def date_was_an_off_day
149
+ return unless date_worked
150
+ return if date_worked > Date.current
151
+
152
+ calendar = WorkingCalendar.new(date_worked..date_worked)
153
+ if calendar.working_day?(date_worked)
154
+ errors.add(:date_worked, "was a regular working day — comp-off is for working on a weekend or holiday")
155
+ end
156
+ end
157
+
158
+ def no_duplicate_for_date
159
+ return unless date_worked
160
+
161
+ clash = self.class.where(user_id: user_id, date_worked: date_worked,
162
+ status: %w[pending approved]).where.not(id: id)
163
+ errors.add(:date_worked, "already has a comp-off request") if clash.exists?
164
+ end
165
+
166
+ def notify_requested
167
+ Notifications.publish(
168
+ "comp_off.requested",
169
+ title: "#{HrLite.display_name(user)} requested comp-off for #{date_worked.strftime('%d %b')} " \
170
+ "(#{half_day ? 'half' : 'full'} day)",
171
+ body: reason.presence,
172
+ path: "/admin/comp_off_requests/#{id}",
173
+ bell_to: HrLite.admin_users
174
+ )
175
+ end
176
+ end
177
+ end
@@ -52,6 +52,7 @@ module HrLite
52
52
  return false if insufficient
53
53
 
54
54
  notify_decision("Leave approved")
55
+ notify_team
55
56
  true
56
57
  end
57
58
 
@@ -132,6 +133,23 @@ module HrLite
132
133
  )
133
134
  end
134
135
 
136
+ # Everyone should know a colleague will be away — bell + email to the
137
+ # whole team (matrix row "leave.team_notice"; hosts can mute either
138
+ # channel). Deliberately excludes the reason: dates are team-relevant,
139
+ # the why is not.
140
+ def notify_team
141
+ team = HrLite.active_employees.reject { |member| member.id == user_id }
142
+ return if team.empty?
143
+
144
+ Notifications.publish(
145
+ "leave.team_notice",
146
+ title: "#{HrLite.display_name(user)} is on leave — #{leave_type.name} (#{date_range_label})",
147
+ path: "/team",
148
+ bell_to: team,
149
+ email_to: team
150
+ )
151
+ end
152
+
135
153
  def notify_decision(title)
136
154
  Notifications.publish(
137
155
  status == "approved" ? "leave.approved" : "leave.rejected",