hr_lite 0.36.0 → 0.37.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 +19 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +3 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +2 -1
- data/app/controllers/hr_lite/admin/work_locations_controller.rb +11 -3
- data/app/controllers/hr_lite/admin/work_time_controller.rb +1 -1
- data/app/controllers/hr_lite/attendance_controller.rb +3 -1
- data/app/controllers/hr_lite/work_locations_controller.rb +16 -4
- data/app/controllers/hr_lite/work_time_controller.rb +1 -1
- data/app/models/hr_lite/attendance_record.rb +11 -0
- data/app/models/hr_lite/wfh_request.rb +1 -0
- data/app/models/hr_lite/work_location.rb +1 -1
- data/app/services/hr_lite/attendance_puncher.rb +84 -16
- data/app/views/hr_lite/admin/attendances/show.html.erb +3 -1
- data/app/views/hr_lite/admin/work_locations/index.html.erb +5 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +18 -0
- data/app/views/hr_lite/attendance/_trail.html.erb +13 -0
- data/app/views/hr_lite/home/_today_checkins.html.erb +3 -1
- data/app/views/hr_lite/wfh_requests/index.html.erb +1 -1
- data/app/views/hr_lite/work_locations/index.html.erb +10 -6
- data/app/views/hr_lite/work_time/_report.html.erb +2 -2
- data/app/views/layouts/hr_lite/application.html.erb +3 -1
- data/config/routes.rb +1 -1
- data/db/migrate/20261014031000_add_punch_places_to_hr_lite_attendance_records.rb +10 -0
- data/lib/hr_lite/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea9e044a63897a04e8618db0d7fa06e03aeedcde075aa2b6e834218b09bd733b
|
|
4
|
+
data.tar.gz: 36ce528066177bf11df3fd65c6a7d62bb503e2f2ca6b8a545c05d128cbd6f2b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 31d918ddc7dc1ad19b6c4d77a9aed5cc3767842019963690478769d4accad9772f755890a529359674676d6dab696d92acc440b36dc920063bba754abf09ac68
|
|
7
|
+
data.tar.gz: f74714fb54fd581fd49d0de6e3b3e38ac911ced19f511dda075353e634563d4b289bb379bc4fdd08391e3312067ed1dbe73905afd8831f0422070ba4331b613d
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.37.1] - 2026-10-14
|
|
11
|
+
|
|
12
|
+
- A WFH request still waiting for HR now lets the person check in away from the office on that day (HR has 24 hours to decide). The day is flagged "WFH request pending HR approval: <reason>", and HR gets the flagged-punch alert with that reason. HR was already told when the request was raised. Rejected requests and requests for other dates are still refused.
|
|
13
|
+
|
|
14
|
+
## [0.37.0] - 2026-10-14
|
|
15
|
+
|
|
16
|
+
- Home detection: a WFH check-in matches any approved home within 500 m, and the nearest one becomes active. From anywhere else the punch card asks "Is this your home location?". Yes saves the place as an approved, active home straight away (a GPS fix good to 500 m, fewer than three live homes, audit row `work_location.self_saved`). No checks in as Travelling. The question comes back at every check-in from a place that is not a home.
|
|
17
|
+
- WFH locations: "Register here" saves an approved home at once, and the first one becomes active. With three live homes the employee can no longer use, switch or remove one (refused server-side too); HR has a Remove button on Admin → WFH locations, which now opens on approved homes. Removing a home never rewrites past attendance.
|
|
18
|
+
- Travel: any WFH employee (not only field staff) may check in or out as Travelling away from their homes. A day begun at home that checks out away from every home must tick "I am travelling". A travel day can end at home without asking. No HR approval; the place is always shown.
|
|
19
|
+
- Every punch records its place (office, home, travel), the matched home on check-in and check-out, and metres to the nearest home. New columns on `hr_lite_attendance_records`: `check_in_place`, `check_out_place`, `check_out_work_location_id`, `check_in_home_m`, `check_out_home_m` (migration 20261014031000; older days stay blank).
|
|
20
|
+
- Daily trail: My work time (private), HR's per-person attendance and HR work time show "Check-in: Home (name) · 09:15 · 35 m from home · ±20 m", the same for check-out, and a "View route on Google Maps" link from start to end. Today's check-ins adds "Check-in: Home → Check-out: Travel" to each person.
|
|
21
|
+
- Messages: `HOME_ASK_MESSAGE`, `HOME_CAP_MESSAGE`, `HOME_FIX_MESSAGE` and `TRAVEL_OUT_MESSAGE` replace `HOME_OFF_SITE_MESSAGE` and `NO_HOME_MESSAGE`. `WorkLocation::MAX_ACCURACY_M` is now 500 (was 100).
|
|
22
|
+
- Choice rows (radio/checkbox) inside a `.hrl-field` keep their 44px row layout and 8px gaps (the field-staff check-in choices were affected too).
|
|
23
|
+
|
|
24
|
+
## [0.36.1] - 2026-10-14
|
|
25
|
+
|
|
26
|
+
- The home page punch card has a "Request WFH" button beside Apply leave (when the office geofence is on).
|
|
27
|
+
- A check-in refused for being outside the office now carries a "Request WFH" button in the warning, straight to the WFH request form. Before, the only way there was a small link at the bottom of the punch card.
|
|
28
|
+
|
|
10
29
|
## [0.36.0] - 2026-10-13
|
|
11
30
|
|
|
12
31
|
- Pick how long a mode lasts: Busy, Meeting and Out of office offer 15 min, 30 min, 1 h, 2 h, 4 h or 1 day (30 min preselected); Travelling and Client meeting also offer "Until I change it" (the default). Lunch and short break offer only lengths within their company limit — lunch 15 or 30 min by default — and one tap still takes the full limit; a longer break is refused. Every mode ends on its own at the picked time; the longest is a day. The choices are real radio buttons shown as 44px chips with 8px gaps.
|
|
@@ -346,6 +346,9 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
|
|
346
346
|
.hrl-field textarea { min-height: 90px; resize: vertical; }
|
|
347
347
|
.hrl-filter { appearance: none; margin-bottom: var(--hrl-gap-target); } /* select_filter.js search box above its select */
|
|
348
348
|
.hrl-field--check { display: flex; align-items: center; gap: .55rem; min-height: var(--hrl-target); }
|
|
349
|
+
.hrl-field--check + .hrl-field--check { margin-top: var(--hrl-gap-target); }
|
|
350
|
+
/* A choice row inside a .hrl-field keeps its row layout (.hrl-field label would make it a block). */
|
|
351
|
+
.hrl-field label.hrl-field--check { display: flex; font-size: inherit; font-weight: 400; margin-bottom: 0; }
|
|
349
352
|
.hrl-field--check input {
|
|
350
353
|
width: 1.15rem; height: 1.15rem; min-height: 0; flex: 0 0 auto;
|
|
351
354
|
accent-color: var(--hrl-accent);
|
|
@@ -55,7 +55,8 @@ module HrLite
|
|
|
55
55
|
@counts[:absent] -= 1 if @month.all_month.cover?(Date.current) && @day_status.for(Date.current).kind == :absent
|
|
56
56
|
@edit_date = params[:date].present? ? parse_date_param(params[:date]) : nil
|
|
57
57
|
@edit_record = @edit_date && AttendanceRecord.find_or_initialize_by(user_id: @employee.id, date: @edit_date)
|
|
58
|
-
@punches = AttendanceRecord.for_month(@month).where(user_id: @employee.id).where.not(check_in_at: nil)
|
|
58
|
+
@punches = AttendanceRecord.for_month(@month).where(user_id: @employee.id).where.not(check_in_at: nil)
|
|
59
|
+
.includes(:work_location, :check_out_work_location).order(:date)
|
|
59
60
|
@offices = OfficeLocation.active.to_a
|
|
60
61
|
end
|
|
61
62
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
|
-
# HR
|
|
4
|
-
#
|
|
3
|
+
# HR sees every home employees save, decides any still pending, and is
|
|
4
|
+
# the only one who changes a person's homes once they have MAX_LIVE.
|
|
5
|
+
# Coordinates are only shown here, inside attendance.view reach.
|
|
5
6
|
class WorkLocationsController < BaseController
|
|
6
7
|
def index
|
|
7
|
-
@status = params[:status].presence_in(WorkLocation::STATUSES) || "
|
|
8
|
+
@status = params[:status].presence_in(WorkLocation::STATUSES) || "approved"
|
|
8
9
|
@locations = paginate(hr_scope(WorkLocation.where(status: @status), "attendance.view")
|
|
9
10
|
.includes(:user).order(created_at: :desc))
|
|
10
11
|
end
|
|
@@ -20,6 +21,13 @@ module HrLite
|
|
|
20
21
|
decide("rejected", note)
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# Past attendance keeps its own coordinates and place; nothing is rewritten.
|
|
25
|
+
def remove
|
|
26
|
+
location = hr_scope(WorkLocation.live, "attendance.manage").where.not(user_id: hr_current_user.id).find(params[:id])
|
|
27
|
+
location.remove!(actor: hr_current_user)
|
|
28
|
+
redirect_to admin_work_locations_path(status: "removed"), notice: "#{location.name} removed for #{HrLite.display_name(location.user)}."
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
private
|
|
24
32
|
|
|
25
33
|
def decide(status, note)
|
|
@@ -32,7 +32,7 @@ module HrLite
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
@result = WorkModeDurations.call(ids, @period)
|
|
35
|
-
@records = @person ? AttendanceRecord.where(user_id: @person.id, date: @period.from..@period.to).where.not(check_in_at: nil).order(:date).limit(400) : []
|
|
35
|
+
@records = @person ? AttendanceRecord.includes(:work_location, :check_out_work_location).where(user_id: @person.id, date: @period.from..@period.to).where.not(check_in_at: nil).order(:date).limit(400) : []
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
end
|
|
@@ -52,11 +52,13 @@ module HrLite
|
|
|
52
52
|
|
|
53
53
|
def punch(kind)
|
|
54
54
|
field = params.fetch(:field, {}).permit(:mode, :purpose, :place).to_h
|
|
55
|
-
result = AttendancePuncher.call(user: hr_current_user, kind: kind, field: field,
|
|
55
|
+
result = AttendancePuncher.call(user: hr_current_user, kind: kind, field: field, travel: params[:place] == "travel",
|
|
56
|
+
save_home: params[:save_home] == "1" || params[:place] == "home", **punch_params.to_h.symbolize_keys)
|
|
56
57
|
if result.ok?
|
|
57
58
|
time = Time.current.strftime("%H:%M")
|
|
58
59
|
redirect_to attendance_path, notice: "#{kind == :check_in ? 'Checked in' : 'Checked out'} at #{time}."
|
|
59
60
|
else
|
|
61
|
+
flash[:punch_offer] = result.offer.to_s if result.offer
|
|
60
62
|
redirect_to attendance_path, alert: result.error
|
|
61
63
|
end
|
|
62
64
|
end
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
# Employee's own WFH locations. Scoping to hr_current_user is the
|
|
3
3
|
# authorization; coordinates arrive only from the device capture.
|
|
4
|
+
# Self-registered homes count at once; at MAX_LIVE only HR changes them.
|
|
4
5
|
class WorkLocationsController < ApplicationController
|
|
6
|
+
CAP_MESSAGE = "You have #{WorkLocation::MAX_LIVE} home locations. Ask HR to change one.".freeze
|
|
7
|
+
|
|
8
|
+
before_action :refuse_at_cap, only: %i[activate remove]
|
|
9
|
+
|
|
5
10
|
def index
|
|
6
11
|
@locations = WorkLocation.live.where(user_id: hr_current_user.id).order(:created_at)
|
|
7
12
|
@location = WorkLocation.new
|
|
@@ -9,17 +14,18 @@ module HrLite
|
|
|
9
14
|
|
|
10
15
|
def create
|
|
11
16
|
@location = WorkLocation.new(params.permit(:name, :address, :lat, :lng, :accuracy_m)
|
|
12
|
-
.merge(user_id: hr_current_user.id, status: "
|
|
17
|
+
.merge(user_id: hr_current_user.id, status: "approved", decided_at: Time.current))
|
|
13
18
|
saved = WorkLocation.transaction { HrLite.user_klass.lock.find(hr_current_user.id) && @location.save }
|
|
14
19
|
unless saved
|
|
15
20
|
@locations = WorkLocation.live.where(user_id: hr_current_user.id).order(:created_at)
|
|
16
21
|
return render :index, status: :unprocessable_entity
|
|
17
22
|
end
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
@location.make_active! unless WorkLocation.active_for(hr_current_user.id)
|
|
25
|
+
AuditLog.record!(action: "work_location.self_saved", subject: @location, actor: hr_current_user, changes: { "name" => @location.name })
|
|
26
|
+
notify("work_location.requested", "#{HrLite.display_name(hr_current_user)} saved a home location: #{@location.name}",
|
|
21
27
|
"/admin/work_locations", HrLite.admin_users)
|
|
22
|
-
redirect_to work_locations_path, notice: "
|
|
28
|
+
redirect_to work_locations_path, notice: "#{@location.name} saved as a home location."
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
def activate
|
|
@@ -35,6 +41,12 @@ module HrLite
|
|
|
35
41
|
|
|
36
42
|
private
|
|
37
43
|
|
|
44
|
+
def refuse_at_cap
|
|
45
|
+
return if WorkLocation.live.where(user_id: hr_current_user.id).count < WorkLocation::MAX_LIVE
|
|
46
|
+
|
|
47
|
+
redirect_to work_locations_path, alert: CAP_MESSAGE
|
|
48
|
+
end
|
|
49
|
+
|
|
38
50
|
def notify(event, title, path, to)
|
|
39
51
|
Notifications.publish(event, title: title, path: path, bell_to: to)
|
|
40
52
|
rescue => e
|
|
@@ -7,7 +7,7 @@ module HrLite
|
|
|
7
7
|
def show
|
|
8
8
|
@period, @period_error = WorkModeDurations::Period.from_params(params, Date.current)
|
|
9
9
|
@result = WorkModeDurations.call([ hr_current_user.id ], @period)
|
|
10
|
-
@records = AttendanceRecord.where(user_id: hr_current_user.id, date: @period.from..@period.to)
|
|
10
|
+
@records = AttendanceRecord.includes(:work_location, :check_out_work_location).where(user_id: hr_current_user.id, date: @period.from..@period.to)
|
|
11
11
|
.where.not(check_in_at: nil).order(:date).limit(400)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -11,14 +11,25 @@ module HrLite
|
|
|
11
11
|
"outside_meeting" => "Client meeting" }.freeze
|
|
12
12
|
# Check-in and check-out further apart than this are called out on the board.
|
|
13
13
|
PUNCH_GAP_ALERT_M = 500
|
|
14
|
+
# Where each punch happened, set by AttendancePuncher.
|
|
15
|
+
PLACES = { "office" => "Office", "home" => "Home", "travel" => "Travel" }.freeze
|
|
14
16
|
|
|
15
17
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
16
18
|
belongs_to :regularized_by, class_name: HrLite.config.user_class, optional: true
|
|
17
19
|
belongs_to :wfh_request, optional: true
|
|
18
20
|
belongs_to :work_location, optional: true
|
|
21
|
+
belongs_to :check_out_work_location, class_name: "HrLite::WorkLocation", optional: true
|
|
19
22
|
|
|
20
23
|
def remote? = work_mode.present? && work_mode != "office"
|
|
21
24
|
def work_mode_label = WORK_MODES[work_mode]
|
|
25
|
+
def place_label(side) = PLACES[public_send("#{side}_place")]
|
|
26
|
+
|
|
27
|
+
# The day's start and end as a Google Maps route, one tap from the trail.
|
|
28
|
+
def route_url
|
|
29
|
+
return unless check_in_lat && check_in_lng && check_out_lat && check_out_lng
|
|
30
|
+
|
|
31
|
+
"https://www.google.com/maps/dir/?api=1&origin=#{check_in_lat},#{check_in_lng}&destination=#{check_out_lat},#{check_out_lng}"
|
|
32
|
+
end
|
|
22
33
|
|
|
23
34
|
validates :date, presence: true, uniqueness: { scope: :user_id }
|
|
24
35
|
validates :status, inclusion: { in: STATUSES }
|
|
@@ -23,6 +23,7 @@ module HrLite
|
|
|
23
23
|
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
24
24
|
|
|
25
25
|
def self.approved_for(user_id, date) = approved.where(user_id: user_id).overlapping(date, date).first
|
|
26
|
+
def self.pending_for(user_id, date) = pending.where(user_id: user_id).overlapping(date, date).first
|
|
26
27
|
|
|
27
28
|
# HR grants WFH outright: approved on creation, up to HR_MAX_DAYS long.
|
|
28
29
|
def self.grant!(user_id:, by:, start_date:, end_date:, reason:)
|
|
@@ -4,8 +4,8 @@ module HrLite
|
|
|
4
4
|
class WorkLocation < ApplicationRecord
|
|
5
5
|
STATUSES = %w[pending approved rejected removed].freeze
|
|
6
6
|
MAX_LIVE = 3
|
|
7
|
-
MAX_ACCURACY_M = 100 # a vaguer fix can't pin a home
|
|
8
7
|
FENCE_M = 500
|
|
8
|
+
MAX_ACCURACY_M = FENCE_M # owner 2026-10-14: a fix good to the 500 m fence may pin a home
|
|
9
9
|
|
|
10
10
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
11
11
|
belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
|
|
@@ -7,22 +7,29 @@ module HrLite
|
|
|
7
7
|
# What the browser can legitimately report (geo_punch.js).
|
|
8
8
|
GEO_STATUSES = %w[ok denied unavailable timeout insecure].freeze
|
|
9
9
|
OFF_SITE_MESSAGE = "You are not within the office location. Please request Work From Home (WFH) approval from HR.".freeze
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
HOME_ASK_MESSAGE = "You are not at any of your home locations. Is this your home location? " \
|
|
11
|
+
"Answer on the card below and check in again.".freeze
|
|
12
|
+
HOME_CAP_MESSAGE = "You already have #{WorkLocation::MAX_LIVE} home locations. Ask HR to change one, or choose Travelling.".freeze
|
|
13
|
+
HOME_FIX_MESSAGE = "Your location is not precise enough to save as home (#{WorkLocation::MAX_ACCURACY_M} m or better). " \
|
|
14
|
+
"Step outside or wait for GPS and try again.".freeze
|
|
15
|
+
TRAVEL_OUT_MESSAGE = "You checked in at home and are away from your home locations now. Choose Travelling to check out here.".freeze
|
|
13
16
|
FIELD_MODES = %w[travelling outside_meeting].freeze
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
# offer: :home or :travel — the choice the punch card should put in front of the person.
|
|
19
|
+
Result = Struct.new(:record, :error, :offer, keyword_init: true) do
|
|
16
20
|
def ok? = error.nil?
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
# field: { mode:, purpose:, place: } — a field-staff check-in away from the office.
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
# travel: a WFH punch away from every home; save_home: keep this spot as a home.
|
|
25
|
+
def self.call(user:, kind:, lat: nil, lng: nil, accuracy_m: nil, geo_status: "ok", field: {}, travel: false, save_home: false)
|
|
26
|
+
new(user, kind.to_sym, lat, lng, accuracy_m, geo_status.to_s, field.to_h.symbolize_keys, travel:, save_home:).call
|
|
22
27
|
end
|
|
23
28
|
|
|
24
|
-
def initialize(user, kind, lat, lng, accuracy_m, geo_status, field = {})
|
|
29
|
+
def initialize(user, kind, lat, lng, accuracy_m, geo_status, field = {}, travel: false, save_home: false)
|
|
25
30
|
@user = user
|
|
31
|
+
@travel = travel
|
|
32
|
+
@save_home = save_home
|
|
26
33
|
@field = field.transform_values { |v| v.to_s.strip.presence }.compact
|
|
27
34
|
@kind = kind
|
|
28
35
|
@lat = lat.presence
|
|
@@ -42,11 +49,14 @@ module HrLite
|
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
if (error = geofence_error)
|
|
45
|
-
return Result.new(error: error)
|
|
52
|
+
return Result.new(error: error, offer: @offer)
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
record = resolve_record
|
|
49
56
|
return Result.new(error: "No open check-in to close.") if record.nil?
|
|
57
|
+
if @kind == :check_out && (error = check_out_place_error(record))
|
|
58
|
+
return Result.new(error: error, offer: @offer)
|
|
59
|
+
end
|
|
50
60
|
|
|
51
61
|
record.with_lock do
|
|
52
62
|
case @kind
|
|
@@ -57,10 +67,12 @@ module HrLite
|
|
|
57
67
|
record.work_mode = @mode
|
|
58
68
|
record.work_location_id = @location&.id
|
|
59
69
|
record.wfh_request_id = @wfh&.id if @mode == "wfh"
|
|
70
|
+
record.add_flag!("WFH request pending HR approval: #{@wfh.reason}") if @wfh&.pending?
|
|
60
71
|
when :check_out
|
|
61
72
|
return Result.new(record: record, error: "Check in first.") if record.check_in_at.blank?
|
|
62
73
|
|
|
63
74
|
stamp(record, :check_out) # repeat check-out overwrites: last out wins
|
|
75
|
+
record.check_out_work_location_id = @location&.id
|
|
64
76
|
else
|
|
65
77
|
return Result.new(error: "Unknown punch #{@kind}.")
|
|
66
78
|
end
|
|
@@ -95,12 +107,13 @@ module HrLite
|
|
|
95
107
|
# the day's work_mode: inside an office radius is office (even on a WFH
|
|
96
108
|
# day); a field-staff check-in that says where and why is travelling or
|
|
97
109
|
# outside_meeting; approved WFH (dated or permanent) must be within the
|
|
98
|
-
#
|
|
110
|
+
# fence of any of their approved homes (the nearest one is picked), or
|
|
111
|
+
# saved as a new home on the spot, or Travelling. Anything else is refused.
|
|
99
112
|
def geofence_error
|
|
100
113
|
return unless @kind == :check_in && HrLite.config.office_geofence && OfficeLocation.active.exists?
|
|
101
114
|
return location_error unless @lat && @lng
|
|
102
115
|
if in_office?
|
|
103
|
-
@mode = "office"
|
|
116
|
+
@mode, @place = "office", "office"
|
|
104
117
|
return
|
|
105
118
|
end
|
|
106
119
|
|
|
@@ -108,21 +121,74 @@ module HrLite
|
|
|
108
121
|
if profile&.field_staff && FIELD_MODES.include?(@field[:mode])
|
|
109
122
|
return "Say why and where you are checking in from the field." unless @field[:purpose] && @field[:place]
|
|
110
123
|
|
|
111
|
-
@mode = @field[:mode]
|
|
124
|
+
@mode, @place = @field[:mode], "travel"
|
|
112
125
|
return
|
|
113
126
|
end
|
|
114
127
|
|
|
115
|
-
|
|
128
|
+
# A request HR has not decided yet still lets the person work; the day is flagged for HR.
|
|
129
|
+
@wfh = WfhRequest.approved_for(@user.id, Date.current) || WfhRequest.pending_for(@user.id, Date.current)
|
|
116
130
|
return OFF_SITE_MESSAGE unless @wfh || profile&.permanent_wfh
|
|
117
131
|
|
|
118
|
-
@
|
|
119
|
-
return
|
|
120
|
-
return
|
|
132
|
+
@mode, @place = "wfh", "home"
|
|
133
|
+
return if (@location = covering_home)
|
|
134
|
+
return save_home if @save_home
|
|
135
|
+
|
|
136
|
+
@offer = :home
|
|
137
|
+
return HOME_ASK_MESSAGE unless @travel
|
|
138
|
+
|
|
139
|
+
@mode, @place = "travelling", "travel"
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A day begun at home or travelling ends at a home, in the office, or
|
|
144
|
+
# travelling; leaving home for elsewhere has to be said out loud.
|
|
145
|
+
def check_out_place_error(record)
|
|
146
|
+
return unless %w[home travel].include?(record.check_in_place) && @lat && @lng
|
|
147
|
+
|
|
148
|
+
if in_office? then @place = "office"
|
|
149
|
+
elsif (@location = covering_home) then @place = "home"
|
|
150
|
+
elsif @travel || record.check_in_place == "travel" then @place = "travel"
|
|
151
|
+
else
|
|
152
|
+
@offer = :travel
|
|
153
|
+
return TRAVEL_OUT_MESSAGE
|
|
154
|
+
end
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def homes = @homes ||= WorkLocation.approved.where(user_id: @user.id).to_a
|
|
159
|
+
|
|
160
|
+
# The nearest approved home whose fence this fix is inside; it becomes the active one.
|
|
161
|
+
def covering_home
|
|
162
|
+
return unless precise_within?(WorkLocation::FENCE_M)
|
|
163
|
+
|
|
164
|
+
home = homes.select { |h| h.covers?(@lat, @lng) }.min_by { |h| Geo.distance_m(h.lat, h.lng, @lat.to_f, @lng.to_f) }
|
|
165
|
+
home&.make_active! unless home&.active
|
|
166
|
+
home
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# "Is this your home?" — yes. Saved approved and active at once while
|
|
170
|
+
# the person has fewer than MAX_LIVE homes; HR sees it in the audit log.
|
|
171
|
+
def save_home
|
|
172
|
+
return HOME_FIX_MESSAGE unless precise_within?(WorkLocation::MAX_ACCURACY_M)
|
|
173
|
+
|
|
174
|
+
count = WorkLocation.live.where(user_id: @user.id).count
|
|
175
|
+
return HOME_CAP_MESSAGE if count >= WorkLocation::MAX_LIVE
|
|
121
176
|
|
|
122
|
-
@
|
|
177
|
+
@location = WorkLocation.create!(user_id: @user.id, name: "Home #{count + 1}", lat: @lat, lng: @lng,
|
|
178
|
+
accuracy_m: @accuracy_m, status: "approved", decided_at: Time.current)
|
|
179
|
+
@location.make_active!
|
|
180
|
+
@homes = nil
|
|
181
|
+
AuditLog.record!(action: "work_location.self_saved", subject: @location, actor: @user, changes: { "name" => @location.name })
|
|
123
182
|
nil
|
|
124
183
|
end
|
|
125
184
|
|
|
185
|
+
# Metres to the nearest approved home; nil without a fix or a home.
|
|
186
|
+
def home_m
|
|
187
|
+
return unless @lat && @lng
|
|
188
|
+
|
|
189
|
+
homes.map { |h| Geo.distance_m(h.lat, h.lng, @lat.to_f, @lng.to_f) }.min&.round
|
|
190
|
+
end
|
|
191
|
+
|
|
126
192
|
# Check-in opens the day's system-set mode; check-out ends everything and
|
|
127
193
|
# opens Offline. The timeline keeps every earlier row.
|
|
128
194
|
def track_work_mode(record)
|
|
@@ -154,6 +220,8 @@ module HrLite
|
|
|
154
220
|
record.public_send("#{side}_lat=", @lat)
|
|
155
221
|
record.public_send("#{side}_lng=", @lng)
|
|
156
222
|
record.public_send("#{side}_accuracy_m=", @accuracy_m)
|
|
223
|
+
record.public_send("#{side}_place=", @place || ("office" if @lat && @lng && in_office?))
|
|
224
|
+
record.public_send("#{side}_home_m=", home_m)
|
|
157
225
|
end
|
|
158
226
|
|
|
159
227
|
def already_in_message(record)
|
|
@@ -36,15 +36,17 @@
|
|
|
36
36
|
<div class="hrl-feed__item hrl-small">
|
|
37
37
|
<div class="hrl-feed__body">
|
|
38
38
|
<strong><%= record.date.strftime("%a %-d %b") %></strong>
|
|
39
|
+
<%# Days punched before places were recorded keep the older office/WFH lines. %>
|
|
39
40
|
<% [ [ "In", record.check_in_at, record.check_in_lat, record.check_in_lng ],
|
|
40
41
|
[ "Out", record.check_out_at, record.check_out_lat, record.check_out_lng ] ].each do |label, at, lat, lng| %>
|
|
41
|
-
<% next unless at %>
|
|
42
|
+
<% next unless at && record.check_in_place.nil? %>
|
|
42
43
|
<div class="hrl-punch-line"><%= label %> <%= at.strftime("%H:%M") %> · <%= record.remote? ? record.work_mode_label : (HrLite::OfficeLocation.place(lat, lng, @offices) || "No GPS") %>
|
|
43
44
|
<% if lat && lng %>
|
|
44
45
|
· <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>
|
|
45
46
|
<% end %>
|
|
46
47
|
</div>
|
|
47
48
|
<% end %>
|
|
49
|
+
<% if record.check_in_place %><%= render "hr_lite/attendance/trail", record: record %><% end %>
|
|
48
50
|
<% if (gap = record.punch_gap_m) %>
|
|
49
51
|
<div class="<%= gap > HrLite::AttendanceRecord::PUNCH_GAP_ALERT_M ? "hrl-badge hrl-badge--warn" : "hrl-muted" %>"><%= (gap / 1000.0).round(1) %> km apart</div>
|
|
50
52
|
<% end %>
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
<input type="text" class="hrl-input" name="decision_note" id="reject-loc-<%= location.id %>" required placeholder="Reason to reject">
|
|
33
33
|
<button type="submit" class="hrl-btn hrl-btn--danger">Reject</button>
|
|
34
34
|
<% end %>
|
|
35
|
+
<% elsif location.approved? && location.user_id != hr_current_user.id %>
|
|
36
|
+
<%= button_to hr_lite.remove_admin_work_location_path(location), method: :post, class: "hrl-btn hrl-btn--danger",
|
|
37
|
+
form: { data: { turbo_confirm: "Remove #{location.name} for #{hr_display_name(location.user)}?" } } do %>
|
|
38
|
+
Remove<span class="hrl-sr-only"> <%= location.name %></span>
|
|
39
|
+
<% end %>
|
|
35
40
|
<% end %>
|
|
36
41
|
</div>
|
|
37
42
|
<% end %>
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
<% if record&.work_mode_label %>
|
|
22
22
|
<span class="hrl-badge hrl-badge--ok"><%= record.work_mode_label %></span>
|
|
23
23
|
<% end %>
|
|
24
|
+
<% if record&.check_in_place == "home" && record.work_location %>
|
|
25
|
+
<span class="hrl-badge hrl-badge--muted"><%= record.work_location.name %> · <%= HrLite::WorkLocation::FENCE_M %> m</span>
|
|
26
|
+
<% end %>
|
|
24
27
|
<% if record&.flagged? %>
|
|
25
28
|
<span class="hrl-badge hrl-badge--bad">Flagged</span>
|
|
26
29
|
<% end %>
|
|
@@ -68,6 +71,17 @@
|
|
|
68
71
|
<div class="hrl-field"><label for="field-place">Destination or client</label><input type="text" class="hrl-input" id="field-place" name="field[place]" maxlength="120"></div>
|
|
69
72
|
</details>
|
|
70
73
|
<% end %>
|
|
74
|
+
<% if flash[:punch_offer] == "home" %>
|
|
75
|
+
<fieldset class="hrl-field">
|
|
76
|
+
<legend class="hrl-small">Is this your home location?</legend>
|
|
77
|
+
<% if HrLite::WorkLocation.live.where(user_id: hr_current_user.id).count < HrLite::WorkLocation::MAX_LIVE %>
|
|
78
|
+
<label class="hrl-field--check"><input type="radio" name="place" value="home" required> Yes, save this place as a home location</label>
|
|
79
|
+
<% else %>
|
|
80
|
+
<p class="hrl-small"><%= HrLite::WorkLocationsController::CAP_MESSAGE %></p>
|
|
81
|
+
<% end %>
|
|
82
|
+
<label class="hrl-field--check"><input type="radio" name="place" value="travel" required> No, I am travelling</label>
|
|
83
|
+
</fieldset>
|
|
84
|
+
<% end %>
|
|
71
85
|
<button type="submit" class="hrl-btn hrl-btn--primary hrl-btn--big hrl-btn--block">Check in</button>
|
|
72
86
|
<p class="hrl-small hrl-muted" data-hrl-geo-status role="status" aria-live="polite"></p>
|
|
73
87
|
<% end %>
|
|
@@ -76,6 +90,9 @@
|
|
|
76
90
|
html: { data: { "hrl-geo-punch": true, "hrl-geo-required": HrLite.config.require_punch_location.presence } } do %>
|
|
77
91
|
<input type="hidden" name="lat"><input type="hidden" name="lng">
|
|
78
92
|
<input type="hidden" name="accuracy_m"><input type="hidden" name="geo_status">
|
|
93
|
+
<% if flash[:punch_offer] == "travel" %>
|
|
94
|
+
<div class="hrl-field"><label class="hrl-field--check"><input type="checkbox" name="place" value="travel" required> I am travelling — check out here</label></div>
|
|
95
|
+
<% end %>
|
|
79
96
|
<button type="submit" class="hrl-btn hrl-btn--big hrl-btn--block">
|
|
80
97
|
<%= record.check_out_at ? "Update check-out" : "Check out" %>
|
|
81
98
|
</button>
|
|
@@ -104,6 +121,7 @@
|
|
|
104
121
|
<% if local_assigns.key?(:attention) %>
|
|
105
122
|
<div class="hrl-row">
|
|
106
123
|
<a class="hrl-btn" href="<%= hr_lite.new_leave_request_path %>">Apply leave</a>
|
|
124
|
+
<% if HrLite.config.office_geofence %><a class="hrl-btn" href="<%= hr_lite.wfh_requests_path %>">Request WFH</a><% end %>
|
|
107
125
|
<a class="hrl-btn" href="<%= hr_lite.new_regularization_request_path %>">Fix a punch</a>
|
|
108
126
|
<a class="hrl-btn" href="<%= hr_lite.new_comp_off_request_path %>">Comp-off</a>
|
|
109
127
|
<% if hr_lite_route?(:salary_slips_path) %><a class="hrl-btn" href="<%= hr_lite.salary_slips_path %>">Payslip</a><% end %>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%# locals: record — one day's start and end: time, place, metres from home, GPS accuracy, route. %>
|
|
2
|
+
<% [ [ :check_in, "Check-in" ], [ :check_out, "Check-out" ] ].each do |side, label| %>
|
|
3
|
+
<% next unless (at = record.public_send("#{side}_at")) %>
|
|
4
|
+
<% home_m = record.public_send("#{side}_home_m")
|
|
5
|
+
accuracy = record.public_send("#{side}_accuracy_m")
|
|
6
|
+
home = side == :check_in ? record.work_location : record.check_out_work_location %>
|
|
7
|
+
<div class="hrl-punch-line"><%= label %>: <%= record.place_label(side) || "Unknown place" %><% if home && record.public_send("#{side}_place") == "home" %> (<%= home.name %>)<% end %> · <%= at.strftime("%H:%M") %>
|
|
8
|
+
<% if home_m %> · <%= number_with_delimiter(home_m) %> m from home<% end %>
|
|
9
|
+
<% if accuracy %> · ±<%= accuracy %> m<% end %></div>
|
|
10
|
+
<% end %>
|
|
11
|
+
<% if (route = record.route_url) %>
|
|
12
|
+
<div class="hrl-punch-line"><a class="hrl-card__link" href="<%= route %>" target="_blank" rel="noopener noreferrer">View route on Google Maps<span class="hrl-sr-only"> for <%= record.date.strftime("%-d %b") %></span></a></div>
|
|
13
|
+
<% end %>
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
if r
|
|
7
7
|
parts << "In #{r.check_in_at.strftime('%H:%M')}#{" (#{p.late_minutes} min late)" if p.late?}"
|
|
8
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
|
+
parts << [ r.place_label(:check_in) && "Check-in: #{r.place_label(:check_in)}",
|
|
10
|
+
r.place_label(:check_out) && "Check-out: #{r.place_label(:check_out)}" ].compact.join(" → ").presence
|
|
9
11
|
else
|
|
10
12
|
parts << "Not checked in yet"
|
|
11
13
|
end
|
|
12
|
-
parts.join(" · ")
|
|
14
|
+
parts.compact.join(" · ")
|
|
13
15
|
end %>
|
|
14
16
|
<section class="hrl-card">
|
|
15
17
|
<h2 class="hrl-card__title">Today's check-ins</h2>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
<section class="hrl-card">
|
|
8
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
|
|
9
|
+
<p class="hrl-small hrl-muted">Pick dates from today up to <%= HrLite::WfhRequest::MAX_DAYS_AHEAD %> days ahead. HR approves or rejects. While a request is waiting you can still check in away from the office; HR sees the reason. A rejected date cannot.</p>
|
|
10
10
|
<%= form_with model: @request, url: hr_lite.wfh_requests_path, scope: :wfh_request, local: true do |f| %>
|
|
11
11
|
<%= render "hr_lite/shared/form_errors", record: @request %>
|
|
12
12
|
<% min, max = @window.first.iso8601, @window.last.iso8601 %>
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
</div>
|
|
6
6
|
|
|
7
7
|
<section class="hrl-card">
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
<% at_cap = @locations.size >= HrLite::WorkLocation::MAX_LIVE %>
|
|
9
|
+
<p class="hrl-small hrl-muted">On a WFH day you check in within <%= HrLite::WorkLocation::FENCE_M %> m of any of your homes; the nearest one is picked for you.
|
|
10
|
+
Save up to <%= HrLite::WorkLocation::MAX_LIVE %> places; after that only HR changes them. Your location is read only when you check in or tap
|
|
10
11
|
"Register here" — never tracked in between. Teammates see "Home", never the address.</p>
|
|
12
|
+
<% if at_cap %><p class="hrl-small"><%= HrLite::WorkLocationsController::CAP_MESSAGE %></p><% end %>
|
|
11
13
|
<% if @locations.any? %>
|
|
12
14
|
<% @locations.each do |location| %>
|
|
13
15
|
<div class="hrl-feed__item hrl-row">
|
|
@@ -18,11 +20,13 @@
|
|
|
18
20
|
<% if location.address.present? %><div class="hrl-small hrl-muted"><%= location.address %></div><% end %>
|
|
19
21
|
<% if location.decision_note.present? %><div class="hrl-small hrl-muted">HR: <%= location.decision_note %></div><% end %>
|
|
20
22
|
</div>
|
|
21
|
-
<%
|
|
22
|
-
|
|
23
|
+
<% unless at_cap %>
|
|
24
|
+
<% if location.approved? && !location.active? %>
|
|
25
|
+
<%= button_to "Use this", hr_lite.activate_work_location_path(location), method: :post, class: "hrl-btn" %>
|
|
26
|
+
<% end %>
|
|
27
|
+
<%= button_to "Remove", hr_lite.remove_work_location_path(location), method: :post, class: "hrl-btn hrl-btn--danger",
|
|
28
|
+
form: { data: { turbo_confirm: "Remove #{location.name}?" } } %>
|
|
23
29
|
<% 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
30
|
</div>
|
|
27
31
|
<% end %>
|
|
28
32
|
<% else %>
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
<% records.each do |r| %>
|
|
66
66
|
<div class="hrl-feed__item hrl-small"><div class="hrl-feed__body">
|
|
67
67
|
<% if names %><strong><%= names[r.user_id] %></strong> · <% end %>
|
|
68
|
-
<%= r.date.strftime("%a %-d %b")
|
|
69
|
-
|
|
68
|
+
<%= r.date.strftime("%a %-d %b") %><% if r.work_mode_label %> · <%= r.work_mode_label %><% end %>
|
|
69
|
+
<%= render "hr_lite/attendance/trail", record: r %>
|
|
70
70
|
</div></div>
|
|
71
71
|
<% end %>
|
|
72
72
|
</section>
|
|
@@ -86,7 +86,9 @@
|
|
|
86
86
|
<a class="hrl-backlink" href="<%= hr_lite.public_send(tab[:path]) %>"><span aria-hidden="true">‹</span> <%= tab[:label] %></a>
|
|
87
87
|
<% end %>
|
|
88
88
|
<% if flash[:notice].present? %><div class="hrl-flash hrl-flash--ok" role="status"><%= flash[:notice] %></div><% end %>
|
|
89
|
-
<% if flash[:alert].present? %><div class="hrl-flash hrl-flash--warn" role="alert"><%= flash[:alert]
|
|
89
|
+
<% if flash[:alert].present? %><div class="hrl-flash hrl-flash--warn" role="alert"><%= flash[:alert] %>
|
|
90
|
+
<% if flash[:alert] == HrLite::AttendancePuncher::OFF_SITE_MESSAGE %><div class="hrl-mt"><a class="hrl-btn" href="<%= hr_lite.wfh_requests_path %>">Request WFH</a></div><% end %>
|
|
91
|
+
</div><% end %>
|
|
90
92
|
<%= yield %>
|
|
91
93
|
</main>
|
|
92
94
|
</div>
|
data/config/routes.rb
CHANGED
|
@@ -91,7 +91,7 @@ HrLite::Engine.routes.draw do
|
|
|
91
91
|
member { post :approve; post :reject }
|
|
92
92
|
end
|
|
93
93
|
resources :work_locations, only: :index do
|
|
94
|
-
member { post :approve; post :reject }
|
|
94
|
+
member { post :approve; post :reject; post :remove }
|
|
95
95
|
end
|
|
96
96
|
get "work_modes", to: "work_modes#index", as: :work_modes
|
|
97
97
|
get "work_time", to: "work_time#index", as: :work_time
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class AddPunchPlacesToHrLiteAttendanceRecords < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :hr_lite_attendance_records, :check_in_place, :string
|
|
4
|
+
add_column :hr_lite_attendance_records, :check_out_place, :string
|
|
5
|
+
add_column :hr_lite_attendance_records, :check_out_work_location_id, :bigint
|
|
6
|
+
add_column :hr_lite_attendance_records, :check_in_home_m, :integer
|
|
7
|
+
add_column :hr_lite_attendance_records, :check_out_home_m, :integer
|
|
8
|
+
add_index :hr_lite_attendance_records, :check_out_work_location_id
|
|
9
|
+
end
|
|
10
|
+
end
|
data/lib/hr_lite/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hr_lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.37.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -355,6 +355,7 @@ files:
|
|
|
355
355
|
- app/views/hr_lite/attendance/_penalties.html.erb
|
|
356
356
|
- app/views/hr_lite/attendance/_punch_card.html.erb
|
|
357
357
|
- app/views/hr_lite/attendance/_slot.html.erb
|
|
358
|
+
- app/views/hr_lite/attendance/_trail.html.erb
|
|
358
359
|
- app/views/hr_lite/attendance/_work_mode.html.erb
|
|
359
360
|
- app/views/hr_lite/attendance/show.html.erb
|
|
360
361
|
- app/views/hr_lite/benefits/index.html.erb
|
|
@@ -484,6 +485,7 @@ files:
|
|
|
484
485
|
- db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb
|
|
485
486
|
- db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb
|
|
486
487
|
- db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb
|
|
488
|
+
- db/migrate/20261014031000_add_punch_places_to_hr_lite_attendance_records.rb
|
|
487
489
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
488
490
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
489
491
|
- lib/generators/hr_lite/install/templates/initializer.rb
|