hr_lite 0.30.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/app/assets/javascripts/hr_lite/chart_tip.js +33 -0
  4. data/app/assets/javascripts/hr_lite/countdown.js +12 -0
  5. data/app/assets/javascripts/hr_lite/crm_picker.js +27 -0
  6. data/app/assets/javascripts/hr_lite/emoji_pick.js +11 -0
  7. data/app/assets/stylesheets/hr_lite/hr_lite.css +47 -0
  8. data/app/controllers/hr_lite/admin/attendances_controller.rb +8 -5
  9. data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
  10. data/app/controllers/hr_lite/admin/holidays_controller.rb +17 -9
  11. data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
  12. data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +103 -0
  13. data/app/controllers/hr_lite/admin/work_locations_controller.rb +41 -0
  14. data/app/controllers/hr_lite/admin/work_modes_controller.rb +72 -0
  15. data/app/controllers/hr_lite/admin/work_time_controller.rb +39 -0
  16. data/app/controllers/hr_lite/attendance_controller.rb +2 -1
  17. data/app/controllers/hr_lite/calendar_controller.rb +3 -2
  18. data/app/controllers/hr_lite/comp_off_requests_controller.rb +1 -1
  19. data/app/controllers/hr_lite/holidays_controller.rb +3 -2
  20. data/app/controllers/hr_lite/team_controller.rb +13 -0
  21. data/app/controllers/hr_lite/wfh_requests_controller.rb +45 -0
  22. data/app/controllers/hr_lite/work_locations_controller.rb +44 -0
  23. data/app/controllers/hr_lite/work_modes_controller.rb +40 -0
  24. data/app/controllers/hr_lite/work_time_controller.rb +14 -0
  25. data/app/helpers/hr_lite/application_helper.rb +43 -2
  26. data/app/jobs/hr_lite/attendance_close_job.rb +12 -5
  27. data/app/jobs/hr_lite/calendar_meeting_start_job.rb +31 -0
  28. data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +55 -0
  29. data/app/jobs/hr_lite/work_mode_expire_job.rb +12 -0
  30. data/app/models/hr_lite/attendance_penalty.rb +2 -2
  31. data/app/models/hr_lite/attendance_record.rb +8 -0
  32. data/app/models/hr_lite/comp_off_request.rb +2 -2
  33. data/app/models/hr_lite/employee_profile.rb +2 -0
  34. data/app/models/hr_lite/holiday.rb +11 -5
  35. data/app/models/hr_lite/leave_balance.rb +1 -1
  36. data/app/models/hr_lite/leave_request.rb +1 -1
  37. data/app/models/hr_lite/regularization_request.rb +1 -1
  38. data/app/models/hr_lite/setting.rb +2 -0
  39. data/app/models/hr_lite/wfh_request.rb +108 -0
  40. data/app/models/hr_lite/work_location.rb +62 -0
  41. data/app/models/hr_lite/work_mode_event.rb +84 -0
  42. data/app/services/hr_lite/attendance_puncher.rb +79 -4
  43. data/app/services/hr_lite/day_status.rb +1 -1
  44. data/app/services/hr_lite/leave_day_counter.rb +5 -7
  45. data/app/services/hr_lite/team_day.rb +6 -6
  46. data/app/services/hr_lite/today_checkins.rb +41 -0
  47. data/app/services/hr_lite/work_mode_durations.rb +237 -0
  48. data/app/services/hr_lite/work_mode_switch.rb +144 -0
  49. data/app/services/hr_lite/work_status.rb +60 -0
  50. data/app/services/hr_lite/working_calendar.rb +11 -2
  51. data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
  52. data/app/views/hr_lite/admin/employees/_form.html.erb +9 -0
  53. data/app/views/hr_lite/admin/holidays/index.html.erb +10 -1
  54. data/app/views/hr_lite/admin/settings/edit.html.erb +9 -0
  55. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +7 -1
  56. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +91 -0
  57. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +40 -0
  58. data/app/views/hr_lite/admin/work_locations/index.html.erb +42 -0
  59. data/app/views/hr_lite/admin/work_modes/index.html.erb +105 -0
  60. data/app/views/hr_lite/admin/work_time/index.html.erb +42 -0
  61. data/app/views/hr_lite/attendance/_calendar_connection.html.erb +15 -0
  62. data/app/views/hr_lite/attendance/_crm_picker.html.erb +9 -0
  63. data/app/views/hr_lite/attendance/_custom_status.html.erb +26 -0
  64. data/app/views/hr_lite/attendance/_punch_card.html.erb +23 -0
  65. data/app/views/hr_lite/attendance/_work_mode.html.erb +92 -0
  66. data/app/views/hr_lite/attendance/show.html.erb +4 -1
  67. data/app/views/hr_lite/holidays/_next_holiday.html.erb +1 -1
  68. data/app/views/hr_lite/home/_today_checkins.html.erb +31 -0
  69. data/app/views/hr_lite/home/index.html.erb +5 -0
  70. data/app/views/hr_lite/team/show.html.erb +25 -4
  71. data/app/views/hr_lite/wfh_requests/index.html.erb +70 -0
  72. data/app/views/hr_lite/work_locations/index.html.erb +52 -0
  73. data/app/views/hr_lite/work_time/_bars.html.erb +44 -0
  74. data/app/views/hr_lite/work_time/_donut.html.erb +36 -0
  75. data/app/views/hr_lite/work_time/_report.html.erb +74 -0
  76. data/app/views/hr_lite/work_time/_timeline.html.erb +26 -0
  77. data/app/views/hr_lite/work_time/show.html.erb +10 -0
  78. data/config/routes.rb +22 -0
  79. data/db/migrate/20261013100000_create_hr_lite_wfh_requests.rb +22 -0
  80. data/db/migrate/20261013100100_add_wfh_request_id_to_hr_lite_attendance_records.rb +7 -0
  81. data/db/migrate/20261013100200_add_permanent_wfh_to_hr_lite_employee_profiles.rb +5 -0
  82. data/db/migrate/20261013110000_create_hr_lite_work_locations.rb +23 -0
  83. data/db/migrate/20261013110100_add_work_mode_to_hr_lite_attendance_records.rb +7 -0
  84. data/db/migrate/20261013110200_add_field_staff_to_hr_lite_employee_profiles.rb +5 -0
  85. data/db/migrate/20261013110300_create_hr_lite_work_mode_events.rb +30 -0
  86. data/db/migrate/20261013110400_add_break_minutes_to_hr_lite_settings.rb +6 -0
  87. data/db/migrate/20261013120000_grant_hr_lite_work_analytics.rb +14 -0
  88. data/db/migrate/20261013130000_add_office_location_to_hr_lite_holidays.rb +12 -0
  89. data/db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb +6 -0
  90. data/db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb +9 -0
  91. data/db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb +9 -0
  92. data/lib/hr_lite/configuration.rb +16 -1
  93. data/lib/hr_lite/notifications.rb +13 -4
  94. data/lib/hr_lite/permissions.rb +1 -0
  95. data/lib/hr_lite/role_seeds.rb +4 -2
  96. data/lib/hr_lite/seeds.rb +1 -1
  97. data/lib/hr_lite/version.rb +1 -1
  98. metadata +53 -1
