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
@@ -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 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
+ def credit_balance!(type)
109
+ year = LeaveYear.current_key
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
@@ -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
@@ -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
 
@@ -96,7 +97,7 @@ module HrLite
96
97
  end
97
98
 
98
99
  def balance
99
- LeaveBalance.for(user, leave_type, start_date.year)
100
+ LeaveBalance.for(user, leave_type, LeaveYear.key_for(start_date))
100
101
  end
101
102
 
102
103
  private
@@ -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",
@@ -154,11 +172,11 @@ module HrLite
154
172
  errors.add(:end_date, "must be on or after the start date") if end_date < start_date
155
173
  end
156
174
 
157
- def same_calendar_year
175
+ def same_leave_year
158
176
  return unless start_date && end_date
159
177
 
160
- if start_date.year != end_date.year
161
- 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")
162
180
  end
163
181
  end
164
182
 
@@ -12,11 +12,28 @@ module HrLite
12
12
  validates :accrual, inclusion: { in: ACCRUALS }
13
13
  validates :annual_quota, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
14
14
  validates :carry_forward_cap, numericality: { greater_than_or_equal_to: 0 }
15
+ validate :single_comp_off_type
15
16
 
16
17
  scope :active, -> { where(active: true).order(:position, :id) }
17
18
 
19
+ # The type approved comp-off requests credit into (Settings marks it).
20
+ def self.comp_off_type
21
+ active.find_by(comp_off: true)
22
+ end
23
+
18
24
  def unlimited?
19
25
  annual_quota.nil?
20
26
  end
27
+
28
+ private
29
+
30
+ # Approvals credit into THE comp-off type — two flagged types would
31
+ # make the credit target position-order roulette.
32
+ def single_comp_off_type
33
+ return unless comp_off
34
+
35
+ clash = self.class.where(comp_off: true).where.not(id: id)
36
+ errors.add(:comp_off, "is already set on #{clash.first.name}") if clash.exists?
37
+ end
21
38
  end
22
39
  end
