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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/app/controllers/hr_lite/admin/attendances_controller.rb +29 -0
  4. data/app/controllers/hr_lite/admin/employees_controller.rb +2 -1
  5. data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +103 -0
  6. data/app/controllers/hr_lite/attendance_controller.rb +23 -0
  7. data/app/controllers/hr_lite/wfh_requests_controller.rb +45 -0
  8. data/app/helpers/hr_lite/application_helper.rb +2 -2
  9. data/app/jobs/hr_lite/attendance_close_job.rb +12 -0
  10. data/app/models/hr_lite/attendance_penalty.rb +30 -0
  11. data/app/models/hr_lite/attendance_record.rb +13 -2
  12. data/app/models/hr_lite/attendance_slot.rb +38 -0
  13. data/app/models/hr_lite/employee_profile.rb +11 -0
  14. data/app/models/hr_lite/wfh_request.rb +108 -0
  15. data/app/services/hr_lite/attendance_puncher.rb +54 -0
  16. data/app/views/hr_lite/admin/attendances/_slot.html.erb +24 -0
  17. data/app/views/hr_lite/admin/attendances/show.html.erb +6 -1
  18. data/app/views/hr_lite/admin/employees/_form.html.erb +14 -0
  19. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +4 -1
  20. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +91 -0
  21. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +40 -0
  22. data/app/views/hr_lite/attendance/_penalties.html.erb +25 -0
  23. data/app/views/hr_lite/attendance/_punch_card.html.erb +9 -0
  24. data/app/views/hr_lite/attendance/_slot.html.erb +33 -0
  25. data/app/views/hr_lite/attendance/show.html.erb +4 -0
  26. data/app/views/hr_lite/team/show.html.erb +2 -1
  27. data/app/views/hr_lite/wfh_requests/index.html.erb +70 -0
  28. data/config/routes.rb +8 -0
  29. data/db/migrate/20261013090000_create_hr_lite_attendance_slots.rb +16 -0
  30. data/db/migrate/20261013090100_create_hr_lite_attendance_penalties.rb +19 -0
  31. data/db/migrate/20261013090200_add_buffer_to_hr_lite_employee_profiles.rb +7 -0
  32. data/db/migrate/20261013100000_create_hr_lite_wfh_requests.rb +22 -0
  33. data/db/migrate/20261013100100_add_wfh_request_id_to_hr_lite_attendance_records.rb +7 -0
  34. data/db/migrate/20261013100200_add_permanent_wfh_to_hr_lite_employee_profiles.rb +5 -0
  35. data/lib/hr_lite/configuration.rb +7 -1
  36. data/lib/hr_lite/notifications.rb +5 -1
  37. data/lib/hr_lite/version.rb +1 -1
  38. 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 %></span>
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
@@ -0,0 +1,5 @@
1
+ class AddPermanentWfhToHrLiteEmployeeProfiles < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hr_lite_employee_profiles, :permanent_wfh, :boolean, null: false, default: false
4
+ end
5
+ 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
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.29.0"
2
+ VERSION = "0.31.0"
3
3
  end