@@ -6,6 +6,19 @@ module HrLite
6
6
  def show
7
7
  @date = parse_date_param(params[:date])
8
8
  @board = TeamDay.new(date: @date)
9
+ return unless HrLite.config.office_geofence && @date == Date.current
10
+
11
+ # Live work modes: labels only (Office / Home / …), never where home is.
12
+ @statuses = WorkStatus.for_users(@board.rows.map { |r| r.user.id })
13
+ @departments = @board.rows.filter_map { |r| r.profile&.department.presence }.uniq.sort
14
+ @mode = params[:mode].presence_in(WorkModeEvent::MODES.keys + WorkStatus::DERIVED.keys)
15
+ @availability = params[:availability].presence_in(%w[available unavailable])
16
+ @rows = @board.rows.select do |r|
17
+ st = @statuses[r.user.id]
18
+ (params[:department].blank? || r.profile&.department == params[:department]) &&
19
+ (@mode.nil? || [ st.primary.mode, st.overlay&.mode ].include?(@mode)) &&
20
+ (@availability.nil? || st.available? == (@availability == "available"))
21
+ end
9
22
  end
10
23
  end
11
24
  end
@@ -0,0 +1,45 @@
1
+ module HrLite
2
+ # Employee-side WFH requests. Scoping to hr_current_user IS the
3
+ # authorization; there is no edit, so a decided request cannot change.
4
+ class WfhRequestsController < ApplicationController
5
+ def index
6
+ @requests = paginate(WfhRequest.where(user_id: hr_current_user.id).recent_first)
7
+ @request = WfhRequest.new(start_date: Date.current, end_date: Date.current)
8
+ load_calendar
9
+ end
10
+
11
+ def create
12
+ @request = WfhRequest.new(params.require(:wfh_request).permit(:start_date, :end_date, :reason)
13
+ .merge(user_id: hr_current_user.id, status: "pending"))
14
+ # Row lock on the user: two tabs can't both pass the overlap check.
15
+ saved = WfhRequest.transaction { HrLite.user_klass.lock.find(hr_current_user.id) && @request.save }
16
+ if saved
17
+ @request.notify_requested
18
+ redirect_to wfh_requests_path, notice: "WFH request sent to HR."
19
+ else
20
+ @requests = paginate(WfhRequest.where(user_id: hr_current_user.id).recent_first)
21
+ load_calendar
22
+ render :index, status: :unprocessable_entity
23
+ end
24
+ end
25
+
26
+ def cancel
27
+ WfhRequest.where(user_id: hr_current_user.id).find(params[:id]).cancel!(actor: hr_current_user)
28
+ redirect_to wfh_requests_path, notice: "WFH request cancelled."
29
+ rescue ActiveRecord::RecordInvalid
30
+ redirect_to wfh_requests_path, alert: "Only pending requests can be cancelled."
31
+ end
32
+
33
+ private
34
+
35
+ # Status per date for the next 30 days; a later request wins a shared date.
36
+ def load_calendar
37
+ @window = Date.current..(Date.current + WfhRequest::MAX_DAYS_AHEAD)
38
+ @day_status = {}
39
+ WfhRequest.where(user_id: hr_current_user.id, status: %w[pending approved rejected])
40
+ .overlapping(@window.first, @window.last).order(:id).each do |request|
41
+ (request.start_date..request.end_date).each { |d| @day_status[d] = request.status if @window.cover?(d) }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,44 @@
1
+ module HrLite
2
+ # Employee's own WFH locations. Scoping to hr_current_user is the
3
+ # authorization; coordinates arrive only from the device capture.
4
+ class WorkLocationsController < ApplicationController
5
+ def index
6
+ @locations = WorkLocation.live.where(user_id: hr_current_user.id).order(:created_at)
7
+ @location = WorkLocation.new
8
+ end
9
+
10
+ def create
11
+ @location = WorkLocation.new(params.permit(:name, :address, :lat, :lng, :accuracy_m)
12
+ .merge(user_id: hr_current_user.id, status: "pending"))
13
+ saved = WorkLocation.transaction { HrLite.user_klass.lock.find(hr_current_user.id) && @location.save }
14
+ unless saved
15
+ @locations = WorkLocation.live.where(user_id: hr_current_user.id).order(:created_at)
16
+ return render :index, status: :unprocessable_entity
17
+ end
18
+
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}",
21
+ "/admin/work_locations", HrLite.admin_users)
22
+ redirect_to work_locations_path, notice: "Location sent to HR for approval."
23
+ end
24
+
25
+ def activate
26
+ location = WorkLocation.approved.where(user_id: hr_current_user.id).find(params[:id])
27
+ location.make_active!
28
+ redirect_to work_locations_path, notice: "#{location.name} is now your WFH location."
29
+ end
30
+
31
+ def remove
32
+ WorkLocation.live.where(user_id: hr_current_user.id).find(params[:id]).remove!(actor: hr_current_user)
33
+ redirect_to work_locations_path, notice: "Location removed."
34
+ end
35
+
36
+ private
37
+
38
+ def notify(event, title, path, to)
39
+ Notifications.publish(event, title: title, path: path, bell_to: to)
40
+ rescue => e
41
+ Rails.logger.error("[hr_lite] work location notification failed: #{e.class}: #{e.message}")
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ module HrLite
2
+ # Employee switches their own mode. hr_current_user is the only subject.
3
+ class WorkModesController < ApplicationController
4
+ before_action { raise ActiveRecord::RecordNotFound unless HrLite.config.office_geofence }
5
+
6
+ def create
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))
9
+ if result.ok?
10
+ redirect_to attendance_path, notice: "You are now: #{result.event.label}."
11
+ else
12
+ redirect_to attendance_path, alert: result.error
13
+ end
14
+ end
15
+
16
+ def status
17
+ result = WorkModeSwitch.set_status(user: hr_current_user, text: params[:text], emoji: params[:emoji], clear_after: params[:clear_after])
18
+ result.ok? ? redirect_to(attendance_path, notice: "Status set.") : redirect_to(attendance_path, alert: result.error)
19
+ end
20
+
21
+ def clear_status
22
+ WorkModeEvent.open.where(user_id: hr_current_user.id, layer: "status").find_each { |e| e.close!("ended") }
23
+ redirect_to attendance_path, notice: "Status cleared."
24
+ end
25
+
26
+ # Lead type-ahead for the meeting forms; the host decides who sees what.
27
+ def crm_search
28
+ query = params[:q].to_s.strip
29
+ hook = HrLite.config.crm_search
30
+ leads = hook && query.length >= 2 ? Array(hook.call(hr_current_user, query)).first(20) : []
31
+ render json: leads.map { |l| { id: l[:id].to_s, label: l[:label].to_s } }
32
+ end
33
+
34
+ # Ends the current break / busy / meeting early.
35
+ def end_overlay
36
+ WorkModeEvent.open.overlay.where(user_id: hr_current_user.id).find_each { |e| e.close!("ended") }
37
+ redirect_to attendance_path, notice: "Back to work."
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,14 @@
1
+ module HrLite
2
+ # "My work time": the signed-in person's own analytics. There is no user
3
+ # param — nobody can read someone else's here.
4
+ class WorkTimeController < ApplicationController
5
+ before_action { raise ActiveRecord::RecordNotFound unless HrLite.config.office_geofence }
6
+
7
+ def show
8
+ @period, @period_error = WorkModeDurations::Period.from_params(params, Date.current)
9
+ @result = WorkModeDurations.call([ hr_current_user.id ], @period)
10
+ @records = AttendanceRecord.where(user_id: hr_current_user.id, date: @period.from..@period.to)
11
+ .where.not(check_in_at: nil).order(:date).limit(400)
12
+ end
13
+ end
14
+ end
@@ -2,7 +2,7 @@ module HrLite
2
2
  module ApplicationHelper
