hr_lite 0.2.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +2 -1
- data/app/assets/stylesheets/hr_lite/hr_lite.css +17 -4
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +38 -0
- data/app/controllers/hr_lite/admin/leave_types_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +38 -0
- data/app/controllers/hr_lite/comp_off_requests_controller.rb +46 -0
- data/app/controllers/hr_lite/regularization_requests_controller.rb +36 -0
- data/app/controllers/hr_lite/team_controller.rb +11 -0
- data/app/helpers/hr_lite/application_helper.rb +61 -3
- data/app/models/hr_lite/comp_off_request.rb +177 -0
- data/app/models/hr_lite/leave_request.rb +18 -0
- data/app/models/hr_lite/leave_type.rb +17 -0
- data/app/models/hr_lite/regularization_request.rb +181 -0
- data/app/services/hr_lite/team_day.rb +84 -0
- data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +37 -0
- data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +47 -0
- data/app/views/hr_lite/admin/leave_requests/index.html.erb +2 -0
- data/app/views/hr_lite/admin/leave_types/_form.html.erb +4 -0
- data/app/views/hr_lite/admin/regularization_requests/index.html.erb +37 -0
- data/app/views/hr_lite/admin/regularization_requests/show.html.erb +50 -0
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +11 -0
- data/app/views/hr_lite/attendance/show.html.erb +3 -0
- data/app/views/hr_lite/comp_off_requests/index.html.erb +42 -0
- data/app/views/hr_lite/comp_off_requests/new.html.erb +28 -0
- data/app/views/hr_lite/leave_requests/index.html.erb +1 -0
- data/app/views/hr_lite/regularization_requests/index.html.erb +41 -0
- data/app/views/hr_lite/regularization_requests/new.html.erb +30 -0
- data/app/views/hr_lite/team/show.html.erb +48 -0
- data/app/views/layouts/hr_lite/application.html.erb +1 -1
- data/config/routes.rb +13 -0
- data/db/migrate/20260719104315_add_comp_off_to_hr_lite_leave_types.rb +6 -0
- data/db/migrate/20260719104316_create_hr_lite_comp_off_requests.rb +24 -0
- data/db/migrate/20260719104317_create_hr_lite_regularization_requests.rb +20 -0
- data/lib/hr_lite/notifications.rb +14 -2
- data/lib/hr_lite/seeds.rb +16 -1
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +7 -0
- metadata +22 -1
|
@@ -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 %>
|
|
@@ -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>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Raise a regularization ticket" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Raise a regularization ticket</h1>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<section class="hrl-card">
|
|
7
|
+
<%= form_with model: @request, url: hr_lite.regularization_requests_path, scope: :regularization_request, local: true do |f| %>
|
|
8
|
+
<%= render "hr_lite/shared/form_errors", record: @request %>
|
|
9
|
+
<div class="hrl-field">
|
|
10
|
+
<%= f.label :date, "Which day needs fixing?" %>
|
|
11
|
+
<%= f.date_field :date, value: @request.date&.strftime("%Y-%m-%d"), max: Date.current %>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="hrl-field">
|
|
14
|
+
<%= f.label :check_in_at, "Actual check-in (leave blank if only check-out is missing)" %>
|
|
15
|
+
<%= f.datetime_local_field :check_in_at, value: @request.check_in_at&.strftime("%Y-%m-%dT%H:%M"), max: Date.current.strftime("%Y-%m-%dT23:59") %>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="hrl-field">
|
|
18
|
+
<%= f.label :check_out_at, "Actual check-out (leave blank if only check-in is missing)" %>
|
|
19
|
+
<%= f.datetime_local_field :check_out_at, value: @request.check_out_at&.strftime("%Y-%m-%dT%H:%M"), max: Date.current.strftime("%Y-%m-%dT23:59") %>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="hrl-field">
|
|
22
|
+
<%= f.label :reason, "Why was the punch missed? (required)" %>
|
|
23
|
+
<%= f.text_area :reason %>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="hrl-form-actions">
|
|
26
|
+
<%= f.submit "Raise ticket", class: "hrl-btn hrl-btn--primary" %>
|
|
27
|
+
<a class="hrl-btn" href="<%= hr_lite.regularization_requests_path %>">Cancel</a>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
</section>
|