hr_lite 0.35.1 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/app/assets/stylesheets/hr_lite/hr_lite.css +3 -0
  4. data/app/controllers/hr_lite/admin/attendances_controller.rb +2 -1
  5. data/app/controllers/hr_lite/admin/work_locations_controller.rb +11 -3
  6. data/app/controllers/hr_lite/admin/work_modes_controller.rb +1 -1
  7. data/app/controllers/hr_lite/admin/work_time_controller.rb +1 -1
  8. data/app/controllers/hr_lite/attendance_controller.rb +3 -1
  9. data/app/controllers/hr_lite/work_locations_controller.rb +16 -4
  10. data/app/controllers/hr_lite/work_modes_controller.rb +1 -1
  11. data/app/controllers/hr_lite/work_time_controller.rb +1 -1
  12. data/app/models/hr_lite/attendance_record.rb +11 -0
  13. data/app/models/hr_lite/work_location.rb +1 -1
  14. data/app/services/hr_lite/attendance_puncher.rb +81 -15
  15. data/app/services/hr_lite/work_mode_switch.rb +31 -6
  16. data/app/views/hr_lite/admin/attendances/show.html.erb +3 -1
  17. data/app/views/hr_lite/admin/work_locations/index.html.erb +5 -0
  18. data/app/views/hr_lite/attendance/_duration_chips.html.erb +16 -0
  19. data/app/views/hr_lite/attendance/_punch_card.html.erb +18 -0
  20. data/app/views/hr_lite/attendance/_trail.html.erb +13 -0
  21. data/app/views/hr_lite/attendance/_work_mode.html.erb +26 -11
  22. data/app/views/hr_lite/home/_today_checkins.html.erb +3 -1
  23. data/app/views/hr_lite/work_locations/index.html.erb +10 -6
  24. data/app/views/hr_lite/work_time/_report.html.erb +2 -2
  25. data/app/views/layouts/hr_lite/application.html.erb +3 -1
  26. data/config/routes.rb +1 -1
  27. data/db/migrate/20261014031000_add_punch_places_to_hr_lite_attendance_records.rb +10 -0
  28. data/lib/hr_lite/version.rb +1 -1
  29. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b89cc7943db74414a4d788a6455c972cbc114f786a1f4f9441908dba5fe9a0a4
4
- data.tar.gz: 7a80d0e103a2dab3720ba5b3f3abbc861e1b0133d85edd20035314667b325928
3
+ metadata.gz: ab092169ccdae2360753c2535d7e3dba33f4fb661f05f8dabf96b7d4ea4bf91a
4
+ data.tar.gz: '039d406ffda5c820c670b1b9d30171e06bd70d1f29f5b4dc77f41dde2ed86fd7'
5
5
  SHA512:
6
- metadata.gz: 5b0d4fa99d60e1c3b4d15275ab7309032922c384d64aaa9af950980427ade931dc91c369f78b8870708f04b1284cedbe89c990a9705a4b28c22fd74a0ffa7ca9
7
- data.tar.gz: 17f0cacac2684fd53c56d2faffb8846531529e3e97f723cd4d9845eb78ed1bc5d687dfd1116b2aeda08dc3c30faf36cfdd1e5a42b976fd3593595817330c15b6
6
+ metadata.gz: 30340a7e5e365e3a2a84d6e00701688c68a76550bfde6a778a19fc285282531a458a9f700c7639b1cc30d16d060ec604c4134e25cfb5b65bd7cb6a9701aaa701
7
+ data.tar.gz: 97f9e43b39ffa62c388077bb00f89c54baa1ab1a286b0db8cf6b6c15313e5a1e2d5880449d82b82e7dc7babf5fd512721d34b80a2e40351b1648453b8e5bda06
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.0] - 2026-10-14
11
+
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - 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).
16
+ - 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.
17
+ - 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).
18
+ - 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).
19
+
20
+ ## [0.36.1] - 2026-10-14
21
+
22
+ - The home page punch card has a "Request WFH" button beside Apply leave (when the office geofence is on).
23
+ - 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.
24
+
25
+ ## [0.36.0] - 2026-10-13
26
+
27
+ - 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.
28
+
10
29
  ## [0.35.1] - 2026-10-13