3
3
  NAV_ITEMS = [
4
4
  { label: "Home", path: :root_path, match: [ "/" ], group: "My work" },
5
- { label: "Attendance", path: :attendance_path, match: [ "/attendance", "/regularization_requests" ], group: "My work" },
5
+ { label: "Attendance", path: :attendance_path, match: [ "/attendance", "/regularization_requests", "/wfh_requests", "/work_locations" ], group: "My work" },
6
6
  { label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances", "/comp_off_requests" ], group: "My work" },
7
7
  { label: "Team", path: :team_path, match: [ "/team" ], group: "My work" },
8
8
  # The phone tab bar shows the first four; the rest fill the More sheet,
@@ -33,7 +33,11 @@ module HrLite
33
33
  ADMIN_NAV_ITEMS = [
34
34
  { label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
35
35
  { label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
36
- { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
36
+ { label: "Work modes", path: :admin_work_modes_path, match: [ "/admin/work_modes" ],
37
+ if: -> { HrLite.config.office_geofence } },
38
+ { label: "Work time", path: :admin_work_time_path, match: [ "/admin/work_time" ],
39
+ if: -> { HrLite.config.office_geofence && hr_can?("work_analytics.view", scope: :team) } },
40
+ { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests", "/admin/wfh_requests", "/admin/work_locations" ] },
37
41
  { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] },
38
42
  { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ],
39
43
  if: -> { hr_can?("expense.approve", scope: :team) } },
@@ -201,6 +205,43 @@ module HrLite
201
205
  content_tag(:span, status.humanize, class: "hrl-badge #{css}")
202
206
  end
203
207
 
208
+ # Lucide icons (ISC licence), one per work mode / derived status.
209
+ MODE_ICONS = {
210
+ "office" => '<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4M10 10h4M10 14h4M10 18h4"/>',
211
+ "wfh" => '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
212
+ "travelling" => '<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/>',
213
+ "outside_meeting" => '<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>',
214
+ "meeting" => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>',
215
+ "lunch" => '<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2M7 2v20"/><path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/>',
216
+ "short_break" => '<path d="M10 2v2M14 2v2M6 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>',
217
+ "busy" => '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>',
218
+ "out_of_office" => '<path d="M13 4h3a2 2 0 0 1 2 2v14M2 20h3M13 20h9M10 12v.01"/><path d="M13 4.562v16.157a1 1 0 0 1-1.242.97L5 20V5.562a2 2 0 0 1 1.515-1.94l4-1A2 2 0 0 1 13 4.561Z"/>',
219
+ "sick_leave" => '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/><path d="M3.22 12H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27"/>',
220
+ "holiday" => '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01M16 18h.01"/>',
221
+ "on_leave" => '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><path d="m9 16 2 2 4-4"/>',
222
+ "weekend" => '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
223
+ "offline" => '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
224
+ "custom" => '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>'
225
+ }.freeze
226
+ MODE_SOURCES = { "system" => "set automatically", "employee" => "set by the employee", "hr" => "set by HR" }.freeze
227
+
228
+ # One small icon for a WorkStatus::Entry; a dot marks a system-set mode.
229
+ # The label, source and end time live in title + screen-reader text.
230
+ def hrl_mode_icon(entry)
231
+ detail = [ entry.label, MODE_SOURCES[entry.source], ("until #{entry.until.strftime('%H:%M')}" if entry.until.respond_to?(:hour)) ].compact.join(" · ")
232
+ svg = content_tag(:svg, MODE_ICONS.fetch(entry.mode, MODE_ICONS["offline"]).html_safe,
233
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", width: 14, height: 14, fill: "none",
234
+ stroke: "currentColor", "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": true)
235
+ content_tag(:span, svg + content_tag(:span, detail, class: "hrl-sr-only"),
236
+ class: class_names("hrl-mode", "hrl-mode--#{entry.mode}", "hrl-mode--auto": entry.source == "system"),
237
+ title: detail)
238
+ end
239
+
240
+ # Primary + compatible overlay, e.g. office + busy.
241
+ def hrl_status_icons(status)
242
+ safe_join([ hrl_mode_icon(status.primary), (hrl_mode_icon(status.overlay) if status.overlay) ].compact)
243
+ end
244
+
204
245
  private
205
246
 
206
247
  # Always ONE root node — these land inside stacked-table flex cells,
@@ -4,16 +4,16 @@ module HrLite
4
4
  # A punch left open is checked out at the day's end and paid as a half day,
5
5
  # and so is a closed punch short of config.work_hours.
6
6
  # Nobody punched in already reads as absent (DayStatus), so they are only
7
- # told. Leave days are left alone.
7
+ # told. Leave days, and each person's weekends and office holidays, are left alone.
8
8
  class AttendanceCloseJob < ApplicationJob
9
9
  def perform(date: (Time.current - 12.hours).to_date)
10
- return unless WorkingCalendar.new(date..date).working_day?(date)
11
-
10
+ calendars = WorkingCalendar.by_office(date..date)
11
+ offices = EmployeeProfile.pluck(:user_id, :office_location_id).to_h
12
12
  on_leave = LeaveRequest.active_on(date).pluck(:user_id)
13
13
  records = AttendanceRecord.for_date(date).index_by(&:user_id)
14
14
  day = date.strftime("%d %b")
15
15
  HrLite.active_employees(on: date).each do |user|
16
- next if on_leave.include?(user.id)
16
+ next if on_leave.include?(user.id) || !calendars[offices[user.id]].working_day?(date)
17
17
 
18
18
  record = records[user.id]
19
19
  if record.nil? || record.check_in_at.nil?
@@ -29,15 +29,22 @@ module HrLite
29
29
  end
30
30
  penalize_short_day(record) if record&.check_out_at
31
31
  end
32
+ close_work_modes(date) if HrLite.config.office_geofence
32
33
  end
33
34
 
34
35
  private
35
36
 
37
+ # Anything still running from that day ends at its close; Offline stays open.
38
+ def close_work_modes(date)
39
+ day_end = [ date.in_time_zone(HrLite.config.time_zone).end_of_day, Time.current ].min
40
+ WorkModeEvent.open.work.where.not(mode: "offline").where(started_at: ..day_end).find_each { |e| e.close!("day_closed", at: day_end) }
41
+ end
42
+
36
43
  # Out before the slot's required checkout: +30 min on the next working day.
37
44
  def penalize_short_day(record)
38
45
  return unless HrLite.config.attendance_slots && record.check_out_at < record.required_check_out_at
39
46
 
40
- next_day = AttendancePenalty.next_working_days(record.date, 1).first
47
+ next_day = AttendancePenalty.next_working_days(record.user_id, record.date, 1).first
41
48
  AttendancePenalty.find_or_create_by!(attendance_record: record, reason: "short_day", date: next_day) do |p|
42
49
  p.user_id = record.user_id
43
50
  p.minutes = AttendanceSlot::STEP_MINUTES
@@ -0,0 +1,31 @@
1
+ module HrLite
2
+ # Opens Meeting mode (system-set) for a calendar event at its start. It asks
3
+ # the calendar again first, so a meeting moved or cancelled since it was
4
+ # scheduled does not open. It never overrides a break or a mode the person
5
+ # or HR chose, and only runs for someone checked in.
6
+ class CalendarMeetingStartJob < ApplicationJob
7
+ def perform(user_id, calendar_event_id)
8
+ now = Time.current
9
+ return if WorkModeEvent.exists?(user_id: user_id, calendar_event_id: calendar_event_id)
10
+
11
+ user = HrLite.user_klass.find_by(id: user_id) or return
12
+ hook = HrLite.config.calendar_events or return
13
+ meeting = CalendarMeetingSyncJob.meetings(hook, user, now, now + 1.minute)&.find { |m| m[:id] == calendar_event_id }
14
+ return unless meeting && meeting[:starts_at] <= now + 1.minute && meeting[:ends_at] > now
15
+
16
+ record = AttendanceRecord.find_by(user_id: user_id, date: now.in_time_zone(HrLite.config.time_zone).to_date)
17
+ return unless record&.check_in_at && record.check_out_at.nil?
18
+
19
+ status = WorkStatus.for(user_id)
20
+ return unless status.primary.event
21
+ return if status.overlay && status.overlay.source != "system"
22
+
23
+ event = WorkModeEvent.start!(user_id: user_id, mode: "meeting", source: "system", at: now, ends_at: meeting[:ends_at],
24
+ title: meeting[:title].to_s.truncate(120).presence || "Calendar meeting",
25
+ calendar_event_id: calendar_event_id, attendance_record: record)
26
+ WorkModeExpireJob.set(wait_until: meeting[:ends_at]).perform_later(event.id)
27
+ rescue ActiveRecord::RecordNotUnique
28
+ nil # another run opened it first
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,55 @@
1
+ module HrLite
2
+ # Keeps Meeting mode in step with each person's calendar. The host runs it
3
+ # for everyone every few minutes, and for one person the moment Google says
4
+ # their calendar changed. Each run schedules meetings starting soon and
5
+ # reconciles open ones: a cancelled or declined invite ends the meeting, a
6
+ # moved end time moves it. Re-runs are harmless.
7
+ class CalendarMeetingSyncJob < ApplicationJob
8
+ WINDOW = 10.minutes
9
+
10
+ def perform(user_ids = nil)
11
+ hook = HrLite.config.calendar_events
12
+ return unless hook && HrLite.config.calendar_users && HrLite.config.office_geofence
13
+
14
+ now = Time.current
15
+ ids = HrLite.config.calendar_users.call
16
+ ids &= Array(user_ids).map(&:to_i) if user_ids
17
+ HrLite.user_klass.where(id: ids).find_each do |user|
18
+ meetings = self.class.meetings(hook, user, now, now + WINDOW)
19
+ next if meetings.nil? # the calendar could not be read: leave everything as it is
20
+
21
+ reconcile(user, meetings, now)
22
+ meetings.each do |m|
23
+ next unless m[:starts_at] < now + WINDOW && m[:ends_at] > now
24
+
25
+ CalendarMeetingStartJob.set(wait_until: [ m[:starts_at], now ].max).perform_later(user.id, m[:id])
26
+ end
27
+ end
28
+ end
29
+
30
+ # Timed meetings overlapping from..to, or nil when the hook fails.
31
+ def self.meetings(hook, user, from, to)
32
+ Array(hook.call(user, from, to)).map { |m| m.to_h.symbolize_keys }
33
+ .select { |m| m[:id].present? && m[:starts_at] && m[:ends_at] }
34
+ .each { |m| m[:id] = m[:id].to_s }
35
+ rescue => e
36
+ Rails.logger.warn("[hr_lite] calendar read failed for user #{user.id}: #{e.class}: #{e.message}")
37
+ nil
38
+ end
39
+
40
+ private
41
+
42
+ def reconcile(user, meetings, now)
43
+ by_id = meetings.index_by { |m| m[:id] }
44
+ WorkModeEvent.open.where(user_id: user.id, source: "system").where.not(calendar_event_id: nil).find_each do |event|
45
+ meeting = by_id[event.calendar_event_id]
46
+ if meeting.nil? || meeting[:starts_at] > now
47
+ event.close!("ended", at: now) # cancelled, declined or moved later
48
+ elsif meeting[:ends_at] != event.ends_at && meeting[:ends_at] > now
49
+ event.update!(ends_at: meeting[:ends_at])
50
+ WorkModeExpireJob.set(wait_until: meeting[:ends_at]).perform_later(event.id)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,12 @@
1
+ module HrLite
2
+ # Ends a timed mode at its end time on the server, browser or not. A break
3
+ # the system had to end at its limit is marked as an overrun for HR.
4
+ class WorkModeExpireJob < ApplicationJob
5
+ def perform(event_id)
6
+ event = WorkModeEvent.open.find_by(id: event_id)
7
+ return unless event&.ends_at && event.ends_at <= Time.current
8
+
9
+ event.update!(ended_at: event.ends_at, end_reason: "expired", overrun: %w[lunch short_break].include?(event.mode))
10
+ end
11
+ end
12
+ end
@@ -20,8 +20,8 @@ module HrLite
20
20
 
21
21
  scope :active, -> { where(removed_at: nil) }
22
22
 
23
- def self.next_working_days(date, count)
24
- calendar = WorkingCalendar.new((date + 1)..(date + 31))
23
+ def self.next_working_days(user_id, date, count)
24
+ calendar = WorkingCalendar.for_user(user_id, (date + 1)..(date + 31))
25
25
  ((date + 1)..(date + 31)).select { |d| calendar.working_day?(d) }.first(count)
26
26
  end
27
27
 
@@ -6,11 +6,19 @@ module HrLite
6
6
  LATE_HALF_DAY_NOTE = "Half day: checked in after 11:30".freeze
7
7
  # Half days the system set (not HR); an approved ticket lifts them.
8
8
  AUTO_NOTES = [ AUTO_CHECKOUT_NOTE, SHORT_DAY_NOTE, LATE_HALF_DAY_NOTE ].freeze
9
+ # Where the day was worked, set at check-in under config.office_geofence.
10
+ WORK_MODES = { "office" => "Office", "wfh" => "WFH", "travelling" => "Travelling",
11
+ "outside_meeting" => "Client meeting" }.freeze
9
12
  # Check-in and check-out further apart than this are called out on the board.
10
13
  PUNCH_GAP_ALERT_M = 500
11
14
 
12
15
  belongs_to :user, class_name: HrLite.config.user_class
13
16
  belongs_to :regularized_by, class_name: HrLite.config.user_class, optional: true
17
+ belongs_to :wfh_request, optional: true
18
+ belongs_to :work_location, optional: true
19
+
20
+ def remote? = work_mode.present? && work_mode != "office"
21
+ def work_mode_label = WORK_MODES[work_mode]
14
22
 
15
23
  validates :date, presence: true, uniqueness: { scope: :user_id }
16
24
  validates :status, inclusion: { in: STATUSES }
@@ -45,7 +45,7 @@ module HrLite
45
45
  raise MissingCompOffType if type.nil?
46
46
 
47
47
  transition!("approved", actor, note) do
48
- raise StaleOffDay if WorkingCalendar.new(date_worked..date_worked).working_day?(date_worked)
48
+ raise StaleOffDay if WorkingCalendar.for_user(user_id, date_worked..date_worked).working_day?(date_worked)
49
49
 
50
50
  # (A duplicate live request for the same date cannot exist — the
51
51
  # partial unique index on [user_id, date_worked] guarantees it.)
@@ -141,7 +141,7 @@ module HrLite
141
141
  return unless date_worked
142
142
  return if date_worked > Date.current
143
143
 
144
- calendar = WorkingCalendar.new(date_worked..date_worked)
144
+ calendar = WorkingCalendar.for_user(user_id, date_worked..date_worked)
145
145
  if calendar.working_day?(date_worked)
146
146
  errors.add(:date_worked, "was a regular working day — comp-off is for working on a weekend or holiday")
147
147
  end
@@ -12,6 +12,8 @@ module HrLite
12
12
  # Reporting line (L1). Optional; the org chart treats manager-less
13
13
  # profiles as roots (founders/directors).
14
14
  belongs_to :manager, class_name: HrLite.config.user_class, optional: true
15
+ # Decides which office holidays apply; none = only all-office holidays.
16
+ belongs_to :office_location, optional: true
15
17
 
16
18
  encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc, :blood_group, :emergency_contact
17
19
  # fy_opening_*: income already paid this financial year that this install
@@ -2,17 +2,23 @@ module HrLite
2
2
  class Holiday < ApplicationRecord
3
3
  include Audited
4
4
 
5
- validates :date, presence: true, uniqueness: true
5
+ belongs_to :office_location, optional: true
6
+
7
+ validates :date, presence: true, uniqueness: { scope: :office_location_id }
6
8
  validates :name, presence: true
7
9
 
8
10
  scope :company_wide, -> { where(optional: false) }
9
11
  scope :in_range, ->(range) { where(date: range) }
10
12
  scope :upcoming, -> { where(date: Date.current..).order(:date) }
11
13
 
12
- # Company-wide holiday dates only — optional holidays never exclude
13
- # working days.
14
- def self.dates_for(range)
15
- company_wide.in_range(range).pluck(:date).to_set
14
+ scope :for_office, ->(office_id) { where(office_location_id: [ nil, office_id ].uniq) }
15
+
16
+ # Days off for one office: every all-office holiday plus that office's
17
+ # own. Optional holidays never exclude working days.
18
+ def self.dates_for(range, office_id = nil)
19
+ company_wide.in_range(range).for_office(office_id).pluck(:date).to_set
16
20
  end
21
+
22
+ def office_label = office_location&.name || "All offices"
17
23
  end
18
24
  end
@@ -62,7 +62,7 @@ module HrLite
62
62
  # Building one per request meant a Holiday query and a Setting query
63
63
  # each — and the admin balances grid reads this cell for every employee
64
64
  # times every leave type.
65
- calendar = WorkingCalendar.new(range)
65
+ calendar = WorkingCalendar.for_user(user_id, range)
66
66
  # Loss-of-pay days (beyond paid_days) never drew on the balance.
67
67
  requests.sum { |request| [ LeaveDayCounter.count(request, calendar: calendar), request.paid_days ].compact.min }
68
68
  end
@@ -58,7 +58,7 @@ module HrLite
58
58
  def unpaid_on(date)
59
59
  return 0 if paid_days.nil?
60
60
 
61
- @leave_calendar ||= WorkingCalendar.new(start_date..end_date)
61
+ @leave_calendar ||= WorkingCalendar.for_user(user_id, start_date..end_date)
62
62
  through = LeaveDayCounter.count_range(start_date: start_date, end_date: date, half_day: half_day,
63
63
  calendar: @leave_calendar)
64
64
  (through - paid_days).clamp(0, half_day ? BigDecimal("0.5") : 1)
@@ -44,7 +44,7 @@ module HrLite
44
44
  # day HR already decided or rejected, stays HR's call.
45
45
  def self_fixable?
46
46
  rule = HrLite.config.self_regularization
47
- return false unless rule && date >= Date.current - rule[:within_days] && WorkingCalendar.new(date..date).working_day?(date)
47
+ return false unless rule && date >= Date.current - rule[:within_days] && WorkingCalendar.for_user(user_id, date..date).working_day?(date)
48
48
 
49
49
  day = punch
50
50
  fills_gap = (check_in_at.nil? || !day&.check_in_at) &&
@@ -6,6 +6,8 @@ module HrLite
6
6
  WEEKEND_POLICIES = %w[sun_only sat_sun second_fourth_sat_sun].freeze
7
7
 
8
8
  validates :weekend_policy, inclusion: { in: WEEKEND_POLICIES }
9
+ validates :lunch_break_minutes, numericality: { only_integer: true, in: 5..120 }
10
+ validates :short_break_minutes, numericality: { only_integer: true, in: 1..60 }
9
11
  validates :employee_code_prefix, presence: true,
10
12
  format: { with: /\A[A-Za-z]{1,10}-?\z/, message: "letters only (max 10), optionally ending in -" }
11
13
 
@@ -0,0 +1,108 @@
1
+ module HrLite
2
+ # "Let me work from home on these dates." HR approves or rejects; only an
3
+ # approved request covering today lets a check-in skip the office geofence.
4
+ class WfhRequest < ApplicationRecord
5
+ STATUSES = %w[pending approved rejected cancelled].freeze
6
+ MAX_DAYS_AHEAD = 30
7
+ HR_MAX_DAYS = 60 # a span HR grants directly
8
+
9
+ belongs_to :user, class_name: HrLite.config.user_class
10
+ belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
11
+ belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
12
+
13
+ validates :start_date, :end_date, :reason, presence: true
14
+ validates :status, inclusion: { in: STATUSES }
15
+ validate :dates_in_window, :no_overlap, on: :create
16
+
17
+ scope :pending, -> { where(status: "pending") }
18
+ scope :approved, -> { where(status: "approved") }
19
+ scope :live, -> { where(status: %w[pending approved]) }
20
+ scope :overlapping, ->(from, to) { where("start_date <= ? AND end_date >= ?", to, from) }
21
+ scope :recent_first, -> { order(start_date: :desc, id: :desc) }
22
+
23
+ STATUSES.each { |s| define_method("#{s}?") { status == s } }
24
+
25
+ def self.approved_for(user_id, date) = approved.where(user_id: user_id).overlapping(date, date).first
26
+
27
+ # HR grants WFH outright: approved on creation, up to HR_MAX_DAYS long.
28
+ def self.grant!(user_id:, by:, start_date:, end_date:, reason:)
29
+ request = create!(user_id: user_id, start_date: start_date, end_date: end_date, reason: reason,
30
+ status: "approved", created_by_id: by.id, decided_by_id: by.id, decided_at: Time.current)
31
+ AuditLog.record!(action: "wfh.granted", subject: request, actor: by,
32
+ changes: { "dates" => request.dates_label, "reason" => reason })
33
+ request.send(:announce, "wfh.approved", "HR gave you WFH — #{request.dates_label}", reason, "/wfh_requests", [ request.user ])
34
+ request
35
+ end
36
+
37
+ def granted? = created_by_id.present? && created_by_id != user_id
38
+
39
+ def dates_label
40
+ start_date == end_date ? start_date.strftime("%a %-d %b") : "#{start_date.strftime('%-d %b')} – #{end_date.strftime('%-d %b')}"
41
+ end
42
+
43
+ def days = (end_date - start_date).to_i + 1
44
+
45
+ def approve!(actor:, note: nil)
46
+ transition!("approved", actor, note)
47
+ announce("wfh.approved", "WFH approved — #{dates_label}", decision_note, "/wfh_requests", [ user ])
48
+ end
49
+
50
+ def reject!(actor:, note:)
51
+ transition!("rejected", actor, note)
52
+ announce("wfh.rejected", "WFH rejected — #{dates_label}", decision_note, "/wfh_requests", [ user ])
53
+ end
54
+
55
+ def cancel!(actor:)
56
+ transition!("cancelled", actor, nil)
57
+ announce("wfh.cancelled", "#{HrLite.display_name(user)} cancelled WFH for #{dates_label}", nil,
58
+ "/admin/wfh_requests/#{id}", HrLite.admin_users)
59
+ end
60
+
61
+ def notify_requested
62
+ AuditLog.record!(action: "wfh.requested", subject: self, actor: user,
63
+ changes: { "dates" => dates_label, "reason" => reason })
64
+ announce("wfh.requested", "#{HrLite.display_name(user)} requested WFH for #{dates_label}", reason,
65
+ "/admin/wfh_requests/#{id}", HrLite.admin_users)
66
+ end
67
+
68
+ private
69
+
70
+ def transition!(new_status, actor, note)
71
+ with_lock do
72
+ raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
73
+
74
+ update!(status: new_status, decided_by_id: actor.id, decided_at: Time.current, decision_note: note)
75
+ end
76
+ AuditLog.record!(action: "wfh.#{new_status}", subject: self, actor: actor,
77
+ changes: { "dates" => dates_label, "note" => note }.compact)
78
+ true
79
+ end
80
+
81
+ # A notification failure never undoes a request or a decision.
82
+ def announce(event, title, body, path, to)
83
+ Notifications.publish(event, title: title, body: body.presence, path: path, bell_to: to,
84
+ email_to: event == "wfh.requested" ? [] : to)
85
+ rescue => e
86
+ Rails.logger.error("[hr_lite] wfh notification failed: #{e.class}: #{e.message}")
87
+ end
88
+
89
+ def dates_in_window
90
+ return unless start_date && end_date
91
+
92
+ errors.add(:start_date, "cannot be in the past") if start_date < Date.current
93
+ errors.add(:end_date, "must be on or after the start date") if end_date < start_date
94
+ if granted?
95
+ errors.add(:end_date, "HR can grant at most #{HR_MAX_DAYS} days at a time") if days > HR_MAX_DAYS
96
+ elsif end_date > Date.current + MAX_DAYS_AHEAD
97
+ errors.add(:end_date, "must be within #{MAX_DAYS_AHEAD} days from today")
98
+ end
99
+ end
100
+
101
+ def no_overlap
102
+ return unless start_date && end_date && user_id
103
+
104
+ clash = self.class.live.where(user_id: user_id).overlapping(start_date, end_date).exists?
105
+ errors.add(:base, "You already have a pending or approved WFH request on some of these dates") if clash
106
+ end
107
+ end
108
+ end