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
@@ -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
@@ -0,0 +1,37 @@
1
+ <% content_for(:page_title) { "Comp-off approvals" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Comp-off approvals</h1>
4
+ </div>
5
+
6
+ <%= render "hr_lite/admin/shared/approvals_tabs", active: :comp_off %>
7
+
8
+ <div class="hrl-row" style="margin-bottom:.85rem;">
9
+ <span class="hrl-small hrl-muted">Status</span>
10
+ <% HrLite::CompOffRequest::STATUSES.each do |status| %>
11
+ <a class="hrl-btn <%= 'hrl-btn--primary' if @status == status %>"
12
+ href="<%= hr_lite.admin_comp_off_requests_path(status: status) %>"><%= status.humanize %></a>
13
+ <% end %>
14
+ </div>
15
+
16
+ <section class="hrl-card">
17
+ <% if @requests.any? %>
18
+ <div class="hrl-table-wrap">
19
+ <table class="hrl-table hrl-table--stack">
20
+ <thead><tr><th>Employee</th><th>Day worked</th><th>Credit</th><th></th></tr></thead>
21
+ <tbody>
22
+ <% @requests.each do |request| %>
23
+ <tr>
24
+ <td data-label="Employee"><%= hr_display_name(request.user) %></td>
25
+ <td data-label="Day worked"><%= request.date_worked.strftime("%a, %d %b %Y") %></td>
26
+ <td data-label="Credit" class="hrl-num"><%= request.credit_days.to_f %></td>
27
+ <td data-label=""><a class="hrl-btn" href="<%= hr_lite.admin_comp_off_request_path(request) %>">Open</a></td>
28
+ </tr>
29
+ <% end %>
30
+ </tbody>
31
+ </table>
32
+ </div>
33
+ <%= hrl_pagination %>
34
+ <% else %>
35
+ <p class="hrl-card--empty">No <%= @status %> comp-off requests.</p>
36
+ <% end %>
37
+ </section>
@@ -0,0 +1,47 @@
1
+ <% content_for(:page_title) { "Comp-off — #{hr_display_name(@request.user)}" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Comp-off — <%= hr_display_name(@request.user) %></h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn" href="<%= hr_lite.admin_comp_off_requests_path %>">All requests</a>
6
+ </div>
7
+ </div>
8
+
9
+ <section class="hrl-card">
10
+ <dl class="hrl-deflist">
11
+ <dt>Day worked</dt><dd><%= @request.date_worked.strftime("%A, %d %B %Y") %></dd>
12
+ <dt>Credit</dt><dd><%= @request.credit_days.to_f %> day</dd>
13
+ <dt>Reason</dt><dd><%= @request.reason %></dd>
14
+ <dt>Status</dt><dd><%= hrl_request_status_badge(@request.status) %></dd>
15
+ <dt>Punch that day</dt>
16
+ <dd>
17
+ <% if (punch = @request.punch)&.check_in_at %>
18
+ <%= punch.check_in_at.strftime("%H:%M") %><%= punch.check_out_at ? "–#{punch.check_out_at.strftime('%H:%M')}" : " (no check-out)" %>
19
+ <% else %>
20
+ <span class="hrl-muted">No punch recorded — weigh the reason accordingly.</span>
21
+ <% end %>
22
+ </dd>
23
+ <% if @request.decided_at %>
24
+ <dt>Decided</dt>
25
+ <dd><%= hr_display_name(@request.decided_by) %> · <%= @request.decided_at.strftime("%d %b %H:%M") %>
26
+ <% if @request.decision_note.present? %> · <%= @request.decision_note %><% end %></dd>
27
+ <% end %>
28
+ </dl>
29
+ </section>
30
+
31
+ <% if @request.pending? %>
32
+ <section class="hrl-card">
33
+ <h2 class="hrl-card__title">Decide</h2>
34
+ <%= form_with url: hr_lite.approve_admin_comp_off_request_path(@request), method: :post, local: true do |f| %>
35
+ <div class="hrl-field">
36
+ <%= f.label :decision_note, "Note (required to reject, optional to approve)" %>
37
+ <%= f.text_area :decision_note %>
38
+ </div>
39
+ <div class="hrl-form-actions">
40
+ <%= f.button "Approve — credit #{@request.credit_days.to_f} day",
41
+ type: :submit, class: "hrl-btn hrl-btn--primary" %>
42
+ <%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger",
43
+ formaction: hr_lite.reject_admin_comp_off_request_path(@request) %>
44
+ </div>
45
+ <% end %>
46
+ </section>
47
+ <% end %>
@@ -21,8 +21,10 @@
21
21
  <%= f.email_field :new_user_email %>
22
22
  </div>
23
23
  <div class="hrl-field">
24
- <%= f.label :new_user_password, "Starting password (share it with them; they can change it later)" %>
24
+ <%= f.label :new_user_password, "Starting password (optional)" %>
25
25
  <%= f.text_field :new_user_password %>
26
+ <p class="hrl-hint">Leave blank (recommended): they get an email invite and set their own
27
+ password. Fill it only if you want to hand a password over yourself.</p>
26
28
  </div>
27
29
  </fieldset>
28
30
  <% end %>
@@ -3,6 +3,8 @@
3
3
  <h1 class="hrl-page__title">Leave approvals</h1>
4
4
  </div>
5
5
 
6
+ <%= render "hr_lite/admin/shared/approvals_tabs", active: :leaves %>
7
+
6
8
  <div class="hrl-row" style="margin-bottom:.8rem;">
7
9
  <% HrLite::LeaveRequest::STATUSES.each do |status| %>
8
10
  <a class="hrl-btn <%= 'hrl-btn--primary' if @status == status %>"
@@ -19,6 +19,10 @@
19
19
  <div class="hrl-field"><%= f.label :position %><%= f.number_field :position %></div>
20
20
  <div class="hrl-field hrl-field--check"><%= f.check_box :paid %><%= f.label :paid %></div>
21
21
  <div class="hrl-field hrl-field--check"><%= f.check_box :active %><%= f.label :active %></div>
22
+ <div class="hrl-field hrl-field--check">
23
+ <%= f.check_box :comp_off %>
24
+ <%= f.label :comp_off, "Comp-off type (approved comp-off requests credit into this)" %>
25
+ </div>
22
26
  <div class="hrl-form-actions">
23
27
  <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
24
28
  <a class="hrl-btn" href="<%= hr_lite.admin_leave_types_path %>">Cancel</a>
@@ -0,0 +1,37 @@
1
+ <% content_for(:page_title) { "Regularization tickets" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Regularization tickets</h1>
4
+ </div>
5
+
6
+ <%= render "hr_lite/admin/shared/approvals_tabs", active: :regularizations %>
7
+
8
+ <div class="hrl-row" style="margin-bottom:.85rem;">
9
+ <span class="hrl-small hrl-muted">Status</span>
10
+ <% HrLite::RegularizationRequest::STATUSES.each do |status| %>
11
+ <a class="hrl-btn <%= 'hrl-btn--primary' if @status == status %>"
12
+ href="<%= hr_lite.admin_regularization_requests_path(status: status) %>"><%= status.humanize %></a>
13
+ <% end %>
14
+ </div>
15
+
16
+ <section class="hrl-card">
17
+ <% if @requests.any? %>
18
+ <div class="hrl-table-wrap">
19
+ <table class="hrl-table hrl-table--stack">
20
+ <thead><tr><th>Employee</th><th>Date</th><th>Proposed times</th><th></th></tr></thead>
21
+ <tbody>
22
+ <% @requests.each do |request| %>
23
+ <tr>
24
+ <td data-label="Employee"><%= hr_display_name(request.user) %></td>
25
+ <td data-label="Date"><%= request.date.strftime("%a, %d %b %Y") %></td>
26
+ <td data-label="Proposed times"><%= request.times_label %></td>
27
+ <td data-label=""><a class="hrl-btn" href="<%= hr_lite.admin_regularization_request_path(request) %>">Open</a></td>
28
+ </tr>
29
+ <% end %>
30
+ </tbody>
31
+ </table>
32
+ </div>
33
+ <%= hrl_pagination %>
34
+ <% else %>
35
+ <p class="hrl-card--empty">No <%= @status %> tickets.</p>
36
+ <% end %>
37
+ </section>
@@ -0,0 +1,50 @@
1
+ <% content_for(:page_title) { "Ticket — #{hr_display_name(@request.user)}" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Regularization — <%= hr_display_name(@request.user) %></h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn" href="<%= hr_lite.admin_regularization_requests_path %>">All tickets</a>
6
+ <a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@request.user_id, month: @request.date.strftime("%Y-%m")) %>">Their month</a>
7
+ </div>
8
+ </div>
9
+
10
+ <section class="hrl-card">
11
+ <dl class="hrl-deflist">
12
+ <dt>Date</dt><dd><%= @request.date.strftime("%A, %d %B %Y") %></dd>
13
+ <dt>Proposed times</dt><dd><%= @request.times_label %></dd>
14
+ <dt>Reason</dt><dd><%= @request.reason %></dd>
15
+ <dt>Status</dt><dd><%= hrl_request_status_badge(@request.status) %></dd>
16
+ <dt>Current punch</dt>
17
+ <dd>
18
+ <% if (punch = @request.punch)&.check_in_at %>
19
+ <%= punch.check_in_at.strftime("%H:%M") %><%= punch.check_out_at ? "–#{punch.check_out_at.strftime('%H:%M')}" : " (no check-out)" %>
20
+ <% if punch.flagged? %><span class="hrl-badge hrl-badge--bad">Flagged</span><% end %>
21
+ <% else %>
22
+ <span class="hrl-muted">No punch recorded for this day.</span>
23
+ <% end %>
24
+ </dd>
25
+ <% if @request.decided_at %>
26
+ <dt>Decided</dt>
27
+ <dd><%= hr_display_name(@request.decided_by) %> · <%= @request.decided_at.strftime("%d %b %H:%M") %>
28
+ <% if @request.decision_note.present? %> · <%= @request.decision_note %><% end %></dd>
29
+ <% end %>
30
+ </dl>
31
+ </section>
32
+
33
+ <% if @request.pending? %>
34
+ <section class="hrl-card">
35
+ <h2 class="hrl-card__title">Decide</h2>
36
+ <p class="hrl-hint">Approving writes the proposed times onto the day's attendance record
37
+ (only the provided times overwrite) with the full regularization trail.</p>
38
+ <%= form_with url: hr_lite.approve_admin_regularization_request_path(@request), method: :post, local: true do |f| %>
39
+ <div class="hrl-field">
40
+ <%= f.label :decision_note, "Note (required to reject, optional to approve)" %>
41
+ <%= f.text_area :decision_note %>
42
+ </div>
43
+ <div class="hrl-form-actions">
44
+ <%= f.button "Approve — fix attendance", type: :submit, class: "hrl-btn hrl-btn--primary" %>
45
+ <%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger",
46
+ formaction: hr_lite.reject_admin_regularization_request_path(@request) %>
47
+ </div>
48
+ <% end %>
49
+ </section>
50
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <%# locals: active (:leaves | :comp_off | :regularizations) %>
2
+ <div class="hrl-row" style="margin-bottom:.85rem;">
3
+ <a class="hrl-btn <%= 'hrl-btn--primary' if active == :leaves %>"
4
+ href="<%= hr_lite.admin_leave_requests_path %>">Leaves</a>
5
+ <a class="hrl-btn <%= 'hrl-btn--primary' if active == :comp_off %>"
6
+ href="<%= hr_lite.admin_comp_off_requests_path %>">Comp-off
7
+ <% if (n = HrLite::CompOffRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
8
+ <a class="hrl-btn <%= 'hrl-btn--primary' if active == :regularizations %>"
9
+ href="<%= hr_lite.admin_regularization_requests_path %>">Regularization
10
+ <% if (n = HrLite::RegularizationRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
11
+ </div>
@@ -15,6 +15,9 @@
15
15
  </span>
16
16
  </div>
17
17
  <%= render "hr_lite/attendance/month_grid", month: @month, day_status: @day_status %>
18
+ <p class="hrl-small hrl-muted hrl-mt">Forgot to check in or out on some day?
19
+ <a href="<%= hr_lite.new_regularization_request_path %>">Raise a regularization ticket</a>
20
+ · <a href="<%= hr_lite.regularization_requests_path %>">My tickets</a></p>
18
21
  </section>
19
22
 
20
23
  <% content_for :scripts do %>
@@ -0,0 +1,42 @@
1
+ <% content_for(:page_title) { "Comp-off" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">My comp-off</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn" href="<%= hr_lite.leave_requests_path %>">My leaves</a>
6
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_comp_off_request_path %>">Request comp-off</a>
7
+ </div>
8
+ </div>
9
+
10
+ <section class="hrl-card">
11
+ <p class="hrl-small hrl-muted" style="margin-top:0;">
12
+ Worked on a weekend or holiday? Request a comp-off — once an admin approves,
13
+ <%= @comp_off_type ? "your #{@comp_off_type.name} balance is credited" : "a day is credited to your balance" %>
14
+ and you spend it through the normal leave flow.
15
+ </p>
16
+ <% if @requests.any? %>
17
+ <div class="hrl-table-wrap">
18
+ <table class="hrl-table hrl-table--stack">
19
+ <thead><tr><th>Day worked</th><th>Credit</th><th>Status</th><th>Note</th><th></th></tr></thead>
20
+ <tbody>
21
+ <% @requests.each do |request| %>
22
+ <tr>
23
+ <td data-label="Day worked"><%= request.date_worked.strftime("%a, %d %b %Y") %></td>
24
+ <td data-label="Credit" class="hrl-num"><%= request.credit_days.to_f %></td>
25
+ <td data-label="Status"><%= hrl_request_status_badge(request.status) %></td>
26
+ <td data-label="Note" class="hrl-small hrl-muted"><%= request.decision_note.presence || "—" %></td>
27
+ <td data-label="" class="<%= 'hrl-cell--void' unless request.pending? %>">
28
+ <% if request.pending? %>
29
+ <%= button_to "Cancel", hr_lite.cancel_comp_off_request_path(request), method: :post,
30
+ class: "hrl-btn hrl-btn--danger", form: { data: { turbo_confirm: "Cancel this request?" } } %>
31
+ <% end %>
32
+ </td>
33
+ </tr>
34
+ <% end %>
35
+ </tbody>
36
+ </table>
37
+ </div>
38
+ <%= hrl_pagination %>
39
+ <% else %>
40
+ <p class="hrl-card--empty">No comp-off requests yet.</p>
41
+ <% end %>
42
+ </section>
@@ -0,0 +1,28 @@
1
+ <% content_for(:page_title) { "Request comp-off" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Request comp-off</h1>
4
+ </div>
5
+
6
+ <section class="hrl-card">
7
+ <%= form_with model: @request, url: hr_lite.comp_off_requests_path, scope: :comp_off_request, local: true do |f| %>
8
+ <%= render "hr_lite/shared/form_errors", record: @request %>
9
+ <div class="hrl-field">
10
+ <%= f.label :date_worked, "Which day did you work?" %>
11
+ <%= f.date_field :date_worked, max: Date.current %>
12
+ <p class="hrl-hint">Must be a weekend or holiday you actually worked — extra hours on a
13
+ normal working day are not comp-off under the policy.</p>
14
+ </div>
15
+ <div class="hrl-field hrl-field--check">
16
+ <%= f.check_box :half_day %>
17
+ <%= f.label :half_day, "I worked only half the day (credits 0.5)" %>
18
+ </div>
19
+ <div class="hrl-field">
20
+ <%= f.label :reason, "What did you work on? (required)" %>
21
+ <%= f.text_area :reason %>
22
+ </div>
23
+ <div class="hrl-form-actions">
24
+ <%= f.submit "Send for approval", class: "hrl-btn hrl-btn--primary" %>
25
+ <a class="hrl-btn" href="<%= hr_lite.comp_off_requests_path %>">Cancel</a>
26
+ </div>
27
+ <% end %>
28
+ </section>
@@ -2,6 +2,7 @@
2
2
  <div class="hrl-page__head">
3
3
  <h1 class="hrl-page__title">My leaves</h1>
4
4
  <div class="hrl-page__actions">
5
+ <a class="hrl-btn" href="<%= hr_lite.comp_off_requests_path %>">Comp-off</a>
5
6
  <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_leave_request_path %>">Apply</a>
6
7
  </div>
7
8
  </div>
@@ -0,0 +1,41 @@
1
+ <% content_for(:page_title) { "Regularization" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">My regularization tickets</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn" href="<%= hr_lite.attendance_path %>">My attendance</a>
6
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_regularization_request_path %>">Raise a ticket</a>
7
+ </div>
8
+ </div>
9
+
10
+ <section class="hrl-card">
11
+ <p class="hrl-small hrl-muted" style="margin-top:0;">
12
+ Forgot to check in or out? Raise a ticket with the actual times — once an
13
+ admin approves, your attendance for that day is fixed automatically.
14
+ </p>
15
+ <% if @requests.any? %>
16
+ <div class="hrl-table-wrap">
17
+ <table class="hrl-table hrl-table--stack">
18
+ <thead><tr><th>Date</th><th>Proposed times</th><th>Status</th><th>Note</th><th></th></tr></thead>
19
+ <tbody>
20
+ <% @requests.each do |request| %>
21
+ <tr>
22
+ <td data-label="Date"><%= request.date.strftime("%a, %d %b %Y") %></td>
23
+ <td data-label="Proposed times"><%= request.times_label %></td>
24
+ <td data-label="Status"><%= hrl_request_status_badge(request.status) %></td>
25
+ <td data-label="Note" class="hrl-small hrl-muted"><%= request.decision_note.presence || "—" %></td>
26
+ <td data-label="" class="<%= 'hrl-cell--void' unless request.pending? %>">
27
+ <% if request.pending? %>
28
+ <%= button_to "Cancel", hr_lite.cancel_regularization_request_path(request), method: :post,
29
+ class: "hrl-btn hrl-btn--danger", form: { data: { turbo_confirm: "Cancel this ticket?" } } %>
30
+ <% end %>
31
+ </td>
32
+ </tr>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+ <%= hrl_pagination %>
38
+ <% else %>
39
+ <p class="hrl-card--empty">No tickets yet.</p>
40
+ <% end %>
41
+ </section>