hr_lite 0.31.0 → 0.35.1
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 +30 -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 +59 -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 +26 -6
- 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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<%# locals: record (today's AttendanceRecord or nil) %>
|
|
2
|
+
<% status = HrLite::WorkStatus.for(hr_current_user.id)
|
|
3
|
+
working = record&.check_in_at && !record.check_out_at
|
|
4
|
+
timed = status.overlay&.until || (status.primary.until if status.primary.until.respond_to?(:hour)) %>
|
|
5
|
+
<div class="hrl-row hrl-row--mb hrl-small" role="status">
|
|
6
|
+
<span class="hrl-modes"><%= hrl_status_icons(status) %></span>
|
|
7
|
+
<span><strong><%= status.primary.label %></strong><% if status.overlay %> · <%= status.overlay.label %><% end %></span>
|
|
8
|
+
<% if status.primary.source == "system" && !status.overlay %><span class="hrl-muted">(automatic)</span><% end %>
|
|
9
|
+
<% if timed %>
|
|
10
|
+
<span class="hrl-muted">until <%= timed.strftime("%H:%M") %> ·
|
|
11
|
+
<span data-hrl-countdown="<%= timed.iso8601 %>"><%= hrl_duration([ timed - Time.current, 0 ].max) %> left</span></span>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<%= render "hr_lite/attendance/custom_status", status: status %>
|
|
15
|
+
<%= render "hr_lite/attendance/calendar_connection" %>
|
|
16
|
+
<% if status.half_day_leave %><p class="hrl-small hrl-muted">Half-day leave today (<%= status.half_day_leave.half_day_part || "half day" %>).</p><% end %>
|
|
17
|
+
|
|
18
|
+
<% if working %>
|
|
19
|
+
<% if status.overlay %>
|
|
20
|
+
<%= button_to "End #{status.overlay.label.downcase} now", hr_lite.end_overlay_work_mode_path, method: :post, class: "hrl-btn hrl-btn--block" %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<details>
|
|
23
|
+
<summary class="hrl-btn hrl-btn--block">Change my mode</summary>
|
|
24
|
+
<div class="hrl-mode-switch">
|
|
25
|
+
<% %w[lunch short_break office].each do |mode| %>
|
|
26
|
+
<%= button_to hr_lite.work_mode_path, params: { mode: mode }, method: :post, class: "hrl-btn hrl-btn--block" do %>
|
|
27
|
+
<%= hrl_mode_icon(HrLite::WorkStatus::Entry.new(mode: mode, label: HrLite::WorkModeEvent.label(mode))) %>
|
|
28
|
+
<%= HrLite::WorkModeEvent.label(mode) %>
|
|
29
|
+
<% end %>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% if HrLite::WfhRequest.approved_for(hr_current_user.id, Date.current) || HrLite::EmployeeProfile.where(user_id: hr_current_user.id).pick(:permanent_wfh) %>
|
|
32
|
+
<%= button_to "Work from home", hr_lite.work_mode_path, params: { mode: "wfh" }, method: :post, class: "hrl-btn hrl-btn--block" %>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
<details>
|
|
36
|
+
<summary class="hrl-btn hrl-btn--block">Busy / focus</summary>
|
|
37
|
+
<%= form_with url: hr_lite.work_mode_path, method: :post, local: true do %>
|
|
38
|
+
<input type="hidden" name="mode" value="busy">
|
|
39
|
+
<div class="hrl-field"><label for="busy-minutes">For</label>
|
|
40
|
+
<%= select_tag :minutes, options_for_select([ [ "30 minutes", 30 ], [ "1 hour", 60 ], [ "2 hours", 120 ] ]), id: "busy-minutes" %></div>
|
|
41
|
+
<button type="submit" class="hrl-btn hrl-btn--primary">Set busy</button>
|
|
42
|
+
<% end %>
|
|
43
|
+
</details>
|
|
44
|
+
<details>
|
|
45
|
+
<summary class="hrl-btn hrl-btn--block">Meeting</summary>
|
|
46
|
+
<%= form_with url: hr_lite.work_mode_path, method: :post, local: true do %>
|
|
47
|
+
<input type="hidden" name="mode" value="meeting">
|
|
48
|
+
<div class="hrl-field"><label for="mt-title">Meeting</label><input class="hrl-input" id="mt-title" name="title" maxlength="120" required></div>
|
|
49
|
+
<div class="hrl-field"><label for="mt-client">Client (optional)</label><input class="hrl-input" id="mt-client" name="client" maxlength="120"></div>
|
|
50
|
+
<%= render "hr_lite/attendance/crm_picker", id: "mt-crm" %>
|
|
51
|
+
<div class="hrl-field"><label for="mt-until">Ends at</label><input class="hrl-input" type="time" id="mt-until" name="until" required></div>
|
|
52
|
+
<button type="submit" class="hrl-btn hrl-btn--primary">Start meeting</button>
|
|
53
|
+
<% end %>
|
|
54
|
+
</details>
|
|
55
|
+
<details>
|
|
56
|
+
<summary class="hrl-btn hrl-btn--block">Travelling</summary>
|
|
57
|
+
<%= form_with url: hr_lite.work_mode_path, method: :post, local: true, data: { turbo_confirm: "Switch to Travelling?" } do %>
|
|
58
|
+
<input type="hidden" name="mode" value="travelling">
|
|
59
|
+
<div class="hrl-field"><label for="tr-dest">Going to</label><input class="hrl-input" id="tr-dest" name="destination" maxlength="120" required></div>
|
|
60
|
+
<div class="hrl-field"><label for="tr-why">Purpose</label><input class="hrl-input" id="tr-why" name="title" maxlength="120" required></div>
|
|
61
|
+
<div class="hrl-field"><label for="tr-client">Client (optional)</label><input class="hrl-input" id="tr-client" name="client" maxlength="120"></div>
|
|
62
|
+
<%= render "hr_lite/attendance/crm_picker", id: "tr-crm" %>
|
|
63
|
+
<div class="hrl-field"><label for="tr-until">Back by (optional)</label><input class="hrl-input" type="time" id="tr-until" name="until"></div>
|
|
64
|
+
<button type="submit" class="hrl-btn hrl-btn--primary">Start travelling</button>
|
|
65
|
+
<% end %>
|
|
66
|
+
</details>
|
|
67
|
+
<details>
|
|
68
|
+
<summary class="hrl-btn hrl-btn--block">Client meeting (outside)</summary>
|
|
69
|
+
<%= form_with url: hr_lite.work_mode_path, method: :post, local: true, data: { turbo_confirm: "Switch to an outside-office meeting?" } do %>
|
|
70
|
+
<input type="hidden" name="mode" value="outside_meeting">
|
|
71
|
+
<%= render "hr_lite/attendance/crm_picker", id: "om-crm" %>
|
|
72
|
+
<div class="hrl-field"><label for="om-client">Client or prospect<%= " (or link a lead above)" if HrLite.config.crm_search %></label><input class="hrl-input" id="om-client" name="client" maxlength="120"<%= " required" unless HrLite.config.crm_search %>></div>
|
|
73
|
+
<div class="hrl-field"><label for="om-why">Purpose</label><input class="hrl-input" id="om-why" name="title" maxlength="120" required></div>
|
|
74
|
+
<div class="hrl-field"><label for="om-where">Where (optional)</label><input class="hrl-input" id="om-where" name="destination" maxlength="120"></div>
|
|
75
|
+
<div class="hrl-field"><label for="om-until">Ends at (optional)</label><input class="hrl-input" type="time" id="om-until" name="until"></div>
|
|
76
|
+
<button type="submit" class="hrl-btn hrl-btn--primary">Start client meeting</button>
|
|
77
|
+
<% end %>
|
|
78
|
+
</details>
|
|
79
|
+
<details>
|
|
80
|
+
<summary class="hrl-btn hrl-btn--block">Out of office</summary>
|
|
81
|
+
<%= form_with url: hr_lite.work_mode_path, method: :post, local: true do %>
|
|
82
|
+
<input type="hidden" name="mode" value="out_of_office">
|
|
83
|
+
<div class="hrl-field"><label for="oo-why">Reason</label>
|
|
84
|
+
<%= select_tag :category, options_for_select(HrLite::WorkModeSwitch::OUT_OF_OFFICE_REASONS.invert), id: "oo-why" %></div>
|
|
85
|
+
<div class="hrl-field"><label for="oo-until">Back by</label><input class="hrl-input" type="time" id="oo-until" name="until" required></div>
|
|
86
|
+
<p class="hrl-small hrl-muted">Away for a full day? <a href="<%= hr_lite.new_leave_request_path %>">Apply for leave</a> instead.</p>
|
|
87
|
+
<button type="submit" class="hrl-btn hrl-btn--primary">Set out of office</button>
|
|
88
|
+
<% end %>
|
|
89
|
+
</details>
|
|
90
|
+
</div>
|
|
91
|
+
</details>
|
|
92
|
+
<% end %>
|
|
@@ -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
|