@@ -0,0 +1,181 @@
1
+ module HrLite
2
+ # "I forgot to punch — please fix that day." The employee proposes the
3
+ # times; an admin approves, which applies them to the attendance record
4
+ # with the full regularization audit trail (same fields the manual admin
5
+ # fix writes), or rejects with a note.
6
+ class RegularizationRequest < ApplicationRecord
7
+ STATUSES = %w[pending approved rejected cancelled].freeze
8
+
9
+ belongs_to :user, class_name: HrLite.config.user_class
10
+ belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
11
+
12
+ validates :date, :reason, presence: true
13
+ validates :status, inclusion: { in: STATUSES }
14
+
15
+ with_options on: :create do
16
+ validate :date_not_in_future
17
+ validate :at_least_one_time
18
+ validate :times_fall_on_date
19
+ validate :checkout_after_checkin
20
+ validate :no_duplicate_pending
21
+ validate :no_approved_leave_conflict
22
+ end
23
+
24
+ after_create :notify_requested
25
+
26
+ scope :pending, -> { where(status: "pending") }
27
+ scope :recent_first, -> { order(date: :desc, id: :desc) }
28
+
29
+ STATUSES.each do |s|
30
+ define_method("#{s}?") { status == s }
31
+ end
32
+
33
+ def times_label
34
+ [ check_in_at&.strftime("%H:%M"), check_out_at&.strftime("%H:%M") ].compact.join(" – ").presence || "—"
35
+ end
36
+
37
+ # Current punch state for the approver's context.
38
+ def punch
39
+ AttendanceRecord.find_by(user_id: user_id, date: date)
40
+ end
41
+
42
+ # --- transitions -------------------------------------------------------
43
+
44
+ # Applies the proposed times to the day's record (creating it if the
45
+ # person never punched at all). Only the provided times overwrite —
46
+ # a forgot-checkout ticket keeps the genuine GPS check-in untouched.
47
+ # A merge that would produce a nonsense record (checkout before the
48
+ # existing check-in, or a checkout with no check-in at all) raises
49
+ # InvalidMerge with the real story so the admin knows what to fix.
50
+ def approve!(actor:, note: nil)
51
+ transition!("approved", actor, note) do
52
+ record = AttendanceRecord.find_or_initialize_by(user_id: user_id, date: date)
53
+ record.check_in_at = check_in_at if check_in_at
54
+ record.check_out_at = check_out_at if check_out_at
55
+ if record.check_in_at.nil?
56
+ raise InvalidMerge, "the day has no check-in — the ticket needs a check-in time too"
57
+ end
58
+
59
+ record.status = "present" if record.status.blank?
60
+ record.regularized_by_id = actor.id
61
+ record.regularized_at = Time.current
62
+ record.regularization_note = "Ticket ##{id}: #{reason}"
63
+ raise InvalidMerge, record.errors.full_messages.to_sentence unless record.valid?
64
+
65
+ record.save!
66
+ AuditLog.create!(
67
+ actor: actor, action: "regularize",
68
+ subject_type: record.class.name, subject_id: record.id,
69
+ audited_changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
70
+ )
71
+ end
72
+
73
+ Notifications.publish(
74
+ "regularization.approved",
75
+ title: "Attendance fixed for #{date.strftime('%d %b')} (#{times_label})",
76
+ body: decision_note.presence,
77
+ path: "/regularization_requests",
78
+ bell_to: [ user ], email_to: [ user ]
79
+ )
80
+ true
81
+ end
82
+
83
+ def reject!(actor:, note:)
84
+ transition!("rejected", actor, note)
85
+ Notifications.publish(
86
+ "regularization.rejected",
87
+ title: "Regularization rejected — #{date.strftime('%d %b')}",
88
+ body: decision_note.presence,
89
+ path: "/regularization_requests",
90
+ bell_to: [ user ], email_to: [ user ]
91
+ )
92
+ true
93
+ end
94
+
95
+ def cancel!(actor:)
96
+ transition!("cancelled", actor, nil)
97
+ Notifications.publish(
98
+ "regularization.cancelled",
99
+ title: "#{HrLite.display_name(user)} cancelled a regularization ticket (#{date.strftime('%d %b')})",
100
+ path: "/admin/regularization_requests",
101
+ bell_to: HrLite.admin_users
102
+ )
103
+ true
104
+ end
105
+
106
+ # The merged attendance record would be invalid — surfaced to the
107
+ # deciding admin verbatim; the ticket stays pending.
108
+ class InvalidMerge < StandardError; end
109
+
110
+ private
111
+
112
+ def transition!(new_status, actor, note)
113
+ with_lock do
114
+ raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
115
+
116
+ yield if block_given?
117
+ self.status = new_status
118
+ self.decided_by_id = actor.id
119
+ self.decided_at = Time.current
120
+ self.decision_note = note
121
+ save!
122
+ end
123
+ end
124
+
125
+ def date_not_in_future
126
+ return unless date
127
+
128
+ errors.add(:date, "cannot be in the future") if date > Date.current
129
+ end
130
+
131
+ def at_least_one_time
132
+ return if check_in_at || check_out_at
133
+
134
+ errors.add(:base, "Propose a check-in time, a check-out time, or both")
135
+ end
136
+
137
+ def times_fall_on_date
138
+ return unless date
139
+
140
+ [ [ :check_in_at, check_in_at ], [ :check_out_at, check_out_at ] ].each do |attr, time|
141
+ next unless time
142
+
143
+ errors.add(attr, "must be on #{date.strftime('%d %b')}") unless time.to_date == date
144
+ end
145
+ end
146
+
147
+ def checkout_after_checkin
148
+ return unless check_in_at && check_out_at
149
+ return if check_out_at > check_in_at
150
+
151
+ errors.add(:check_out_at, "must be after check-in")
152
+ end
153
+
154
+ def no_duplicate_pending
155
+ return unless date
156
+
157
+ clash = self.class.pending.where(user_id: user_id, date: date).where.not(id: id)
158
+ errors.add(:date, "already has a pending ticket") if clash.exists?
159
+ end
160
+
161
+ # A punch on a day covered by approved full-day leave would contradict
162
+ # the leave (which wins in every display and still burns the balance).
163
+ def no_approved_leave_conflict
164
+ return unless date
165
+
166
+ leave = LeaveRequest.active_on(date).where(user_id: user_id, half_day: false)
167
+ errors.add(:date, "is covered by your approved leave — cancel the leave first") if leave.exists?
168
+ end
169
+
170
+ def notify_requested
171
+ Notifications.publish(
172
+ "regularization.requested",
173
+ title: "#{HrLite.display_name(user)} raised a regularization ticket for #{date.strftime('%d %b')} " \
174
+ "(#{times_label})",
175
+ body: reason.presence,
176
+ path: "/admin/regularization_requests/#{id}",
177
+ bell_to: HrLite.admin_users
178
+ )
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,84 @@
1
+ module HrLite
2
+ # One row per staff member for a single date — powers the everyone-visible
3
+ # Team board: who's in, who's out, who's on leave, hours worked. Batch
4
+ # queries (five total, regardless of team size); the per-day precedence
5
+ # mirrors DayStatus (holiday > weekend > leave > punch > absent/upcoming).
6
+ class TeamDay
7
+ Row = Struct.new(:user, :profile, :kind, :record, :leave,
8
+ :day_seconds, :month_seconds, :date, keyword_init: true) do
9
+ def date_today?
10
+ date == Date.current
11
+ end
12
+ end
13
+
14
+ def initialize(date: Date.current)
15
+ @date = date
16
+ @calendar = WorkingCalendar.new(date..date)
17
+ end
18
+
19
+ attr_reader :date, :calendar
20
+
21
+ def rows
22
+ @rows ||= build_rows
23
+ end
24
+
25
+ def kpis
26
+ {
27
+ checked_in: rows.count { |r| r.record&.check_in_at },
28
+ on_leave: rows.count { |r| %i[leave half_day_leave].include?(r.kind) },
29
+ not_in: rows.count { |r| r.kind == :absent }
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def build_rows
36
+ users = HrLite.employees
37
+ ids = users.map(&:id)
38
+ profiles = EmployeeProfile.where(user_id: ids).index_by(&:user_id)
39
+ records = AttendanceRecord.for_date(@date).where(user_id: ids).index_by(&:user_id)
40
+ leaves = LeaveRequest.active_on(@date).includes(:leave_type)
41
+ .where(user_id: ids).index_by(&:user_id)
42
+ month_records = AttendanceRecord.for_month(@date).where(user_id: ids).group_by(&:user_id)
43
+
44
+ users.filter_map do |user|
45
+ profile = profiles[user.id]
46
+ next if profile && !profile.active_on?(@date)
47
+
48
+ record = records[user.id]
49
+ leave = leaves[user.id]
50
+ Row.new(
51
+ user: user, profile: profile,
52
+ kind: kind_for(record, leave),
53
+ record: record, leave: leave,
54
+ day_seconds: seconds_worked(record),
55
+ month_seconds: (month_records[user.id] || []).sum { |r| seconds_worked(r) || 0 },
56
+ date: @date
57
+ )
58
+ end
59
+ end
60
+
61
+ def kind_for(record, leave)
62
+ return :holiday if @calendar.holiday?(@date)
63
+ return :weekend if @calendar.weekend?(@date)
64
+ return (leave.half_day ? :half_day_leave : :leave) if leave
65
+
66
+ if record&.check_in_at
67
+ record.status.to_sym
68
+ elsif @date > Date.current
69
+ :upcoming
70
+ else
71
+ :absent
72
+ end
73
+ end
74
+
75
+ # Finished punch → actual duration; still checked in today → running
76
+ # clock, so the board answers "how long have they been in so far".
77
+ def seconds_worked(record)
78
+ return nil unless record&.check_in_at
79
+ return record.worked_duration if record.check_out_at
80
+
81
+ record.date == Date.current ? Time.current - record.check_in_at : nil
82
+ end
83
+ end
84
+ end