11
30
 
12
31
  - Spacing: forms, buttons and expandable sections stacked in a card no longer touch (only form fields carried spacing, so "Set a status" sat on "Change my mode", "Save status" on "Check in", "Out of office" on "Check out"). One card-level rule gives them a 12px gap. The emoji grid, lead picker, check-in badges, punch-card links (now wrapping as whole items, no dangling dots) and the admin month's In/Out map links all keep at least the 8px gap between touch targets. Measured at 360 px and 1280 px across Home, Attendance, WFH, WFH locations, My work time, Team and the HR work-mode, WFH, locations, work-time, attendance, holidays and employee screens: no stacked targets closer than 8px, no horizontal scroll.
@@ -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).order(:date)
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 approves the WFH locations employees register. Coordinates are only
4
- # shown here, inside attendance.view reach.
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) || "pending"
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)
@@ -43,7 +43,7 @@ module HrLite
43
43
  raise ActiveRecord::RecordNotFound if employee.id == hr_current_user.id
44
44
 
45
45
  result = WorkModeSwitch.call(user: employee, mode: params[:mode], source: "hr", actor: hr_current_user,
46
- params: params.permit(:title, :client, :destination, :reason, :minutes, :until, :category, :crm_ref))
46
+ params: params.permit(:title, :client, :destination, :reason, :minutes, :until, :category, :crm_ref, :duration))
47
47
  flash_for = result.ok? ? { notice: "#{hr_display_name(employee)} is now: #{result.event.label}." } : { alert: result.error }
48
48
  redirect_back fallback_location: admin_attendance_path(employee.id), **flash_for
49
49
  end
@@ -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, **punch_params.to_h.symbolize_keys)
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: "pending"))
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
- AuditLog.record!(action: "work_location.requested", subject: @location, actor: hr_current_user, changes: { "name" => @location.name })
20
- notify("work_location.requested", "#{HrLite.display_name(hr_current_user)} registered a WFH location: #{@location.name}",
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: "Location sent to HR for approval."
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
@@ -5,7 +5,7 @@ module HrLite
5
5
 
6
6
  def create
7
7
  result = WorkModeSwitch.call(user: hr_current_user, mode: params[:mode], source: "employee", actor: hr_current_user,
8
- params: params.permit(:title, :client, :destination, :reason, :minutes, :until, :category, :crm_ref))
8
+ params: params.permit(:title, :client, :destination, :reason, :minutes, :until, :category, :crm_ref, :duration))
9
9
  if result.ok?
10
10
  redirect_to attendance_path, notice: "You are now: #{result.event.label}."
11
11
  else
@@ -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 }
@@ -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
- HOME_OFF_SITE_MESSAGE = "You are outside your registered WFH location. Please select an approved location " \
11
- "or switch to Travelling Mode if you are travelling.".freeze
12
- NO_HOME_MESSAGE = "You have WFH today but no approved WFH location. Register one under WFH locations and ask HR to approve it.".freeze
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
- Result = Struct.new(:record, :error, keyword_init: true) do
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
- def self.call(user:, kind:, lat: nil, lng: nil, accuracy_m: nil, geo_status: "ok", field: {})
21
- new(user, kind.to_sym, lat, lng, accuracy_m, geo_status.to_s, field.to_h.symbolize_keys).call
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
@@ -61,6 +71,7 @@ module HrLite
61
71
  return Result.new(record: record, error: "Check in first.") if record.check_in_at.blank?
62
72
 
63
73
  stamp(record, :check_out) # repeat check-out overwrites: last out wins
74
+ record.check_out_work_location_id = @location&.id
64
75
  else
65
76
  return Result.new(error: "Unknown punch #{@kind}.")
66
77
  end
@@ -95,12 +106,13 @@ module HrLite
95
106
  # the day's work_mode: inside an office radius is office (even on a WFH
96
107
  # day); a field-staff check-in that says where and why is travelling or
97
108
  # outside_meeting; approved WFH (dated or permanent) must be within the
98
- # active approved home location's fence. Anything else is refused.
109
+ # fence of any of their approved homes (the nearest one is picked), or
110
+ # saved as a new home on the spot, or Travelling. Anything else is refused.
99
111
  def geofence_error
