hr_lite 0.31.0 → 0.35.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 +26 -0
- data/app/assets/javascripts/hr_lite/chart_tip.js +33 -0
- data/app/assets/javascripts/hr_lite/countdown.js +12 -0
- data/app/assets/javascripts/hr_lite/crm_picker.js +27 -0
- data/app/assets/javascripts/hr_lite/emoji_pick.js +11 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +47 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +8 -5
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/holidays_controller.rb +17 -9
- data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/work_locations_controller.rb +41 -0
- data/app/controllers/hr_lite/admin/work_modes_controller.rb +72 -0
- data/app/controllers/hr_lite/admin/work_time_controller.rb +39 -0
- data/app/controllers/hr_lite/attendance_controller.rb +2 -1
- data/app/controllers/hr_lite/calendar_controller.rb +3 -2
- data/app/controllers/hr_lite/comp_off_requests_controller.rb +1 -1
- data/app/controllers/hr_lite/holidays_controller.rb +3 -2
- data/app/controllers/hr_lite/team_controller.rb +13 -0
- data/app/controllers/hr_lite/work_locations_controller.rb +44 -0
- data/app/controllers/hr_lite/work_modes_controller.rb +40 -0
- data/app/controllers/hr_lite/work_time_controller.rb +14 -0
- data/app/helpers/hr_lite/application_helper.rb +43 -2
- data/app/jobs/hr_lite/attendance_close_job.rb +12 -5
- data/app/jobs/hr_lite/calendar_meeting_start_job.rb +31 -0
- data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +55 -0
- data/app/jobs/hr_lite/work_mode_expire_job.rb +12 -0
- data/app/models/hr_lite/attendance_penalty.rb +2 -2
- data/app/models/hr_lite/attendance_record.rb +7 -0
- data/app/models/hr_lite/comp_off_request.rb +2 -2
- data/app/models/hr_lite/employee_profile.rb +2 -0
- data/app/models/hr_lite/holiday.rb +11 -5
- data/app/models/hr_lite/leave_balance.rb +1 -1
- data/app/models/hr_lite/leave_request.rb +1 -1
- data/app/models/hr_lite/regularization_request.rb +1 -1
- data/app/models/hr_lite/setting.rb +2 -0
- data/app/models/hr_lite/work_location.rb +62 -0
- data/app/models/hr_lite/work_mode_event.rb +84 -0
- data/app/services/hr_lite/attendance_puncher.rb +60 -19
- data/app/services/hr_lite/day_status.rb +1 -1
- data/app/services/hr_lite/leave_day_counter.rb +5 -7
- data/app/services/hr_lite/team_day.rb +6 -6
- data/app/services/hr_lite/today_checkins.rb +41 -0
- data/app/services/hr_lite/work_mode_durations.rb +237 -0
- data/app/services/hr_lite/work_mode_switch.rb +144 -0
- data/app/services/hr_lite/work_status.rb +60 -0
- data/app/services/hr_lite/working_calendar.rb +11 -2
- data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
- data/app/views/hr_lite/admin/employees/_form.html.erb +7 -1
- data/app/views/hr_lite/admin/holidays/index.html.erb +10 -1
- data/app/views/hr_lite/admin/settings/edit.html.erb +9 -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 +2 -2
- data/app/views/hr_lite/admin/work_locations/index.html.erb +42 -0
- data/app/views/hr_lite/admin/work_modes/index.html.erb +105 -0
- data/app/views/hr_lite/admin/work_time/index.html.erb +42 -0
- data/app/views/hr_lite/attendance/_calendar_connection.html.erb +15 -0
- data/app/views/hr_lite/attendance/_crm_picker.html.erb +9 -0
- data/app/views/hr_lite/attendance/_custom_status.html.erb +26 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +19 -2
- data/app/views/hr_lite/attendance/_work_mode.html.erb +92 -0
- data/app/views/hr_lite/attendance/show.html.erb +4 -1
- data/app/views/hr_lite/holidays/_next_holiday.html.erb +1 -1
- data/app/views/hr_lite/home/_today_checkins.html.erb +31 -0
- data/app/views/hr_lite/home/index.html.erb +5 -0
- data/app/views/hr_lite/team/show.html.erb +25 -4
- data/app/views/hr_lite/work_locations/index.html.erb +52 -0
- data/app/views/hr_lite/work_time/_bars.html.erb +44 -0
- data/app/views/hr_lite/work_time/_donut.html.erb +36 -0
- data/app/views/hr_lite/work_time/_report.html.erb +74 -0
- data/app/views/hr_lite/work_time/_timeline.html.erb +26 -0
- data/app/views/hr_lite/work_time/show.html.erb +10 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20261013110000_create_hr_lite_work_locations.rb +23 -0
- data/db/migrate/20261013110100_add_work_mode_to_hr_lite_attendance_records.rb +7 -0
- data/db/migrate/20261013110200_add_field_staff_to_hr_lite_employee_profiles.rb +5 -0
- data/db/migrate/20261013110300_create_hr_lite_work_mode_events.rb +30 -0
- data/db/migrate/20261013110400_add_break_minutes_to_hr_lite_settings.rb +6 -0
- data/db/migrate/20261013120000_grant_hr_lite_work_analytics.rb +14 -0
- data/db/migrate/20261013130000_add_office_location_to_hr_lite_holidays.rb +12 -0
- data/db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb +6 -0
- data/db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb +9 -0
- data/db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb +9 -0
- data/lib/hr_lite/configuration.rb +13 -1
- data/lib/hr_lite/notifications.rb +9 -4
- data/lib/hr_lite/permissions.rb +1 -0
- data/lib/hr_lite/role_seeds.rb +4 -2
- data/lib/hr_lite/seeds.rb +1 -1
- data/lib/hr_lite/version.rb +1 -1
- metadata +44 -1
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
<dt>Worked</dt><dd><%= hrl_duration(rec&.worked_duration) %></dd>
|
|
44
44
|
<% if part %><dt>Half</dt><dd><%= HrLite::DAY_PARTS.rassoc(part)&.first %></dd><% end %>
|
|
45
45
|
<% if leave %><dt>Leave</dt><dd><%= leave.leave_type.name %></dd><% end %>
|
|
46
|
-
<% if @day_info.kind == :holiday %><dt>Holiday</dt><dd><%= HrLite::Holiday.
|
|
46
|
+
<% if @day_info.kind == :holiday %><dt>Holiday</dt><dd><%= HrLite::Holiday.company_wide.for_office(HrLite::WorkingCalendar.office_for(hr_current_user.id)).where(date: @day).pick(:name) %></dd><% end %>
|
|
47
47
|
<% if rec&.flagged? %><dt>Flag</dt><dd><%= rec.flag_note %></dd><% end %>
|
|
48
48
|
<% if @day_ticket %><dt>Ticket</dt><dd><%= hrl_request_status_badge(@day_ticket.status) %> <%= @day_ticket.times_label %></dd><% end %>
|
|
49
49
|
</dl>
|
|
@@ -55,4 +55,7 @@
|
|
|
55
55
|
|
|
56
56
|
<% content_for :scripts do %>
|
|
57
57
|
<%= javascript_include_tag "hr_lite/geo_punch", defer: true %>
|
|
58
|
+
<%= javascript_include_tag "hr_lite/countdown", defer: true %>
|
|
59
|
+
<%= javascript_include_tag "hr_lite/crm_picker", defer: true %>
|
|
60
|
+
<%= javascript_include_tag "hr_lite/emoji_pick", defer: true %>
|
|
58
61
|
<% end %>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<section class="hrl-card">
|
|
2
2
|
<h2 class="hrl-card__title">Upcoming holidays</h2>
|
|
3
|
-
<% holidays = HrLite::Holiday.company_wide.upcoming.where(date: ..Date.current + 60).limit(3).to_a %>
|
|
3
|
+
<% holidays = HrLite::Holiday.company_wide.for_office(HrLite::WorkingCalendar.office_for(hr_current_user.id)).upcoming.where(date: ..Date.current + 60).limit(3).to_a %>
|
|
4
4
|
<% if holidays.any? %>
|
|
5
5
|
<div class="hrl-feed">
|
|
6
6
|
<% holidays.each do |holiday| %>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<%# Today's check-ins: on time, late, not in yet. Hover or tap a person for times. %>
|
|
2
|
+
<% groups = HrLite::TodayCheckins.new.groups
|
|
3
|
+
tip = ->(p) do
|
|
4
|
+
r = p.record
|
|
5
|
+
parts = [ hr_display_name(p.user) ]
|
|
6
|
+
if r
|
|
7
|
+
parts << "In #{r.check_in_at.strftime('%H:%M')}#{" (#{p.late_minutes} min late)" if p.late?}"
|
|
8
|
+
parts << (r.check_out_at ? "Out #{r.check_out_at.strftime('%H:%M')} · #{hrl_duration(r.worked_duration)} worked" : "#{hrl_duration(Time.current - r.check_in_at)} so far")
|
|
9
|
+
else
|
|
10
|
+
parts << "Not checked in yet"
|
|
11
|
+
end
|
|
12
|
+
parts.join(" · ")
|
|
13
|
+
end %>
|
|
14
|
+
<section class="hrl-card">
|
|
15
|
+
<h2 class="hrl-card__title">Today's check-ins</h2>
|
|
16
|
+
<% [ [ :on_time, "On time", "ok" ], [ :late, "Late", "warn" ], [ :not_in, "Not in yet", "muted" ] ].each do |key, label, tone| %>
|
|
17
|
+
<div class="hrl-checkins">
|
|
18
|
+
<p class="hrl-small"><span class="hrl-badge hrl-badge--<%= tone %>"><%= label %> · <%= groups[key].size %></span></p>
|
|
19
|
+
<% if groups[key].any? %>
|
|
20
|
+
<ul class="hrl-checkins__people">
|
|
21
|
+
<% groups[key].each do |p| %>
|
|
22
|
+
<li><button type="button" class="hrl-person hrl-person--<%= tone %>" data-hrl-tip="<%= tip.(p) %>" title="<%= tip.(p) %>" aria-label="<%= tip.(p) %>">
|
|
23
|
+
<%= hr_display_name(p.user).to_s.scan(/[[:alpha:]]+/).first(2).map { |w| w[0] }.join.upcase %>
|
|
24
|
+
</button></li>
|
|
25
|
+
<% end %>
|
|
26
|
+
</ul>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
<a class="hrl-card__link" href="<%= hr_lite.team_path %>">Team board</a>
|
|
31
|
+
</section>
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
<% end %>
|
|
31
31
|
</section>
|
|
32
32
|
<% end %>
|
|
33
|
+
<%= render "hr_lite/home/today_checkins" %>
|
|
33
34
|
<%= render "hr_lite/holidays/next_holiday" %>
|
|
34
35
|
|
|
35
36
|
<section class="hrl-card">
|
|
@@ -72,4 +73,8 @@
|
|
|
72
73
|
|
|
73
74
|
<% content_for :scripts do %>
|
|
74
75
|
<%= javascript_include_tag "hr_lite/geo_punch", defer: true %>
|
|
76
|
+
<%= javascript_include_tag "hr_lite/chart_tip", defer: true %>
|
|
77
|
+
<%= javascript_include_tag "hr_lite/countdown", defer: true %>
|
|
78
|
+
<%= javascript_include_tag "hr_lite/crm_picker", defer: true %>
|
|
79
|
+
<%= javascript_include_tag "hr_lite/emoji_pick", defer: true %>
|
|
75
80
|
<% end %>
|
|
@@ -20,19 +20,40 @@
|
|
|
20
20
|
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @board.rows.size %></div><div class="hrl-kpi__label">Team size</div></div>
|
|
21
21
|
</div>
|
|
22
22
|
|
|
23
|
+
<% if @statuses %>
|
|
24
|
+
<section class="hrl-card">
|
|
25
|
+
<%= form_with url: hr_lite.team_path, method: :get, local: true, class: "hrl-row" do %>
|
|
26
|
+
<% if @departments.any? %>
|
|
27
|
+
<div class="hrl-field"><label for="team-dept">Department</label><%= select_tag :department, options_for_select(@departments, params[:department]), include_blank: "All", id: "team-dept" %></div>
|
|
28
|
+
<% end %>
|
|
29
|
+
<div class="hrl-field"><label for="team-mode">Mode</label><%= select_tag :mode, options_for_select(HrLite::WorkModeEvent::MODES.map { |k, v| [ v[0], k ] } + HrLite::WorkStatus::DERIVED.map { |k, v| [ v, k ] }.reject { |_, k| k == "offline" }, @mode), include_blank: "All", id: "team-mode" %></div>
|
|
30
|
+
<div class="hrl-field"><label for="team-avail">Availability</label><%= select_tag :availability, options_for_select([ [ "Available", "available" ], [ "Not available", "unavailable" ] ], @availability), include_blank: "All", id: "team-avail" %></div>
|
|
31
|
+
<div class="hrl-form-actions"><button type="submit" class="hrl-btn">Filter</button></div>
|
|
32
|
+
<% end %>
|
|
33
|
+
</section>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
23
36
|
<section class="hrl-card">
|
|
24
|
-
<%
|
|
37
|
+
<% rows = @rows || @board.rows %>
|
|
38
|
+
<% if rows.any? %>
|
|
25
39
|
<div class="hrl-table-wrap">
|
|
26
40
|
<table class="hrl-table hrl-table--stack">
|
|
27
|
-
<thead><tr><th>Name</th
|
|
41
|
+
<thead><tr><th>Name</th><% if @statuses %><th>Now</th><% end %><th>Status</th><th>Hours <%= @date == Date.current ? "today" : "that day" %></th><th>Hours this month</th></tr></thead>
|
|
28
42
|
<tbody>
|
|
29
|
-
<%
|
|
43
|
+
<% rows.each do |row| %>
|
|
30
44
|
<tr>
|
|
31
45
|
<td data-label="Name">
|
|
32
46
|
<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
47
|
<span class="hrl-small hrl-muted"> · <%= row.profile.designation %></span><% end %><% if HrLite.config.attendance_slots && row.profile&.buffer_minutes %>
|
|
34
48
|
<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>
|
|
35
49
|
</td>
|
|
50
|
+
<% if (st = @statuses&.dig(row.user.id)) %>
|
|
51
|
+
<td data-label="Now"><span class="hrl-modes"><%= hrl_status_icons(st) %></span>
|
|
52
|
+
<span class="hrl-small"><%= st.primary.place || st.primary.label %><% if st.overlay %> · <%= st.overlay.label %><% end %>
|
|
53
|
+
<% if (till = st.overlay&.until || (st.primary.until if st.primary.until.respond_to?(:hour))) %> · until <%= till.strftime("%H:%M") %><% end %></span>
|
|
54
|
+
<% if st.updated_at %><span class="hrl-small hrl-muted"> · since <%= st.updated_at.strftime("%H:%M") %></span><% end %>
|
|
55
|
+
<% if st.custom %><div class="hrl-custom-status hrl-small"><% if st.custom.emoji %><span aria-hidden="true"><%= st.custom.emoji %></span> <% end %><%= st.custom.title %></div><% end %></td>
|
|
56
|
+
<% end %>
|
|
36
57
|
<td data-label="Status"><%= hrl_team_status(row) %></td>
|
|
37
58
|
<td data-label="Hours <%= @date == Date.current ? "today" : "that day" %>" class="hrl-num"><%= hrl_duration(row.day_seconds) %></td>
|
|
38
59
|
<td data-label="Hours this month" class="hrl-num"><%= hrl_duration(row.month_seconds) %></td>
|
|
@@ -42,7 +63,7 @@
|
|
|
42
63
|
</table>
|
|
43
64
|
</div>
|
|
44
65
|
<% else %>
|
|
45
|
-
<p class="hrl-card--empty">Nobody to show for this date
|
|
66
|
+
<p class="hrl-card--empty">Nobody to show<%= @rows ? " for these filters" : " for this date" %>.</p>
|
|
46
67
|
<% end %>
|
|
47
68
|
<p class="hrl-small hrl-muted hrl-mt">Forgot a punch yourself?
|
|
48
69
|
<a class="hrl-card__link" href="<%= hr_lite.new_regularization_request_path %>">Raise a regularization ticket</a></p>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<% content_for(:page_title) { "WFH locations" } %>
|
|
2
|
+
<% content_for(:back, "Attendance") %>
|
|
3
|
+
<div class="hrl-page__head">
|
|
4
|
+
<h1 class="hrl-page__title">My WFH locations</h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<section class="hrl-card">
|
|
8
|
+
<p class="hrl-small hrl-muted">On an approved WFH day you check in within <%= HrLite::WorkLocation::FENCE_M %> m of your active location.
|
|
9
|
+
Register up to <%= HrLite::WorkLocation::MAX_LIVE %> places; HR approves each one. Your location is read only when you check in or tap
|
|
10
|
+
"Register here" — never tracked in between. Teammates see "Home", never the address.</p>
|
|
11
|
+
<% if @locations.any? %>
|
|
12
|
+
<% @locations.each do |location| %>
|
|
13
|
+
<div class="hrl-feed__item hrl-row">
|
|
14
|
+
<div class="hrl-feed__body">
|
|
15
|
+
<strong><%= location.name %></strong>
|
|
16
|
+
<% if location.active? %><span class="hrl-badge hrl-badge--ok">Active</span><% end %>
|
|
17
|
+
<%= hrl_request_status_badge(location.status) %>
|
|
18
|
+
<% if location.address.present? %><div class="hrl-small hrl-muted"><%= location.address %></div><% end %>
|
|
19
|
+
<% if location.decision_note.present? %><div class="hrl-small hrl-muted">HR: <%= location.decision_note %></div><% end %>
|
|
20
|
+
</div>
|
|
21
|
+
<% if location.approved? && !location.active? %>
|
|
22
|
+
<%= button_to "Use this", hr_lite.activate_work_location_path(location), method: :post, class: "hrl-btn" %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<%= button_to "Remove", hr_lite.remove_work_location_path(location), method: :post, class: "hrl-btn hrl-btn--danger",
|
|
25
|
+
form: { data: { turbo_confirm: "Remove #{location.name}?" } } %>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<p class="hrl-card--empty">No locations yet.</p>
|
|
30
|
+
<% end %>
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
<%= render "hr_lite/shared/form_errors", record: @location %>
|
|
34
|
+
|
|
35
|
+
<% if @locations.size < HrLite::WorkLocation::MAX_LIVE %>
|
|
36
|
+
<section class="hrl-card">
|
|
37
|
+
<h2 class="hrl-card__title">Register where you are now</h2>
|
|
38
|
+
<%= form_with url: hr_lite.work_locations_path, method: :post, local: true,
|
|
39
|
+
html: { data: { "hrl-geo-punch": true, "hrl-geo-required": true } } do %>
|
|
40
|
+
<div class="hrl-field"><label for="loc-name">Name</label><%= text_field_tag :name, @location.name, required: true, maxlength: 60, placeholder: "Home", id: "loc-name" %></div>
|
|
41
|
+
<div class="hrl-field"><label for="loc-address">Address or landmark (optional, HR only)</label><%= text_field_tag :address, @location.address, maxlength: 200, id: "loc-address" %></div>
|
|
42
|
+
<input type="hidden" name="lat"><input type="hidden" name="lng">
|
|
43
|
+
<input type="hidden" name="accuracy_m"><input type="hidden" name="geo_status">
|
|
44
|
+
<button type="submit" class="hrl-btn hrl-btn--primary hrl-btn--block">Register here</button>
|
|
45
|
+
<p class="hrl-small hrl-muted" data-hrl-geo-status role="status" aria-live="polite"></p>
|
|
46
|
+
<% end %>
|
|
47
|
+
</section>
|
|
48
|
+
<% end %>
|
|
49
|
+
|
|
50
|
+
<% content_for :scripts do %>
|
|
51
|
+
<%= javascript_include_tag "hr_lite/geo_punch", defer: true %>
|
|
52
|
+
<% end %>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<%# Stacked bars per bucket (day / week / month), Offline left out. %>
|
|
2
|
+
<% buckets = result.by_bucket
|
|
3
|
+
totals = buckets.transform_values { |m| m.except("offline").values.sum }
|
|
4
|
+
max = [ totals.values.max.to_f, 1 ].max
|
|
5
|
+
w = 12; gap = 4; h = 100
|
|
6
|
+
fmt = { day: "%-d", week: "%-d %b", month: "%b" }[period.bucket] %>
|
|
7
|
+
<section class="hrl-card">
|
|
8
|
+
<h2 class="hrl-card__title">By <%= period.bucket %></h2>
|
|
9
|
+
<div class="hrl-chart-scroll">
|
|
10
|
+
<svg class="hrl-bars" viewBox="0 0 <%= buckets.size * (w + gap) %> <%= h + 14 %>" role="img"
|
|
11
|
+
aria-label="Time per <%= period.bucket %> stacked by work mode; tallest bar <%= hrl_duration(max) %>; details in the table">
|
|
12
|
+
<% buckets.each_with_index do |(key, modes), i| %>
|
|
13
|
+
<% x = i * (w + gap); y = h %>
|
|
14
|
+
<a href="<%= path.(period: period.bucket == :day ? "day" : (period.bucket == :week ? "week" : "month"), date: key) %>">
|
|
15
|
+
<% modes.except("offline").sort.each do |mode, secs| %>
|
|
16
|
+
<% next unless secs.positive? %>
|
|
17
|
+
<% bh = secs / max * h; y -= bh %>
|
|
18
|
+
<rect x="<%= x %>" y="<%= y.round(2) %>" width="<%= w %>" height="<%= bh.round(2) %>" class="hrl-fill--<%= mode %>">
|
|
19
|
+
<title><%= key.strftime(fmt) %> · <%= label.(mode) %>: <%= hrl_duration(secs) %></title>
|
|
20
|
+
</rect>
|
|
21
|
+
<% end %>
|
|
22
|
+
<rect x="<%= x %>" y="0" width="<%= w %>" height="<%= h %>" fill="transparent"><title><%= key.strftime(fmt) %>: <%= hrl_duration(totals[key]) %> — open</title></rect>
|
|
23
|
+
</a>
|
|
24
|
+
<% if buckets.size <= 16 || (i % 7).zero? %>
|
|
25
|
+
<text x="<%= x + w / 2.0 %>" y="<%= h + 11 %>" text-anchor="middle" class="hrl-bars__label"><%= key.strftime(fmt) %></text>
|
|
26
|
+
<% end %>
|
|
27
|
+
<% end %>
|
|
28
|
+
</svg>
|
|
29
|
+
</div>
|
|
30
|
+
<details class="hrl-small">
|
|
31
|
+
<summary>Show as a table</summary>
|
|
32
|
+
<table class="hrl-table">
|
|
33
|
+
<thead><tr><th><%= period.bucket.to_s.capitalize %></th><th class="hrl-num">Working</th><th class="hrl-num">Breaks</th><th class="hrl-num">Total</th></tr></thead>
|
|
34
|
+
<tbody>
|
|
35
|
+
<% buckets.each do |key, modes| %>
|
|
36
|
+
<tr><td><%= key.strftime(period.bucket == :day ? "%a %-d %b" : fmt) %></td>
|
|
37
|
+
<td class="hrl-num"><%= hrl_duration(HrLite::WorkModeDurations::WORKING.sum { |m| modes[m] }) %></td>
|
|
38
|
+
<td class="hrl-num"><%= hrl_duration(HrLite::WorkModeDurations::BREAKS.sum { |m| modes[m] }) %></td>
|
|
39
|
+
<td class="hrl-num"><%= hrl_duration(totals[key]) %></td></tr>
|
|
40
|
+
<% end %>
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</details>
|
|
44
|
+
</section>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%# Share of tracked time (working + breaks + out of office; Offline excluded). %>
|
|
2
|
+
<% total = result.tracked
|
|
3
|
+
modes = result.by_mode.except("offline").select { |_, s| s.positive? }.sort_by { |_, s| -s }
|
|
4
|
+
counts = result.segments.group_by(&:mode).transform_values(&:size)
|
|
5
|
+
offset = 25.0 %>
|
|
6
|
+
<section class="hrl-card">
|
|
7
|
+
<h2 class="hrl-card__title">Where the time went</h2>
|
|
8
|
+
<% if total.positive? %>
|
|
9
|
+
<div class="hrl-chart-row">
|
|
10
|
+
<svg class="hrl-donut" viewBox="0 0 42 42" role="img" aria-label="Share of tracked time by work mode; details in the table">
|
|
11
|
+
<circle cx="21" cy="21" r="15.915" fill="none" class="hrl-donut__track" stroke-width="6"></circle>
|
|
12
|
+
<% modes.each do |mode, secs| %>
|
|
13
|
+
<% pct = secs * 100.0 / total %>
|
|
14
|
+
<circle cx="21" cy="21" r="15.915" fill="none" stroke-width="6" class="hrl-stroke--<%= mode %>" data-hrl-tip tabindex="0"
|
|
15
|
+
stroke-dasharray="<%= pct.round(3) %> <%= (100 - pct).round(3) %>" stroke-dashoffset="<%= offset.round(3) %>">
|
|
16
|
+
<title><%= label.(mode) %>: <%= hrl_duration(secs) %> (<%= pct.round %>%)</title>
|
|
17
|
+
</circle>
|
|
18
|
+
<% offset -= pct %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</svg>
|
|
21
|
+
<table class="hrl-table hrl-small">
|
|
22
|
+
<thead><tr><th>Mode</th><th class="hrl-num">Time</th><th class="hrl-num">Share</th><th class="hrl-num">Stretches</th></tr></thead>
|
|
23
|
+
<tbody>
|
|
24
|
+
<% modes.each do |mode, secs| %>
|
|
25
|
+
<tr><td><span class="hrl-swatch hrl-fill-bg--<%= mode %>" aria-hidden="true"></span> <%= label.(mode) %></td>
|
|
26
|
+
<td class="hrl-num"><%= hrl_duration(secs) %></td><td class="hrl-num"><%= (secs * 100.0 / total).round %>%</td>
|
|
27
|
+
<td class="hrl-num"><%= counts[mode] %></td></tr>
|
|
28
|
+
<% end %>
|
|
29
|
+
</tbody>
|
|
30
|
+
</table>
|
|
31
|
+
</div>
|
|
32
|
+
<p class="hrl-small hrl-muted">Shares are of tracked time (<%= hrl_duration(total) %>: working, breaks and out of office). Offline (<%= hrl_duration(result.offline) %>) is not included.</p>
|
|
33
|
+
<% else %>
|
|
34
|
+
<p class="hrl-card--empty">Only offline time recorded.</p>
|
|
35
|
+
<% end %>
|
|
36
|
+
</section>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<%# locals: result (WorkModeDurations::Result), period, path (->(params) { url }), records, names, keep, timeline (all optional) %>
|
|
2
|
+
<% path = local_assigns[:path]
|
|
3
|
+
records = local_assigns[:records] || []
|
|
4
|
+
names = local_assigns[:names]
|
|
5
|
+
label = ->(m) { HrLite::WorkModeEvent.label(m) || m.humanize } %>
|
|
6
|
+
|
|
7
|
+
<section class="hrl-card">
|
|
8
|
+
<div class="hrl-row hrl-row--tabs hrl-row--mb">
|
|
9
|
+
<% HrLite::WorkModeDurations::Period::KINDS.excluding("custom").each do |kind| %>
|
|
10
|
+
<a class="hrl-btn <%= 'hrl-btn--primary' if period.kind == kind %>" <%= 'aria-current="page"'.html_safe if period.kind == kind %>
|
|
11
|
+
href="<%= path.(period: kind, date: period.from) %>"><%= kind.capitalize %></a>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="hrl-row hrl-row--mb">
|
|
15
|
+
<% unless period.kind == "custom" %>
|
|
16
|
+
<a class="hrl-btn" href="<%= path.(period: period.kind, date: period.shift(-1)) %>" aria-label="Previous <%= period.kind %>">‹</a>
|
|
17
|
+
<% end %>
|
|
18
|
+
<strong><%= period.label %></strong>
|
|
19
|
+
<% if period.kind != "custom" && period.to < Date.current %>
|
|
20
|
+
<a class="hrl-btn" href="<%= path.(period: period.kind, date: period.shift(1)) %>" aria-label="Next <%= period.kind %>">›</a>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
<%= form_with url: path.({}), method: :get, local: true, class: "hrl-row" do %>
|
|
24
|
+
<input type="hidden" name="period" value="custom">
|
|
25
|
+
<% (local_assigns[:keep] || {}).each do |k, v| %><input type="hidden" name="<%= k %>" value="<%= v %>"><% end %>
|
|
26
|
+
<div class="hrl-field"><label for="wt-from">From</label><%= date_field_tag :from, period.from, max: Date.current, id: "wt-from" %></div>
|
|
27
|
+
<div class="hrl-field"><label for="wt-to">To</label><%= date_field_tag :to, period.to, max: Date.current, id: "wt-to" %></div>
|
|
28
|
+
<div class="hrl-form-actions"><button type="submit" class="hrl-btn">Show range</button></div>
|
|
29
|
+
<% end %>
|
|
30
|
+
</section>
|
|
31
|
+
|
|
32
|
+
<% if result.days_with_data.empty? %>
|
|
33
|
+
<section class="hrl-card"><p class="hrl-card--empty">No recorded activity in <%= period.label %>. Days with nothing recorded are left out rather than shown as zero.</p></section>
|
|
34
|
+
<% else %>
|
|
35
|
+
<% office = result.by_mode["office"]; wfh = result.by_mode["wfh"]; field = result.by_mode["travelling"] + result.by_mode["outside_meeting"] %>
|
|
36
|
+
<div class="hrl-kpis">
|
|
37
|
+
<% [ [ "Attendance (check-in to check-out)", result.attendance ], [ "Working time", result.working ], [ "Breaks", result.breaks ],
|
|
38
|
+
[ "In office", office ], [ "Work from home", wfh ], [ "Travel and client meetings", field ], [ "Out of office", result.other ] ].each do |kpi_label, secs| %>
|
|
39
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_duration(secs) %></div><div class="hrl-kpi__label"><%= kpi_label %></div></div>
|
|
40
|
+
<% end %>
|
|
41
|
+
</div>
|
|
42
|
+
<p class="hrl-small hrl-muted">Working time = in office, work from home, travelling, client and other meetings, and busy. It is time in those modes, not a measure of output.
|
|
43
|
+
Recorded on <%= pluralize(result.days_with_data.size, "day") %>.</p>
|
|
44
|
+
|
|
45
|
+
<%= render "hr_lite/work_time/donut", result: result, label: label %>
|
|
46
|
+
<% if period.kind == "day" %>
|
|
47
|
+
<% if local_assigns.fetch(:timeline, true) %>
|
|
48
|
+
<%= render "hr_lite/work_time/timeline", result: result, period: period, label: label, names: names %>
|
|
49
|
+
<% end %>
|
|
50
|
+
<% else %>
|
|
51
|
+
<%= render "hr_lite/work_time/bars", result: result, period: period, label: label, path: path %>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
54
|
+
<% manual = result.by_source["employee"] + result.by_source["hr"] %>
|
|
55
|
+
<section class="hrl-card">
|
|
56
|
+
<h2 class="hrl-card__title">Set automatically vs chosen</h2>
|
|
57
|
+
<p class="hrl-small">Automatic (check-in, check-out, timers): <strong><%= hrl_duration(result.by_source["system"]) %></strong> ·
|
|
58
|
+
chosen by the employee: <strong><%= hrl_duration(result.by_source["employee"]) %></strong>
|
|
59
|
+
<% if result.by_source["hr"].positive? %> · set by HR: <strong><%= hrl_duration(result.by_source["hr"]) %></strong><% end %></p>
|
|
60
|
+
</section>
|
|
61
|
+
|
|
62
|
+
<% if records.any? %>
|
|
63
|
+
<section class="hrl-card">
|
|
64
|
+
<h2 class="hrl-card__title">Check-ins</h2>
|
|
65
|
+
<% records.each do |r| %>
|
|
66
|
+
<div class="hrl-feed__item hrl-small"><div class="hrl-feed__body">
|
|
67
|
+
<% if names %><strong><%= names[r.user_id] %></strong> · <% end %>
|
|
68
|
+
<%= r.date.strftime("%a %-d %b") %> · in <%= r.check_in_at.strftime("%H:%M") %> · out <%= r.check_out_at&.strftime("%H:%M") || "—" %>
|
|
69
|
+
<% if r.work_mode_label %> · <%= r.work_mode_label %><% end %>
|
|
70
|
+
</div></div>
|
|
71
|
+
<% end %>
|
|
72
|
+
</section>
|
|
73
|
+
<% end %>
|
|
74
|
+
<% end %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%# A day as one horizontal strip per person: 00:00 to 24:00. A dashed edge marks a system-set stretch. %>
|
|
2
|
+
<% day_start = period.from.in_time_zone(HrLite.config.time_zone)
|
|
3
|
+
rows = result.segments.group_by(&:user_id)
|
|
4
|
+
pct = ->(t) { ((t - day_start) / 86_400.0 * 100).clamp(0, 100) } %>
|
|
5
|
+
<section class="hrl-card">
|
|
6
|
+
<h2 class="hrl-card__title">The day, hour by hour</h2>
|
|
7
|
+
<% rows.each do |user_id, segs| %>
|
|
8
|
+
<% if names %><p class="hrl-small"><strong><%= names[user_id] %></strong></p><% end %>
|
|
9
|
+
<svg class="hrl-timeline" viewBox="0 0 100 10" preserveAspectRatio="none" role="img" aria-label="Work modes through the day; details in the list below">
|
|
10
|
+
<% segs.each do |seg| %>
|
|
11
|
+
<rect x="<%= pct.(seg.from).round(3) %>" y="1" width="<%= [ pct.(seg.to) - pct.(seg.from), 0.15 ].max.round(3) %>" height="8"
|
|
12
|
+
class="<%= class_names("hrl-fill--#{seg.mode}", "hrl-timeline__auto": seg.source == "system") %>" data-hrl-tip tabindex="0">
|
|
13
|
+
<title><%= label.(seg.mode) %> · <%= seg.from.strftime("%H:%M") %>–<%= seg.to.strftime("%H:%M") %> · <%= hrl_duration(seg.seconds) %> · <%= HrLite::ApplicationHelper::MODE_SOURCES[seg.source] %></title>
|
|
14
|
+
</rect>
|
|
15
|
+
<% end %>
|
|
16
|
+
</svg>
|
|
17
|
+
<div class="hrl-timeline__axis hrl-small hrl-muted" aria-hidden="true"><span>00</span><span>06</span><span>12</span><span>18</span><span>24</span></div>
|
|
18
|
+
<ol class="hrl-timeline__list hrl-small">
|
|
19
|
+
<% segs.each do |seg| %>
|
|
20
|
+
<li><span class="hrl-swatch hrl-fill-bg--<%= seg.mode %>" aria-hidden="true"></span>
|
|
21
|
+
<strong><%= seg.from.strftime("%H:%M") %>–<%= seg.to.strftime("%H:%M") %></strong> <%= label.(seg.mode) %> · <%= hrl_duration(seg.seconds) %>
|
|
22
|
+
<span class="hrl-muted">· <%= HrLite::ApplicationHelper::MODE_SOURCES[seg.source] %></span></li>
|
|
23
|
+
<% end %>
|
|
24
|
+
</ol>
|
|
25
|
+
<% end %>
|
|
26
|
+
</section>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<% content_for(:page_title) { "My work time" } %>
|
|
2
|
+
<% content_for(:back, "Attendance") %>
|
|
3
|
+
<div class="hrl-page__head">
|
|
4
|
+
<h1 class="hrl-page__title">My work time</h1>
|
|
5
|
+
</div>
|
|
6
|
+
<p class="hrl-small hrl-muted">Only you and HR can see this page's detail. Times come from your recorded check-ins and work modes.</p>
|
|
7
|
+
<% if @period_error %><div class="hrl-errors" role="alert"><%= @period_error %></div><% end %>
|
|
8
|
+
<%= render "hr_lite/work_time/report", result: @result, period: @period, records: @records,
|
|
9
|
+
path: ->(p) { hr_lite.work_time_path(p) } %>
|
|
10
|
+
<% content_for :scripts do %><%= javascript_include_tag "hr_lite/chart_tip", defer: true %><% end %>
|
data/config/routes.rb
CHANGED
|
@@ -23,6 +23,16 @@ HrLite::Engine.routes.draw do
|
|
|
23
23
|
resources :wfh_requests, only: %i[index create] do
|
|
24
24
|
member { post :cancel }
|
|
25
25
|
end
|
|
26
|
+
get "work_time", to: "work_time#show", as: :work_time
|
|
27
|
+
resource :work_mode, only: :create do
|
|
28
|
+
post :end_overlay
|
|
29
|
+
get :crm_search
|
|
30
|
+
post :status
|
|
31
|
+
post :clear_status
|
|
32
|
+
end
|
|
33
|
+
resources :work_locations, only: %i[index create] do
|
|
34
|
+
member { post :activate; post :remove }
|
|
35
|
+
end
|
|
26
36
|
resources :approvals, only: :index
|
|
27
37
|
resources :expenses, only: %i[index new create] do
|
|
28
38
|
member { post :cancel }
|
|
@@ -80,6 +90,12 @@ HrLite::Engine.routes.draw do
|
|
|
80
90
|
resources :wfh_requests, only: %i[index show create] do
|
|
81
91
|
member { post :approve; post :reject }
|
|
82
92
|
end
|
|
93
|
+
resources :work_locations, only: :index do
|
|
94
|
+
member { post :approve; post :reject }
|
|
95
|
+
end
|
|
96
|
+
get "work_modes", to: "work_modes#index", as: :work_modes
|
|
97
|
+
get "work_time", to: "work_time#index", as: :work_time
|
|
98
|
+
post "work_modes/:user_id", to: "work_modes#override", as: :work_mode_override
|
|
83
99
|
resources :roles, except: :show do
|
|
84
100
|
resources :assignments, only: %i[create destroy], controller: "role_assignments"
|
|
85
101
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class CreateHrLiteWorkLocations < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hr_lite_work_locations do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.string :name, null: false
|
|
6
|
+
t.decimal :lat, precision: 9, scale: 6, null: false
|
|
7
|
+
t.decimal :lng, precision: 9, scale: 6, null: false
|
|
8
|
+
t.integer :accuracy_m, null: false
|
|
9
|
+
t.string :address
|
|
10
|
+
t.string :status, null: false, default: "pending"
|
|
11
|
+
t.boolean :active, null: false, default: false
|
|
12
|
+
t.bigint :decided_by_id
|
|
13
|
+
t.datetime :decided_at
|
|
14
|
+
t.text :decision_note
|
|
15
|
+
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
add_index :hr_lite_work_locations, [ :user_id, :status ]
|
|
19
|
+
add_index :hr_lite_work_locations, :user_id, unique: true, where: "active", name: "index_hr_lite_work_locations_one_active"
|
|
20
|
+
add_check_constraint :hr_lite_work_locations, "status IN ('pending','approved','rejected','removed')",
|
|
21
|
+
name: "hr_lite_work_locations_status"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddWorkModeToHrLiteAttendanceRecords < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :hr_lite_attendance_records, :work_mode, :string
|
|
4
|
+
add_column :hr_lite_attendance_records, :work_location_id, :bigint
|
|
5
|
+
remove_column :hr_lite_attendance_records, :wfh, :boolean, null: false, default: false
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class CreateHrLiteWorkModeEvents < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hr_lite_work_mode_events do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.string :mode, null: false
|
|
6
|
+
t.string :layer, null: false
|
|
7
|
+
t.string :source, null: false
|
|
8
|
+
t.datetime :started_at, null: false
|
|
9
|
+
t.datetime :ends_at
|
|
10
|
+
t.datetime :ended_at
|
|
11
|
+
t.string :end_reason
|
|
12
|
+
t.boolean :overrun, null: false, default: false
|
|
13
|
+
t.bigint :attendance_record_id
|
|
14
|
+
t.bigint :wfh_request_id
|
|
15
|
+
t.bigint :work_location_id
|
|
16
|
+
t.bigint :set_by_id
|
|
17
|
+
t.string :title
|
|
18
|
+
t.string :client
|
|
19
|
+
t.string :destination
|
|
20
|
+
t.text :reason
|
|
21
|
+
|
|
22
|
+
t.timestamps
|
|
23
|
+
end
|
|
24
|
+
add_index :hr_lite_work_mode_events, [ :user_id, :started_at ]
|
|
25
|
+
add_index :hr_lite_work_mode_events, [ :user_id, :layer ], unique: true, where: "ended_at IS NULL",
|
|
26
|
+
name: "index_hr_lite_work_mode_events_one_open"
|
|
27
|
+
add_check_constraint :hr_lite_work_mode_events, "source IN ('system','employee','hr')", name: "hr_lite_work_mode_events_source"
|
|
28
|
+
add_check_constraint :hr_lite_work_mode_events, "layer IN ('primary','overlay')", name: "hr_lite_work_mode_events_layer"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
class AddBreakMinutesToHrLiteSettings < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :hr_lite_settings, :lunch_break_minutes, :integer, null: false, default: 30
|
|
4
|
+
add_column :hr_lite_settings, :short_break_minutes, :integer, null: false, default: 10
|
|
5
|
+
end
|
|
6
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class GrantHrLiteWorkAnalytics < ActiveRecord::Migration[8.1]
|
|
2
|
+
# work_analytics.view is new in 0.31.0. RoleSeeds only creates missing
|
|
3
|
+
# roles, so the existing HR, Leadership and Super Admin roles get it here.
|
|
4
|
+
# Manager deliberately does not.
|
|
5
|
+
def up
|
|
6
|
+
return unless HrLite::Role.exists?
|
|
7
|
+
|
|
8
|
+
HrLite::Role.where(name: [ "HR", "Leadership", "Super Admin" ]).find_each do |role|
|
|
9
|
+
HrLite::RoleGrant.find_or_initialize_by(role: role, permission_key: "work_analytics.view").update!(scope: "all")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down; end
|
|
14
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class AddOfficeLocationToHrLiteHolidays < ActiveRecord::Migration[8.1]
|
|
2
|
+
# A holiday is for every office (office_location_id null) or for one; two
|
|
3
|
+
# offices may close on the same date for different reasons.
|
|
4
|
+
def change
|
|
5
|
+
add_column :hr_lite_holidays, :office_location_id, :bigint
|
|
6
|
+
remove_index :hr_lite_holidays, :date, unique: true
|
|
7
|
+
add_index :hr_lite_holidays, :date, unique: true, where: "office_location_id IS NULL",
|
|
8
|
+
name: "index_hr_lite_holidays_all_offices"
|
|
9
|
+
add_index :hr_lite_holidays, [ :date, :office_location_id ], unique: true,
|
|
10
|
+
name: "index_hr_lite_holidays_per_office"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddCalendarAndCrmToHrLiteWorkModeEvents < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :hr_lite_work_mode_events, :calendar_event_id, :string
|
|
4
|
+
add_column :hr_lite_work_mode_events, :crm_ref, :string
|
|
5
|
+
# A calendar meeting opens at most once per person, however often we sync.
|
|
6
|
+
add_index :hr_lite_work_mode_events, [ :user_id, :calendar_event_id ], unique: true,
|
|
7
|
+
where: "calendar_event_id IS NOT NULL", name: "index_hr_lite_work_mode_events_one_per_calendar_event"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddCustomStatusToHrLiteWorkModeEvents < ActiveRecord::Migration[8.1]
|
|
2
|
+
# A third layer: a person's own status ("In Goa till Friday" + an emoji),
|
|
3
|
+
# alongside their work mode and any break or meeting.
|
|
4
|
+
def change
|
|
5
|
+
add_column :hr_lite_work_mode_events, :emoji, :string
|
|
6
|
+
remove_check_constraint :hr_lite_work_mode_events, "layer IN ('primary','overlay')", name: "hr_lite_work_mode_events_layer"
|
|
7
|
+
add_check_constraint :hr_lite_work_mode_events, "layer IN ('primary','overlay','status')", name: "hr_lite_work_mode_events_layer"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -20,7 +20,19 @@ module HrLite
|
|
|
20
20
|
# true = check-in slots, late penalties and HR buffers (default off)
|
|
21
21
|
:attendance_slots,
|
|
22
22
|
# true = check-in only inside an active office radius, unless WFH is approved for the day
|
|
23
|
-
:office_geofence
|
|
23
|
+
:office_geofence,
|
|
24
|
+
# ->(user, from, to) { [{ id:, title:, starts_at:, ends_at: }] } — timed,
|
|
25
|
+
# accepted events; starts Meeting mode by itself. Pairs with calendar_users.
|
|
26
|
+
:calendar_events,
|
|
27
|
+
# -> { user ids with a connected calendar }
|
|
28
|
+
:calendar_users,
|
|
29
|
+
# ->(user) { { connected:, label:, connect_url:, disconnect_url: } or nil } —
|
|
30
|
+
# the one-click "Connect Google Calendar" button (nil = no button)
|
|
31
|
+
:calendar_connection,
|
|
32
|
+
# ->(user, query) { [{ id:, label: }] } — leads to link to a client meeting
|
|
33
|
+
:crm_search,
|
|
34
|
+
# ->(id) { { label:, url: } or nil } — one lead, to validate and link it
|
|
35
|
+
:crm_find
|
|
24
36
|
|
|
25
37
|
attr_reader :leave_year_start_month, :slip_release_day, :self_regularization, :work_hours
|
|
26
38
|
|
|
@@ -72,7 +72,10 @@ module HrLite
|
|
|
72
72
|
"wfh.requested" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
|
|
73
73
|
"wfh.approved" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
74
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 }
|
|
75
|
+
"wfh.cancelled" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
|
|
76
|
+
"work_location.requested" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
|
|
77
|
+
"work_location.approved" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
78
|
+
"work_location.rejected" => { bell: true, email: true, leadership_email: false, leadership_bell: false }
|
|
76
79
|
}.freeze
|
|
77
80
|
|
|
78
81
|
class << self
|
|
@@ -131,9 +134,11 @@ module HrLite
|
|
|
131
134
|
next if user.email.blank?
|
|
132
135
|
|
|
133
136
|
begin
|
|
134
|
-
EventMailer.event(to: user.email, subject: title, heading: title,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
mail = EventMailer.event(to: user.email, subject: title, heading: title,
|
|
138
|
+
body: body, lines: lines, path: path, link_url: link_url)
|
|
139
|
+
# Busy or in a meeting: the email waits until that ends.
|
|
140
|
+
held = WorkStatus.do_not_disturb_until(user.id) if HrLite.config.office_geofence
|
|
141
|
+
held ? mail.deliver_later(wait_until: held) : mail.deliver_later
|
|
137
142
|
rescue => e
|
|
138
143
|
Rails.logger.error("[hr_lite] event email failed: #{e.class}: #{e.message}")
|
|
139
144
|
end
|
data/lib/hr_lite/permissions.rb
CHANGED
|
@@ -30,6 +30,7 @@ module HrLite
|
|
|
30
30
|
"profile.manage" => [ "People", "Create and edit employee profiles, onboard and offboard" ],
|
|
31
31
|
"attendance.view" => [ "Attendance", "See attendance records" ],
|
|
32
32
|
"attendance.manage" => [ "Attendance", "Correct punches and decide regularization tickets" ],
|
|
33
|
+
"work_analytics.view" => [ "Attendance", "See another person's work-time analytics (time per work mode, day timelines)" ],
|
|
33
34
|
"leave.request" => [ "Leave", "Apply for leave and comp-off" ],
|
|
34
35
|
"leave.view" => [ "Leave", "See leave requests and balances" ],
|
|
35
36
|
"leave.approve" => [ "Leave", "Approve, reject and cancel leave and comp-off" ],
|