hr_lite 0.29.0 → 0.31.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 +14 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +29 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +2 -1
- data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +103 -0
- data/app/controllers/hr_lite/attendance_controller.rb +23 -0
- data/app/controllers/hr_lite/wfh_requests_controller.rb +45 -0
- data/app/helpers/hr_lite/application_helper.rb +2 -2
- data/app/jobs/hr_lite/attendance_close_job.rb +12 -0
- data/app/models/hr_lite/attendance_penalty.rb +30 -0
- data/app/models/hr_lite/attendance_record.rb +13 -2
- data/app/models/hr_lite/attendance_slot.rb +38 -0
- data/app/models/hr_lite/employee_profile.rb +11 -0
- data/app/models/hr_lite/wfh_request.rb +108 -0
- data/app/services/hr_lite/attendance_puncher.rb +54 -0
- data/app/views/hr_lite/admin/attendances/_slot.html.erb +24 -0
- data/app/views/hr_lite/admin/attendances/show.html.erb +6 -1
- data/app/views/hr_lite/admin/employees/_form.html.erb +14 -0
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +4 -1
- data/app/views/hr_lite/admin/wfh_requests/index.html.erb +91 -0
- data/app/views/hr_lite/admin/wfh_requests/show.html.erb +40 -0
- data/app/views/hr_lite/attendance/_penalties.html.erb +25 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +9 -0
- data/app/views/hr_lite/attendance/_slot.html.erb +33 -0
- data/app/views/hr_lite/attendance/show.html.erb +4 -0
- data/app/views/hr_lite/team/show.html.erb +2 -1
- data/app/views/hr_lite/wfh_requests/index.html.erb +70 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20261013090000_create_hr_lite_attendance_slots.rb +16 -0
- data/db/migrate/20261013090100_create_hr_lite_attendance_penalties.rb +19 -0
- data/db/migrate/20261013090200_add_buffer_to_hr_lite_employee_profiles.rb +7 -0
- data/db/migrate/20261013100000_create_hr_lite_wfh_requests.rb +22 -0
- data/db/migrate/20261013100100_add_wfh_request_id_to_hr_lite_attendance_records.rb +7 -0
- data/db/migrate/20261013100200_add_permanent_wfh_to_hr_lite_employee_profiles.rb +5 -0
- data/lib/hr_lite/configuration.rb +7 -1
- data/lib/hr_lite/notifications.rb +5 -1
- data/lib/hr_lite/version.rb +1 -1
- metadata +18 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<% slot = HrLite::AttendanceSlot.current(employee.id, Date.current) %>
|
|
2
|
+
<section class="hrl-card">
|
|
3
|
+
<h2 class="hrl-card__title">Check-in slot</h2>
|
|
4
|
+
<p class="hrl-small">
|
|
5
|
+
<span class="hrl-badge"><%= HrLite::AttendanceSlot.label(slot&.start_minute || HrLite::AttendanceSlot::DEFAULT_START_MINUTE) %><%= " (default)" unless slot %></span>
|
|
6
|
+
<% if slot %><%= slot.locked? ? "Locked" : "Unlocked" %> till <%= slot.ends_on.strftime("%-d %b") %><% end %>
|
|
7
|
+
</p>
|
|
8
|
+
<% if slot&.locked? %>
|
|
9
|
+
<%= form_with url: hr_lite.unlock_slot_admin_attendance_path(employee.id), method: :post, local: true do %>
|
|
10
|
+
<button type="submit" class="hrl-btn">Unlock so they can pick again</button>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<%= form_with url: hr_lite.slot_admin_attendance_path(employee.id), method: :post, local: true, class: "hrl-mt" do %>
|
|
14
|
+
<div class="hrl-field">
|
|
15
|
+
<label for="hrl-admin-slot-start">Set slot</label>
|
|
16
|
+
<%= select_tag :start_minute, options_for_select(HrLite::AttendanceSlot::START_MINUTES.map { |m| [ HrLite::AttendanceSlot.label(m), m ] }, slot&.start_minute || HrLite::AttendanceSlot::DEFAULT_START_MINUTE), id: "hrl-admin-slot-start" %>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="hrl-field">
|
|
19
|
+
<label for="hrl-admin-slot-period">For</label>
|
|
20
|
+
<%= select_tag :period, options_for_select(HrLite::AttendanceSlot::PERIODS.invert), id: "hrl-admin-slot-period" %>
|
|
21
|
+
</div>
|
|
22
|
+
<button type="submit" class="hrl-btn">Set and lock</button>
|
|
23
|
+
<% end %>
|
|
24
|
+
</section>
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
<p class="hrl-small hrl-muted hrl-mt">Tap a date to fix its punches.</p>
|
|
25
25
|
</section>
|
|
26
26
|
|
|
27
|
+
<% if HrLite.config.attendance_slots %>
|
|
28
|
+
<%= render "slot", employee: @employee %>
|
|
29
|
+
<%= render "hr_lite/attendance/penalties", user: @employee, month: @month, removable: @employee.id != hr_current_user.id %>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
27
32
|
<% if @punches.any? %>
|
|
28
33
|
<section class="hrl-card">
|
|
29
34
|
<h2 class="hrl-card__title">Where they punched</h2>
|
|
@@ -34,7 +39,7 @@
|
|
|
34
39
|
<% [ [ "In", record.check_in_at, record.check_in_lat, record.check_in_lng ],
|
|
35
40
|
[ "Out", record.check_out_at, record.check_out_lat, record.check_out_lng ] ].each do |label, at, lat, lng| %>
|
|
36
41
|
<% next unless at %>
|
|
37
|
-
<div><%= label %> <%= at.strftime("%H:%M") %> · <%= HrLite::OfficeLocation.place(lat, lng, @offices) || "No GPS" %>
|
|
42
|
+
<div><%= label %> <%= at.strftime("%H:%M") %> · <%= record.wfh ? "WFH" : (HrLite::OfficeLocation.place(lat, lng, @offices) || "No GPS") %>
|
|
38
43
|
<% if lat && lng %>
|
|
39
44
|
· <a class="hrl-card__link" href="https://www.google.com/maps?q=<%= lat %>,<%= lng %>" target="_blank" rel="noopener noreferrer">Map<span class="hrl-sr-only"> of <%= label.downcase %> <%= record.date.strftime("%-d %b") %></span></a>
|
|
40
45
|
<% end %>
|
|
@@ -74,6 +74,20 @@
|
|
|
74
74
|
<%= f.label :probation_until, "On probation until (blank = not on probation)" %>
|
|
75
75
|
<%= f.date_field :probation_until %>
|
|
76
76
|
</div>
|
|
77
|
+
<% if HrLite.config.attendance_slots %>
|
|
78
|
+
<div class="hrl-field">
|
|
79
|
+
<%= f.label :buffer_minutes, "Late buffer (delays late penalties; colleagues see the reason category)" %>
|
|
80
|
+
<%= f.select :buffer_minutes, HrLite::EmployeeProfile::BUFFER_MINUTES.map { |m| [ "#{m / 60}h", m ] }, include_blank: "None" %>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="hrl-field">
|
|
83
|
+
<%= f.label :buffer_reason, "Buffer reason" %>
|
|
84
|
+
<%= f.select :buffer_reason, HrLite::EmployeeProfile::BUFFER_REASONS.invert, include_blank: "Choose one" %>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="hrl-field"><%= f.label :buffer_note, "Buffer note (HR only; required for Other)" %><%= f.text_area :buffer_note, rows: 2 %></div>
|
|
87
|
+
<% end %>
|
|
88
|
+
<% if HrLite.config.office_geofence %>
|
|
89
|
+
<div class="hrl-field hrl-field--check"><%= f.check_box :permanent_wfh %><%= f.label :permanent_wfh, "Permanent work from home (checks in from anywhere; inside the office it counts as office)" %></div>
|
|
90
|
+
<% end %>
|
|
77
91
|
<div class="hrl-field">
|
|
78
92
|
<%= f.label :date_of_exit, "Date of exit (leave blank while employed)" %>
|
|
79
93
|
<%= f.date_field :date_of_exit %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: active (:leaves | :comp_off | :regularizations | :balances) %>
|
|
1
|
+
<%# locals: active (:leaves | :comp_off | :regularizations | :wfh | :balances) %>
|
|
2
2
|
<% tab = ->(key) { tag.attributes(class: [ "hrl-btn", ("hrl-btn--primary" if active == key) ], aria: { current: ("page" if active == key) }) } %>
|
|
3
3
|
<div class="hrl-row hrl-row--mb hrl-row--tabs">
|
|
4
4
|
<a <%= tab.(:leaves) %>
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
<a <%= tab.(:regularizations) %>
|
|
10
10
|
href="<%= hr_lite.admin_regularization_requests_path %>">Regularization
|
|
11
11
|
<% if (n = HrLite::RegularizationRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
|
|
12
|
+
<a <%= tab.(:wfh) %>
|
|
13
|
+
href="<%= hr_lite.admin_wfh_requests_path %>">WFH
|
|
14
|
+
<% if (n = HrLite::WfhRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
|
|
12
15
|
<a <%= tab.(:balances) %>
|
|
13
16
|
href="<%= hr_lite.admin_leave_balances_path %>">Balances</a>
|
|
14
17
|
</div>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<% content_for(:page_title) { "WFH" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Work from home</h1>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<%= render "hr_lite/admin/shared/approvals_tabs", active: :wfh %>
|
|
7
|
+
|
|
8
|
+
<section class="hrl-card">
|
|
9
|
+
<%= form_with url: hr_lite.admin_wfh_requests_path, method: :get, local: true do %>
|
|
10
|
+
<div class="hrl-field"><label for="wfh-from">From</label><%= date_field_tag :from, @from, id: "wfh-from" %></div>
|
|
11
|
+
<div class="hrl-field"><label for="wfh-to">To</label><%= date_field_tag :to, @to, id: "wfh-to" %></div>
|
|
12
|
+
<div class="hrl-field"><label for="wfh-q">Employee name</label><%= search_field_tag :q, params[:q], id: "wfh-q" %></div>
|
|
13
|
+
<% if @departments.any? %>
|
|
14
|
+
<div class="hrl-field"><label for="wfh-dept">Department</label><%= select_tag :department, options_for_select(@departments, params[:department]), include_blank: "All", id: "wfh-dept" %></div>
|
|
15
|
+
<% end %>
|
|
16
|
+
<div class="hrl-field"><label for="wfh-status">Status</label><%= select_tag :status, options_for_select(HrLite::WfhRequest::STATUSES.map { |s| [ s.humanize, s ] }, @status), include_blank: "All", id: "wfh-status" %></div>
|
|
17
|
+
<div class="hrl-field"><label for="wfh-on">Who is home on</label><%= date_field_tag :on, @on, id: "wfh-on" %></div>
|
|
18
|
+
<div class="hrl-field"><label for="wfh-att">WFH attendance</label><%= select_tag :attendance, options_for_select([ [ "Checked in", "checked_in" ], [ "Not checked in", "not_checked_in" ] ], @attendance), include_blank: "All", id: "wfh-att" %></div>
|
|
19
|
+
<div class="hrl-form-actions">
|
|
20
|
+
<button type="submit" class="hrl-btn hrl-btn--primary">Filter</button>
|
|
21
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_wfh_requests_path %>">Reset</a>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
24
|
+
</section>
|
|
25
|
+
|
|
26
|
+
<div class="hrl-kpis">
|
|
27
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:requested] %></div><div class="hrl-kpi__label">Employees requested</div></div>
|
|
28
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:approved] %></div><div class="hrl-kpi__label">Employees approved</div></div>
|
|
29
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:pending] %></div><div class="hrl-kpi__label">Pending requests</div></div>
|
|
30
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:rejected] %></div><div class="hrl-kpi__label">Rejected requests</div></div>
|
|
31
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @home_count %></div><div class="hrl-kpi__label">Home on <%= @on.strftime("%-d %b") %></div></div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<section class="hrl-card">
|
|
35
|
+
<h2 class="hrl-card__title">Working from home on <%= @on.strftime("%a %-d %b") %></h2>
|
|
36
|
+
<% if @home.any? %>
|
|
37
|
+
<% @home.each do |row| %>
|
|
38
|
+
<% rec = @records[row.user.id]
|
|
39
|
+
state, tone = rec ? (rec.wfh ? [ "WFH check-in", "ok" ] : [ "In office", "muted" ]) : [ "Not checked in", "muted" ] %>
|
|
40
|
+
<div class="hrl-feed__item hrl-row">
|
|
41
|
+
<div class="hrl-feed__body"><strong><%= hr_display_name(row.user) %></strong> · <%= row.label %>
|
|
42
|
+
<span class="hrl-badge hrl-badge--<%= tone %>"><%= state %></span></div>
|
|
43
|
+
<% if row.request %>
|
|
44
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_wfh_request_path(row.request) %>">Open<span class="hrl-sr-only"> <%= hr_display_name(row.user) %>'s request</span></a>
|
|
45
|
+
<% end %>
|
|
46
|
+
</div>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% else %>
|
|
49
|
+
<p class="hrl-card--empty">Nobody is working from home on this date.</p>
|
|
50
|
+
<% end %>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<% if @grantable.any? %>
|
|
54
|
+
<section class="hrl-card">
|
|
55
|
+
<h2 class="hrl-card__title">Give WFH</h2>
|
|
56
|
+
<p class="hrl-small hrl-muted">Approved straight away, up to <%= HrLite::WfhRequest::HR_MAX_DAYS %> days from today or later.</p>
|
|
57
|
+
<%= form_with url: hr_lite.admin_wfh_requests_path, method: :post, local: true do %>
|
|
58
|
+
<div class="hrl-field"><label for="grant-user">Employee</label><%= select_tag :user_id, options_for_select(@grantable.map { |u| [ hr_display_name(u), u.id ] }), include_blank: "Choose one", required: true, id: "grant-user" %></div>
|
|
59
|
+
<div class="hrl-field"><label for="grant-from">From</label><%= date_field_tag :start_date, Date.current, min: Date.current, required: true, id: "grant-from" %></div>
|
|
60
|
+
<div class="hrl-field"><label for="grant-to">To</label><%= date_field_tag :end_date, Date.current, min: Date.current, required: true, id: "grant-to" %></div>
|
|
61
|
+
<div class="hrl-field"><label for="grant-reason">Reason</label><%= text_area_tag :reason, nil, rows: 2, required: true, id: "grant-reason" %></div>
|
|
62
|
+
<div class="hrl-form-actions"><button type="submit" class="hrl-btn hrl-btn--primary">Give WFH</button></div>
|
|
63
|
+
<% end %>
|
|
64
|
+
</section>
|
|
65
|
+
<% end %>
|
|
66
|
+
|
|
67
|
+
<section class="hrl-card">
|
|
68
|
+
<h2 class="hrl-card__title">Schedules <%= @from.strftime("%-d %b") %> – <%= @to.strftime("%-d %b") %></h2>
|
|
69
|
+
<% if @requests.any? %>
|
|
70
|
+
<div class="hrl-table-wrap">
|
|
71
|
+
<table class="hrl-table hrl-table--stack">
|
|
72
|
+
<thead><tr><th>Employee</th><th>Dates</th><th>Reason</th><th>Submitted</th><th>Status</th><th></th></tr></thead>
|
|
73
|
+
<tbody>
|
|
74
|
+
<% @requests.each do |request| %>
|
|
75
|
+
<tr>
|
|
76
|
+
<td data-label="Employee"><%= hr_display_name(request.user) %></td>
|
|
77
|
+
<td data-label="Dates"><%= request.dates_label %> <span class="hrl-small hrl-muted">· <%= pluralize(request.days, "day") %></span></td>
|
|
78
|
+
<td data-label="Reason"><%= truncate(request.reason, length: 80) %></td>
|
|
79
|
+
<td data-label="Submitted"><%= request.created_at.strftime("%-d %b") %></td>
|
|
80
|
+
<td data-label="Status"><%= hrl_request_status_badge(request.status) %></td>
|
|
81
|
+
<td data-label=""><a class="hrl-btn" href="<%= hr_lite.admin_wfh_request_path(request) %>">Open</a></td>
|
|
82
|
+
</tr>
|
|
83
|
+
<% end %>
|
|
84
|
+
</tbody>
|
|
85
|
+
</table>
|
|
86
|
+
</div>
|
|
87
|
+
<%= hrl_pagination %>
|
|
88
|
+
<% else %>
|
|
89
|
+
<p class="hrl-card--empty">No WFH requests match.</p>
|
|
90
|
+
<% end %>
|
|
91
|
+
</section>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<% content_for(:page_title) { "WFH — #{hr_display_name(@request.user)}" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">WFH — <%= hr_display_name(@request.user) %></h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_wfh_requests_path %>">All WFH</a>
|
|
6
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@request.user_id, month: @request.start_date.strftime("%Y-%m")) %>">Their month</a>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<section class="hrl-card">
|
|
11
|
+
<dl class="hrl-deflist">
|
|
12
|
+
<dt>Dates</dt><dd><%= @request.dates_label %> (<%= pluralize(@request.days, "day") %>)</dd>
|
|
13
|
+
<dt>Reason</dt><dd><%= @request.reason %></dd>
|
|
14
|
+
<dt>Submitted</dt><dd><%= @request.created_at.strftime("%d %b %Y %H:%M") %></dd>
|
|
15
|
+
<dt>Status</dt><dd><%= hrl_request_status_badge(@request.status) %></dd>
|
|
16
|
+
<% if @request.decided_at %>
|
|
17
|
+
<dt>Decided</dt>
|
|
18
|
+
<dd><%= hr_display_name(@request.decided_by) %> · <%= @request.decided_at.strftime("%d %b %H:%M") %>
|
|
19
|
+
<% if @request.decision_note.present? %> · <%= @request.decision_note %><% end %></dd>
|
|
20
|
+
<% end %>
|
|
21
|
+
</dl>
|
|
22
|
+
</section>
|
|
23
|
+
|
|
24
|
+
<% if @request.pending? && @decidable %>
|
|
25
|
+
<%# Two forms: Rails binds the CSRF token to each form's own action. %>
|
|
26
|
+
<section class="hrl-card">
|
|
27
|
+
<h2 class="hrl-card__title">Approve</h2>
|
|
28
|
+
<%= form_with url: hr_lite.approve_admin_wfh_request_path(@request), method: :post, local: true do |f| %>
|
|
29
|
+
<div class="hrl-field"><%= f.label :decision_note, "Remark (optional)", for: "approve_note" %><%= f.text_area :decision_note, id: "approve_note" %></div>
|
|
30
|
+
<div class="hrl-form-actions"><%= f.button "Approve WFH", type: :submit, class: "hrl-btn hrl-btn--primary" %></div>
|
|
31
|
+
<% end %>
|
|
32
|
+
</section>
|
|
33
|
+
<section class="hrl-card">
|
|
34
|
+
<h2 class="hrl-card__title">Reject</h2>
|
|
35
|
+
<%= form_with url: hr_lite.reject_admin_wfh_request_path(@request), method: :post, local: true do |f| %>
|
|
36
|
+
<div class="hrl-field"><%= f.label :decision_note, "Remark (required)", for: "reject_note" %><%= f.text_area :decision_note, id: "reject_note" %></div>
|
|
37
|
+
<div class="hrl-form-actions"><%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger" %></div>
|
|
38
|
+
<% end %>
|
|
39
|
+
</section>
|
|
40
|
+
<% end %>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<% penalties = HrLite::AttendancePenalty.where(user_id: user.id, date: month.all_month).order(:date, :id) %>
|
|
2
|
+
<section class="hrl-card">
|
|
3
|
+
<h2 class="hrl-card__title">Penalties in <%= month.strftime("%B") %></h2>
|
|
4
|
+
<% if penalties.empty? %>
|
|
5
|
+
<p class="hrl-card--empty">No penalties this month.</p>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% penalties.each do |penalty| %>
|
|
8
|
+
<div class="hrl-feed__item hrl-small">
|
|
9
|
+
<div class="hrl-feed__body">
|
|
10
|
+
<strong><%= penalty.date.strftime("%a %-d %b") %></strong> ·
|
|
11
|
+
<span class="<%= class_names("hrl-badge", penalty.removed_at ? "hrl-badge--muted" : "hrl-badge--warn") %>">+<%= penalty.minutes %> min</span>
|
|
12
|
+
<%= penalty.label %>
|
|
13
|
+
<% if penalty.removed_at %>
|
|
14
|
+
<div class="hrl-muted">Removed by HR: <%= penalty.removal_reason %></div>
|
|
15
|
+
<% elsif local_assigns[:removable] %>
|
|
16
|
+
<%= form_with url: hr_lite.remove_penalty_admin_attendance_path(user.id, penalty_id: penalty.id), method: :post, local: true, class: "hrl-row" do %>
|
|
17
|
+
<label class="hrl-sr-only" for="hrl-remove-<%= penalty.id %>">Reason to remove</label>
|
|
18
|
+
<input type="text" class="hrl-input" name="removal_reason" id="hrl-remove-<%= penalty.id %>" required placeholder="Reason to remove">
|
|
19
|
+
<button type="submit" class="hrl-btn">Remove</button>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
</section>
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
<% if record&.check_out_at %>
|
|
19
19
|
<span class="hrl-badge hrl-badge--muted">Out <%= record.check_out_at.strftime("%H:%M") %></span>
|
|
20
20
|
<% end %>
|
|
21
|
+
<% if record&.wfh %>
|
|
22
|
+
<span class="hrl-badge hrl-badge--ok">WFH</span>
|
|
23
|
+
<% end %>
|
|
21
24
|
<% if record&.flagged? %>
|
|
22
25
|
<span class="hrl-badge hrl-badge--bad">Flagged</span>
|
|
23
26
|
<% end %>
|
|
@@ -28,6 +31,9 @@
|
|
|
28
31
|
<span class="hrl-muted">Not checked in yet.</span>
|
|
29
32
|
<% end %>
|
|
30
33
|
</div>
|
|
34
|
+
<% if HrLite.config.attendance_slots %>
|
|
35
|
+
<%= render "hr_lite/attendance/slot", record: record %>
|
|
36
|
+
<% end %>
|
|
31
37
|
<% if record&.flagged? %>
|
|
32
38
|
<p class="hrl-small">Flagged: <%= record.flag_note %></p>
|
|
33
39
|
<% end %>
|
|
@@ -71,6 +77,9 @@
|
|
|
71
77
|
<% end %>
|
|
72
78
|
|
|
73
79
|
<a class="hrl-card__link" href="<%= hr_lite.attendance_path %>">View my month</a>
|
|
80
|
+
<% if HrLite.config.office_geofence %>
|
|
81
|
+
· <a class="hrl-card__link" href="<%= hr_lite.wfh_requests_path %>">Work from home</a>
|
|
82
|
+
<% end %>
|
|
74
83
|
|
|
75
84
|
<% if local_assigns.key?(:attention) %>
|
|
76
85
|
<div class="hrl-row">
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<% slot = HrLite::AttendanceSlot.current(hr_current_user.id, Date.current)
|
|
2
|
+
start = slot&.start_minute || HrLite::AttendanceSlot::DEFAULT_START_MINUTE
|
|
3
|
+
due = record&.required_check_out_at %>
|
|
4
|
+
<div class="hrl-row hrl-row--mb hrl-small">
|
|
5
|
+
<span class="hrl-badge">Slot <%= HrLite::AttendanceSlot.label(start) %><%= " (default)" unless slot %></span>
|
|
6
|
+
<% if slot&.locked? %><span class="hrl-muted">Locked till <%= slot.ends_on.strftime("%-d %b") %></span><% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<% if due && record.check_out_at %>
|
|
9
|
+
<% short = due - record.check_out_at %>
|
|
10
|
+
<p class="hrl-small">Checkout was due <%= due.strftime("%H:%M") %>.
|
|
11
|
+
<% if short.positive? %><span class="hrl-badge hrl-badge--bad">Short by <%= hrl_duration(short) %></span> +30 min goes on your next working day.<% end %></p>
|
|
12
|
+
<% elsif due %>
|
|
13
|
+
<% left = due - Time.current %>
|
|
14
|
+
<p class="hrl-small">Checked in <%= record.check_in_at.strftime("%H:%M") %> · checkout at <strong><%= due.strftime("%H:%M") %></strong> ·
|
|
15
|
+
<%= left.positive? ? "#{hrl_duration(left)} left" : "you can check out" %></p>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% HrLite::AttendancePenalty.active.where(user_id: hr_current_user.id, date: Date.current).each do |penalty| %>
|
|
18
|
+
<p class="hrl-small"><span class="hrl-badge hrl-badge--warn">+<%= penalty.minutes %> min</span> <%= penalty.label %></p>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% unless slot&.locked? || record&.check_in_at %>
|
|
21
|
+
<%= form_with url: hr_lite.slot_attendance_path, method: :post, local: true, class: "hrl-mt" do %>
|
|
22
|
+
<div class="hrl-field">
|
|
23
|
+
<label for="hrl-slot-start">Check-in slot</label>
|
|
24
|
+
<%= select_tag :start_minute, options_for_select(HrLite::AttendanceSlot::START_MINUTES.map { |m| [ "#{HrLite::AttendanceSlot.label(m)} – out #{HrLite::AttendanceSlot.label(m + HrLite::AttendanceSlot::DAY_MINUTES)}", m ] }, start), id: "hrl-slot-start" %>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="hrl-field">
|
|
27
|
+
<label for="hrl-slot-period">For</label>
|
|
28
|
+
<%= select_tag :period, options_for_select(HrLite::AttendanceSlot::PERIODS.invert), id: "hrl-slot-period" %>
|
|
29
|
+
</div>
|
|
30
|
+
<button type="submit" class="hrl-btn hrl-btn--block">Save and lock slot</button>
|
|
31
|
+
<p class="hrl-small hrl-muted">Once saved, only HR can change it for that period.</p>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% end %>
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
· <a href="<%= hr_lite.regularization_requests_path %>">My tickets</a></p>
|
|
26
26
|
</section>
|
|
27
27
|
|
|
28
|
+
<% if HrLite.config.attendance_slots %>
|
|
29
|
+
<%= render "hr_lite/attendance/penalties", user: hr_current_user, month: @month %>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
28
32
|
<% if @day %>
|
|
29
33
|
<% rec, leave = @day_info.record, @day_info.leave
|
|
30
34
|
part = rec&.half_day_part.presence || leave&.half_day_part.presence
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
<tr>
|
|
31
31
|
<td data-label="Name">
|
|
32
32
|
<span><%= tag.strong hr_display_name(row.user), title: ("On leave: #{row.leave.date_range_label}" if row.leave) %><% if row.profile&.designation.present? %>
|
|
33
|
-
<span class="hrl-small hrl-muted"> · <%= row.profile.designation %></span><% end
|
|
33
|
+
<span class="hrl-small hrl-muted"> · <%= row.profile.designation %></span><% end %><% if HrLite.config.attendance_slots && row.profile&.buffer_minutes %>
|
|
34
|
+
<span class="hrl-badge hrl-badge--muted">+<%= row.profile.buffer_minutes / 60 %>h buffer · <%= HrLite::EmployeeProfile::BUFFER_REASONS[row.profile.buffer_reason] %></span><% end %></span>
|
|
34
35
|
</td>
|
|
35
36
|
<td data-label="Status"><%= hrl_team_status(row) %></td>
|
|
36
37
|
<td data-label="Hours <%= @date == Date.current ? "today" : "that day" %>" class="hrl-num"><%= hrl_duration(row.day_seconds) %></td>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Work from home" } %>
|
|
2
|
+
<% content_for(:back, "Attendance") %>
|
|
3
|
+
<div class="hrl-page__head">
|
|
4
|
+
<h1 class="hrl-page__title">Work from home</h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<section class="hrl-card">
|
|
8
|
+
<h2 class="hrl-card__title">Request WFH</h2>
|
|
9
|
+
<p class="hrl-small hrl-muted">Pick dates from today up to <%= HrLite::WfhRequest::MAX_DAYS_AHEAD %> days ahead. HR approves or rejects; only approved dates let you check in away from the office.</p>
|
|
10
|
+
<%= form_with model: @request, url: hr_lite.wfh_requests_path, scope: :wfh_request, local: true do |f| %>
|
|
11
|
+
<%= render "hr_lite/shared/form_errors", record: @request %>
|
|
12
|
+
<% min, max = @window.first.iso8601, @window.last.iso8601 %>
|
|
13
|
+
<div class="hrl-field"><%= f.label :start_date, "From" %><%= f.date_field :start_date, min: min, max: max, required: true %></div>
|
|
14
|
+
<div class="hrl-field"><%= f.label :end_date, "To (same day for one day)" %><%= f.date_field :end_date, min: min, max: max, required: true %></div>
|
|
15
|
+
<div class="hrl-field"><%= f.label :reason, "Why do you need WFH?" %><%= f.text_area :reason, rows: 3, required: true %></div>
|
|
16
|
+
<div class="hrl-form-actions"><%= f.submit "Send to HR", class: "hrl-btn hrl-btn--primary" %></div>
|
|
17
|
+
<% end %>
|
|
18
|
+
</section>
|
|
19
|
+
|
|
20
|
+
<section class="hrl-card">
|
|
21
|
+
<h2 class="hrl-card__title">Next <%= HrLite::WfhRequest::MAX_DAYS_AHEAD %> days</h2>
|
|
22
|
+
<% marks = { "approved" => [ "WFH", "Approved", "present" ], "pending" => [ "Pend", "Pending", "pending" ], "rejected" => [ "Rej", "Rejected", "absent" ] } %>
|
|
23
|
+
<div class="hrl-mgrid" role="group" aria-label="Your WFH dates">
|
|
24
|
+
<% %w[Mo Tu We Th Fr Sa Su].each do |d| %><div class="hrl-mgrid__head"><%= d %></div><% end %>
|
|
25
|
+
<% (@window.first.cwday - 1).times do %><div></div><% end %>
|
|
26
|
+
<% @window.each do |date| %>
|
|
27
|
+
<% code, word, mod = marks[@day_status[date]] %>
|
|
28
|
+
<div class="<%= class_names("hrl-mgrid__day", "hrl-mgrid__day--#{mod}": mod, "hrl-mgrid__day--today": date == Date.current) %>"
|
|
29
|
+
role="group" aria-label="<%= date.strftime('%-d %b') %><%= ", #{word}" if word %>">
|
|
30
|
+
<span class="hrl-mgrid__num"><%= date.day %></span>
|
|
31
|
+
<% if code %><span class="hrl-mgrid__tag"><%= code %></span><% end %>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="hrl-legend hrl-small">
|
|
36
|
+
<% marks.each_value do |_code, word, mod| %>
|
|
37
|
+
<span class="hrl-badge hrl-mgrid__key hrl-mgrid__day--<%= mod %>"><%= word %></span>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<section class="hrl-card">
|
|
43
|
+
<h2 class="hrl-card__title">My requests</h2>
|
|
44
|
+
<% if @requests.any? %>
|
|
45
|
+
<div class="hrl-table-wrap">
|
|
46
|
+
<table class="hrl-table hrl-table--stack">
|
|
47
|
+
<thead><tr><th>Dates</th><th>Reason</th><th>Status</th><th>HR remarks</th><th></th></tr></thead>
|
|
48
|
+
<tbody>
|
|
49
|
+
<% @requests.each do |request| %>
|
|
50
|
+
<tr>
|
|
51
|
+
<td data-label="Dates"><%= request.dates_label %> <span class="hrl-small hrl-muted">· <%= pluralize(request.days, "day") %></span></td>
|
|
52
|
+
<td data-label="Reason"><%= request.reason %></td>
|
|
53
|
+
<td data-label="Status"><%= hrl_request_status_badge(request.status) %></td>
|
|
54
|
+
<td data-label="HR remarks" class="hrl-small hrl-muted"><%= request.decision_note.presence || "—" %></td>
|
|
55
|
+
<td data-label="" class="<%= 'hrl-cell--void' unless request.pending? %>">
|
|
56
|
+
<% if request.pending? %>
|
|
57
|
+
<%= button_to "Cancel", hr_lite.cancel_wfh_request_path(request), method: :post,
|
|
58
|
+
class: "hrl-btn hrl-btn--danger", form: { data: { turbo_confirm: "Cancel this request?" } } %>
|
|
59
|
+
<% end %>
|
|
60
|
+
</td>
|
|
61
|
+
</tr>
|
|
62
|
+
<% end %>
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
<%= hrl_pagination %>
|
|
67
|
+
<% else %>
|
|
68
|
+
<p class="hrl-card--empty">No WFH requests yet.</p>
|
|
69
|
+
<% end %>
|
|
70
|
+
</section>
|
data/config/routes.rb
CHANGED
|
@@ -7,6 +7,7 @@ HrLite::Engine.routes.draw do
|
|
|
7
7
|
resource :attendance, only: :show, controller: "attendance" do
|
|
8
8
|
post :check_in
|
|
9
9
|
post :check_out
|
|
10
|
+
post :slot
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
resources :leave_requests, only: %i[index show new create] do
|
|
@@ -19,6 +20,9 @@ HrLite::Engine.routes.draw do
|
|
|
19
20
|
resources :regularization_requests, only: %i[index new create] do
|
|
20
21
|
member { post :cancel }
|
|
21
22
|
end
|
|
23
|
+
resources :wfh_requests, only: %i[index create] do
|
|
24
|
+
member { post :cancel }
|
|
25
|
+
end
|
|
22
26
|
resources :approvals, only: :index
|
|
23
27
|
resources :expenses, only: %i[index new create] do
|
|
24
28
|
member { post :cancel }
|
|
@@ -59,6 +63,7 @@ HrLite::Engine.routes.draw do
|
|
|
59
63
|
get "overview", to: "overview#index"
|
|
60
64
|
resources :attendances, only: %i[index show update], param: :user_id do
|
|
61
65
|
collection { get :week }
|
|
66
|
+
member { post :slot; post :unlock_slot; post :remove_penalty }
|
|
62
67
|
end
|
|
63
68
|
resources :leave_requests, only: %i[index show new create edit update] do
|
|
64
69
|
member { post :approve; post :reject; post :cancel }
|
|
@@ -72,6 +77,9 @@ HrLite::Engine.routes.draw do
|
|
|
72
77
|
resources :regularization_requests, only: %i[index show] do
|
|
73
78
|
member { post :approve; post :reject }
|
|
74
79
|
end
|
|
80
|
+
resources :wfh_requests, only: %i[index show create] do
|
|
81
|
+
member { post :approve; post :reject }
|
|
82
|
+
end
|
|
75
83
|
resources :roles, except: :show do
|
|
76
84
|
resources :assignments, only: %i[create destroy], controller: "role_assignments"
|
|
77
85
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateHrLiteAttendanceSlots < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hr_lite_attendance_slots do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.date :starts_on, null: false
|
|
6
|
+
t.date :ends_on, null: false
|
|
7
|
+
t.string :period, null: false
|
|
8
|
+
t.integer :start_minute, null: false
|
|
9
|
+
t.boolean :locked, null: false, default: true
|
|
10
|
+
t.bigint :set_by_id
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
add_index :hr_lite_attendance_slots, [ :user_id, :starts_on, :ends_on ]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class CreateHrLiteAttendancePenalties < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hr_lite_attendance_penalties do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.date :date, null: false
|
|
6
|
+
t.integer :minutes, null: false
|
|
7
|
+
t.string :reason, null: false
|
|
8
|
+
t.bigint :attendance_record_id, null: false
|
|
9
|
+
t.datetime :removed_at
|
|
10
|
+
t.bigint :removed_by_id
|
|
11
|
+
t.text :removal_reason
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
add_index :hr_lite_attendance_penalties, [ :user_id, :date ]
|
|
16
|
+
add_index :hr_lite_attendance_penalties, [ :attendance_record_id, :reason, :date ], unique: true,
|
|
17
|
+
name: "index_hr_lite_attendance_penalties_once"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddBufferToHrLiteEmployeeProfiles < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :hr_lite_employee_profiles, :buffer_minutes, :integer
|
|
4
|
+
add_column :hr_lite_employee_profiles, :buffer_reason, :string
|
|
5
|
+
add_column :hr_lite_employee_profiles, :buffer_note, :text
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CreateHrLiteWfhRequests < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hr_lite_wfh_requests do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.date :start_date, null: false
|
|
6
|
+
t.date :end_date, null: false
|
|
7
|
+
t.text :reason, null: false
|
|
8
|
+
t.string :status, null: false, default: "pending"
|
|
9
|
+
t.bigint :decided_by_id
|
|
10
|
+
t.datetime :decided_at
|
|
11
|
+
t.text :decision_note
|
|
12
|
+
t.bigint :created_by_id
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
add_index :hr_lite_wfh_requests, [ :user_id, :start_date, :end_date ]
|
|
17
|
+
add_index :hr_lite_wfh_requests, [ :status, :start_date ]
|
|
18
|
+
add_check_constraint :hr_lite_wfh_requests, "status IN ('pending','approved','rejected','cancelled')",
|
|
19
|
+
name: "hr_lite_wfh_requests_status"
|
|
20
|
+
add_check_constraint :hr_lite_wfh_requests, "end_date >= start_date", name: "hr_lite_wfh_requests_range"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddWfhRequestIdToHrLiteAttendanceRecords < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :hr_lite_attendance_records, :wfh_request_id, :bigint
|
|
4
|
+
add_index :hr_lite_attendance_records, :wfh_request_id
|
|
5
|
+
add_column :hr_lite_attendance_records, :wfh, :boolean, null: false, default: false
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -16,7 +16,11 @@ module HrLite
|
|
|
16
16
|
# e.g. 6 — default probation for new hires (admin edits per employee)
|
|
17
17
|
:probation_months,
|
|
18
18
|
# true = a punch without GPS is refused (default: recorded and flagged)
|
|
19
|
-
:require_punch_location
|
|
19
|
+
:require_punch_location,
|
|
20
|
+
# true = check-in slots, late penalties and HR buffers (default off)
|
|
21
|
+
:attendance_slots,
|
|
22
|
+
# true = check-in only inside an active office radius, unless WFH is approved for the day
|
|
23
|
+
:office_geofence
|
|
20
24
|
|
|
21
25
|
attr_reader :leave_year_start_month, :slip_release_day, :self_regularization, :work_hours
|
|
22
26
|
|
|
@@ -102,6 +106,8 @@ module HrLite
|
|
|
102
106
|
@slip_release_day = nil # e.g. 10 = slips open on the 10th of the next month
|
|
103
107
|
@self_regularization = nil # e.g. { within_days: 2, per_week: 2 } — employees fix their own missed punch
|
|
104
108
|
@require_punch_location = false
|
|
109
|
+
@attendance_slots = false
|
|
110
|
+
@office_geofence = false
|
|
105
111
|
@work_hours = nil # e.g. { day: 8, probation_day: 9, week: 40 } — short days close as half days
|
|
106
112
|
|
|
107
113
|
# Leadership onboarding/offboarding. onboard_user must return a saved
|
|
@@ -68,7 +68,11 @@ module HrLite
|
|
|
68
68
|
"regularization.requested" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
|
|
69
69
|
"regularization.approved" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
70
70
|
"regularization.rejected" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
71
|
-
"regularization.cancelled" => { bell: true, email: false, leadership_email: true, leadership_bell: false }
|
|
71
|
+
"regularization.cancelled" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
|
|
72
|
+
"wfh.requested" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
|
|
73
|
+
"wfh.approved" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
74
|
+
"wfh.rejected" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
75
|
+
"wfh.cancelled" => { bell: true, email: false, leadership_email: false, leadership_bell: false }
|
|
72
76
|
}.freeze
|
|
73
77
|
|
|
74
78
|
class << self
|
data/lib/hr_lite/version.rb
CHANGED