100
112
  return unless @kind == :check_in && HrLite.config.office_geofence && OfficeLocation.active.exists?
101
113
  return location_error unless @lat && @lng
102
114
  if in_office?
103
- @mode = "office"
115
+ @mode, @place = "office", "office"
104
116
  return
105
117
  end
106
118
 
@@ -108,21 +120,73 @@ module HrLite
108
120
  if profile&.field_staff && FIELD_MODES.include?(@field[:mode])
109
121
  return "Say why and where you are checking in from the field." unless @field[:purpose] && @field[:place]
110
122
 
111
- @mode = @field[:mode]
123
+ @mode, @place = @field[:mode], "travel"
112
124
  return
113
125
  end
114
126
 
115
127
  @wfh = WfhRequest.approved_for(@user.id, Date.current)
116
128
  return OFF_SITE_MESSAGE unless @wfh || profile&.permanent_wfh
117
129
 
118
- @location = WorkLocation.active_for(@user.id)
119
- return NO_HOME_MESSAGE unless @location
120
- return HOME_OFF_SITE_MESSAGE unless precise_within?(WorkLocation::FENCE_M) && @location.covers?(@lat, @lng)
130
+ @mode, @place = "wfh", "home"
131
+ return if (@location = covering_home)
132
+ return save_home if @save_home
133
+
134
+ @offer = :home
135
+ return HOME_ASK_MESSAGE unless @travel
136
+
137
+ @mode, @place = "travelling", "travel"
138
+ nil
139
+ end
140
+
141
+ # A day begun at home or travelling ends at a home, in the office, or
142
+ # travelling; leaving home for elsewhere has to be said out loud.
143
+ def check_out_place_error(record)
144
+ return unless %w[home travel].include?(record.check_in_place) && @lat && @lng
145
+
146
+ if in_office? then @place = "office"
147
+ elsif (@location = covering_home) then @place = "home"
148
+ elsif @travel || record.check_in_place == "travel" then @place = "travel"
149
+ else
150
+ @offer = :travel
151
+ return TRAVEL_OUT_MESSAGE
152
+ end
153
+ nil
154
+ end
155
+
156
+ def homes = @homes ||= WorkLocation.approved.where(user_id: @user.id).to_a
157
+
158
+ # The nearest approved home whose fence this fix is inside; it becomes the active one.
159
+ def covering_home
160
+ return unless precise_within?(WorkLocation::FENCE_M)
161
+
162
+ home = homes.select { |h| h.covers?(@lat, @lng) }.min_by { |h| Geo.distance_m(h.lat, h.lng, @lat.to_f, @lng.to_f) }
163
+ home&.make_active! unless home&.active
164
+ home
165
+ end
166
+
167
+ # "Is this your home?" — yes. Saved approved and active at once while
168
+ # the person has fewer than MAX_LIVE homes; HR sees it in the audit log.
169
+ def save_home
170
+ return HOME_FIX_MESSAGE unless precise_within?(WorkLocation::MAX_ACCURACY_M)
171
+
172
+ count = WorkLocation.live.where(user_id: @user.id).count
173
+ return HOME_CAP_MESSAGE if count >= WorkLocation::MAX_LIVE
121
174
 
122
- @mode = "wfh"
175
+ @location = WorkLocation.create!(user_id: @user.id, name: "Home #{count + 1}", lat: @lat, lng: @lng,
176
+ accuracy_m: @accuracy_m, status: "approved", decided_at: Time.current)
177
+ @location.make_active!
178
+ @homes = nil
179
+ AuditLog.record!(action: "work_location.self_saved", subject: @location, actor: @user, changes: { "name" => @location.name })
123
180
  nil
124
181
  end
125
182
 
183
+ # Metres to the nearest approved home; nil without a fix or a home.
184
+ def home_m
185
+ return unless @lat && @lng
186
+
187
+ homes.map { |h| Geo.distance_m(h.lat, h.lng, @lat.to_f, @lng.to_f) }.min&.round
188
+ end
189
+
126
190
  # Check-in opens the day's system-set mode; check-out ends everything and
127
191
  # opens Offline. The timeline keeps every earlier row.
128
192
  def track_work_mode(record)
@@ -154,6 +218,8 @@ module HrLite
154
218
  record.public_send("#{side}_lat=", @lat)
155
219
  record.public_send("#{side}_lng=", @lng)
156
220
  record.public_send("#{side}_accuracy_m=", @accuracy_m)
221
+ record.public_send("#{side}_place=", @place || ("office" if @lat && @lng && in_office?))
222
+ record.public_send("#{side}_home_m=", home_m)
157
223
  end
158
224
 
159
225
  def already_in_message(record)
@@ -13,6 +13,17 @@ module HrLite
13
13
 
14
14
  def self.call(...) = new(...).call
15
15
 
16
+ # How long a mode lasts, picked per use (owner, 2026-10-13): 15 min up to a day.
17
+ DURATIONS = { "15" => 15.minutes, "30" => 30.minutes, "60" => 1.hour, "120" => 2.hours,
18
+ "240" => 4.hours, "day" => 1.day }.freeze
19
+ DURATION_LABELS = { "15" => "15 min", "30" => "30 min", "60" => "1 h", "120" => "2 h", "240" => "4 h", "day" => "1 day" }.freeze
20
+
21
+ # The lengths offered for a mode: breaks never past their company limit.
22
+ def self.duration_choices(mode)
23
+ cap = { "lunch" => Setting.instance.lunch_break_minutes, "short_break" => Setting.instance.short_break_minutes }[mode.to_s]
24
+ cap ? DURATIONS.keys.select { |k| DURATIONS[k] <= cap.minutes } : DURATIONS.keys
25
+ end
26
+
16
27
  CLEAR_AFTER = { "30" => 30.minutes, "60" => 1.hour, "240" => 4.hours, "today" => :today, "never" => nil }.freeze
17
28
 
18
29
  # A person's own status, Slack-style: works with or without a check-in
@@ -120,10 +131,18 @@ module HrLite
120
131
  day = @now.in_time_zone(HrLite.config.time_zone).all_day
121
132
  return "You have already taken lunch today." if WorkModeEvent.where(user_id: @user.id, mode: "lunch", started_at: day).exists?
122
133
 
123
- base.merge(ends_at: @now + Setting.instance.lunch_break_minutes.minutes)
134
+ capped_break(Setting.instance.lunch_break_minutes)
124
135
  end
125
136
 
126
- def short_break_attrs = base.merge(ends_at: @now + Setting.instance.short_break_minutes.minutes)
137
+ def short_break_attrs = capped_break(Setting.instance.short_break_minutes)
138
+
139
+ # A break runs the picked length (never past the limit), or the full limit.
140
+ def capped_break(limit)
141
+ return base.merge(ends_at: @now + limit.minutes) unless @p[:duration]
142
+ return "That break length is over the #{limit}-minute limit." unless self.class.duration_choices(@mode).include?(@p[:duration])
143
+
144
+ base.merge(ends_at: @now + DURATIONS[@p[:duration]])
145
+ end
127
146
 
128
147
  def parse_until
129
148
  Time.zone.parse("#{@now.to_date} #{@p[:until]}") if @p[:until]
@@ -131,12 +150,18 @@ module HrLite
131
150
  nil
132
151
  end
133
152
 
134
- # minutes (30/60/…) or until "HH:MM" today; must be in the future, today.
153
+ # A picked length (15 min … 1 day), or minutes / an "HH:MM" end time;
154
+ # always in the future and at most a day away.
135
155
  def timed(attrs, required: true)
136
- ends_at = @p[:minutes] ? @now + @p[:minutes].to_i.minutes : parse_until
156
+ return "Pick how long from the choices." if @p[:duration] && !DURATIONS.key?(@p[:duration])
157
+
158
+ ends_at = if @p[:duration] then @now + DURATIONS[@p[:duration]]
159
+ elsif @p[:minutes] then @now + @p[:minutes].to_i.minutes
160
+ else parse_until
161
+ end
137
162
  return attrs unless ends_at || required
138
- return "Pick when it ends." unless ends_at
139
- return "The end time must be later today." unless ends_at > @now && ends_at <= @now.end_of_day
163
+ return "Pick how long." unless ends_at
164
+ return "The end time must be later than now, and at most a day away." unless ends_at > @now && ends_at <= @now + 1.day
140
165
 
141
166
  attrs.merge(ends_at: ends_at)
142
167
  end
@@ -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 %>
@@ -0,0 +1,16 @@
1
+ <%# How long a mode lasts: real radios styled as chips (selected state exposed).
2
+ locals: mode, id, optional (adds "Until I change it", the default) %>
3
+ <% choices = HrLite::WorkModeSwitch.duration_choices(mode)
4
+ optional = local_assigns[:optional]
5
+ default = optional ? "" : (choices.include?("30") ? "30" : choices.last) %>
6
+ <fieldset class="hrl-field">
7
+ <legend class="hrl-small"><strong>How long</strong></legend>
8
+ <div class="hrl-chiprow" role="radiogroup" aria-label="How long">
9
+ <% if optional %>
10
+ <label><input type="radio" name="duration" value="" <%= "checked" if default == "" %>><span>Until I change it</span></label>
11
+ <% end %>
12
+ <% choices.each do |key| %>
13
+ <label><input type="radio" name="duration" id="<%= id %>-<%= key %>" value="<%= key %>" <%= "checked" if key == default %>><span><%= HrLite::WorkModeSwitch::DURATION_LABELS[key] %></span></label>
14
+ <% end %>
15
+ </div>
16
+ </fieldset>
@@ -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 %>
@@ -22,22 +22,37 @@
22
22
  <details>
23
23
  <summary class="hrl-btn hrl-btn--block">Change my mode</summary>
24
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 %>
25
+ <%= button_to hr_lite.work_mode_path, params: { mode: "office" }, method: :post, class: "hrl-btn hrl-btn--block" do %>
26
+ <%= hrl_mode_icon(HrLite::WorkStatus::Entry.new(mode: "office", label: HrLite::WorkModeEvent.label("office"))) %>
27
+ <%= HrLite::WorkModeEvent.label("office") %>
30
28
  <% end %>
31
29
  <% if HrLite::WfhRequest.approved_for(hr_current_user.id, Date.current) || HrLite::EmployeeProfile.where(user_id: hr_current_user.id).pick(:permanent_wfh) %>
32
30
  <%= button_to "Work from home", hr_lite.work_mode_path, params: { mode: "wfh" }, method: :post, class: "hrl-btn hrl-btn--block" %>
33
31
  <% end %>
32
+ <%# Breaks: one tap for the full limit, or pick a shorter length when the limit allows a choice. %>
33
+ <% %w[lunch short_break].each do |mode| %>
34
+ <% if HrLite::WorkModeSwitch.duration_choices(mode).size > 1 %>
35
+ <details>
36
+ <summary class="hrl-btn hrl-btn--block"><%= HrLite::WorkModeEvent.label(mode) %></summary>
37
+ <%= form_with url: hr_lite.work_mode_path, method: :post, local: true do %>
38
+ <input type="hidden" name="mode" value="<%= mode %>">
39
+ <%= render "hr_lite/attendance/duration_chips", mode: mode, id: "dur-#{mode}" %>
40
+ <button type="submit" class="hrl-btn hrl-btn--primary">Start <%= HrLite::WorkModeEvent.label(mode).downcase %></button>
41
+ <% end %>
42
+ </details>
43
+ <% else %>
44
+ <%= button_to hr_lite.work_mode_path, params: { mode: mode }, method: :post, class: "hrl-btn hrl-btn--block" do %>
45
+ <%= hrl_mode_icon(HrLite::WorkStatus::Entry.new(mode: mode, label: HrLite::WorkModeEvent.label(mode))) %>
46
+ <%= HrLite::WorkModeEvent.label(mode) %>
47
+ <% end %>
48
+ <% end %>
49
+ <% end %>
34
50
 
35
51
  <details>
36
52
  <summary class="hrl-btn hrl-btn--block">Busy / focus</summary>
37
53
  <%= form_with url: hr_lite.work_mode_path, method: :post, local: true do %>
38
54
  <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>
55
+ <%= render "hr_lite/attendance/duration_chips", mode: "busy", id: "dur-busy" %>
41
56
  <button type="submit" class="hrl-btn hrl-btn--primary">Set busy</button>
42
57
  <% end %>
43
58
  </details>
@@ -48,7 +63,7 @@
48
63
  <div class="hrl-field"><label for="mt-title">Meeting</label><input class="hrl-input" id="mt-title" name="title" maxlength="120" required></div>
49
64
  <div class="hrl-field"><label for="mt-client">Client (optional)</label><input class="hrl-input" id="mt-client" name="client" maxlength="120"></div>
50
65
  <%= 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>
66
+ <%= render "hr_lite/attendance/duration_chips", mode: "meeting", id: "dur-meeting" %>
52
67
  <button type="submit" class="hrl-btn hrl-btn--primary">Start meeting</button>
53
68
  <% end %>
54
69
  </details>
@@ -60,7 +75,7 @@
60
75
  <div class="hrl-field"><label for="tr-why">Purpose</label><input class="hrl-input" id="tr-why" name="title" maxlength="120" required></div>
61
76
  <div class="hrl-field"><label for="tr-client">Client (optional)</label><input class="hrl-input" id="tr-client" name="client" maxlength="120"></div>
62
77
  <%= 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>
78
+ <%= render "hr_lite/attendance/duration_chips", mode: "travelling", id: "dur-travelling", optional: true %>
64
79
  <button type="submit" class="hrl-btn hrl-btn--primary">Start travelling</button>
65
80
  <% end %>
66
81
  </details>
@@ -72,7 +87,7 @@
72
87
  <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
88
  <div class="hrl-field"><label for="om-why">Purpose</label><input class="hrl-input" id="om-why" name="title" maxlength="120" required></div>
74
89
  <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>
90
+ <%= render "hr_lite/attendance/duration_chips", mode: "outside_meeting", id: "dur-om", optional: true %>
76
91
  <button type="submit" class="hrl-btn hrl-btn--primary">Start client meeting</button>
77
92
  <% end %>
78
93
  </details>
@@ -82,7 +97,7 @@
82
97
  <input type="hidden" name="mode" value="out_of_office">
83
98
  <div class="hrl-field"><label for="oo-why">Reason</label>
84
99
  <%= 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>
100
+ <%= render "hr_lite/attendance/duration_chips", mode: "out_of_office", id: "dur-oo" %>
86
101
  <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
102
  <button type="submit" class="hrl-btn hrl-btn--primary">Set out of office</button>
88
103
  <% 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>
@@ -5,9 +5,11 @@
5
5
  </div>
6
6
 
7
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
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
- <% if location.approved? && !location.active? %>
22
- <%= button_to "Use this", hr_lite.activate_work_location_path(location), method: :post, class: "hrl-btn" %>
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") %> · 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 %>
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] %></div><% end %>
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
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.35.1"
2
+ VERSION = "0.37.0"
3
3
  end
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.35.1
4
+ version: 0.37.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -350,10 +350,12 @@ files:
350
350
  - app/views/hr_lite/attendance/_calendar_connection.html.erb
351
351
  - app/views/hr_lite/attendance/_crm_picker.html.erb
352
352
  - app/views/hr_lite/attendance/_custom_status.html.erb
353
+ - app/views/hr_lite/attendance/_duration_chips.html.erb
353
354
  - app/views/hr_lite/attendance/_month_grid.html.erb
354
355
  - app/views/hr_lite/attendance/_penalties.html.erb
355
356
  - app/views/hr_lite/attendance/_punch_card.html.erb
356
357
  - app/views/hr_lite/attendance/_slot.html.erb
358
+ - app/views/hr_lite/attendance/_trail.html.erb
357
359
  - app/views/hr_lite/attendance/_work_mode.html.erb
358
360
  - app/views/hr_lite/attendance/show.html.erb
359
361
  - app/views/hr_lite/benefits/index.html.erb
@@ -483,6 +485,7 @@ files:
483
485
  - db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb
484
486
  - db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb
485
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
486
489
  - lib/generators/hr_lite/install/install_generator.rb
487
490
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
488
491
  - lib/generators/hr_lite/install/templates/initializer.rb