hr_lite 0.31.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -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 +59 -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/work_locations_controller.rb +41 -0
  13. data/app/controllers/hr_lite/admin/work_modes_controller.rb +72 -0
  14. data/app/controllers/hr_lite/admin/work_time_controller.rb +39 -0
  15. data/app/controllers/hr_lite/attendance_controller.rb +2 -1
  16. data/app/controllers/hr_lite/calendar_controller.rb +3 -2
  17. data/app/controllers/hr_lite/comp_off_requests_controller.rb +1 -1
  18. data/app/controllers/hr_lite/holidays_controller.rb +3 -2
  19. data/app/controllers/hr_lite/team_controller.rb +13 -0
  20. data/app/controllers/hr_lite/work_locations_controller.rb +44 -0
  21. data/app/controllers/hr_lite/work_modes_controller.rb +40 -0
  22. data/app/controllers/hr_lite/work_time_controller.rb +14 -0
  23. data/app/helpers/hr_lite/application_helper.rb +43 -2
  24. data/app/jobs/hr_lite/attendance_close_job.rb +12 -5
  25. data/app/jobs/hr_lite/calendar_meeting_start_job.rb +31 -0
  26. data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +55 -0
  27. data/app/jobs/hr_lite/work_mode_expire_job.rb +12 -0
  28. data/app/models/hr_lite/attendance_penalty.rb +2 -2
  29. data/app/models/hr_lite/attendance_record.rb +7 -0
  30. data/app/models/hr_lite/comp_off_request.rb +2 -2
  31. data/app/models/hr_lite/employee_profile.rb +2 -0
  32. data/app/models/hr_lite/holiday.rb +11 -5
  33. data/app/models/hr_lite/leave_balance.rb +1 -1
  34. data/app/models/hr_lite/leave_request.rb +1 -1
  35. data/app/models/hr_lite/regularization_request.rb +1 -1
  36. data/app/models/hr_lite/setting.rb +2 -0
  37. data/app/models/hr_lite/work_location.rb +62 -0
  38. data/app/models/hr_lite/work_mode_event.rb +84 -0
  39. data/app/services/hr_lite/attendance_puncher.rb +60 -19
  40. data/app/services/hr_lite/day_status.rb +1 -1
  41. data/app/services/hr_lite/leave_day_counter.rb +5 -7
  42. data/app/services/hr_lite/team_day.rb +6 -6
  43. data/app/services/hr_lite/today_checkins.rb +41 -0
  44. data/app/services/hr_lite/work_mode_durations.rb +237 -0
  45. data/app/services/hr_lite/work_mode_switch.rb +144 -0
  46. data/app/services/hr_lite/work_status.rb +60 -0
  47. data/app/services/hr_lite/working_calendar.rb +11 -2
  48. data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
  49. data/app/views/hr_lite/admin/employees/_form.html.erb +7 -1
  50. data/app/views/hr_lite/admin/holidays/index.html.erb +10 -1
  51. data/app/views/hr_lite/admin/settings/edit.html.erb +9 -0
  52. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +4 -1
  53. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +2 -2
  54. data/app/views/hr_lite/admin/work_locations/index.html.erb +42 -0
  55. data/app/views/hr_lite/admin/work_modes/index.html.erb +105 -0
  56. data/app/views/hr_lite/admin/work_time/index.html.erb +42 -0
  57. data/app/views/hr_lite/attendance/_calendar_connection.html.erb +15 -0
  58. data/app/views/hr_lite/attendance/_crm_picker.html.erb +9 -0
  59. data/app/views/hr_lite/attendance/_custom_status.html.erb +26 -0
  60. data/app/views/hr_lite/attendance/_punch_card.html.erb +26 -6
  61. data/app/views/hr_lite/attendance/_work_mode.html.erb +92 -0
  62. data/app/views/hr_lite/attendance/show.html.erb +4 -1
  63. data/app/views/hr_lite/holidays/_next_holiday.html.erb +1 -1
  64. data/app/views/hr_lite/home/_today_checkins.html.erb +31 -0
  65. data/app/views/hr_lite/home/index.html.erb +5 -0
  66. data/app/views/hr_lite/team/show.html.erb +25 -4
  67. data/app/views/hr_lite/work_locations/index.html.erb +52 -0
  68. data/app/views/hr_lite/work_time/_bars.html.erb +44 -0
  69. data/app/views/hr_lite/work_time/_donut.html.erb +36 -0
  70. data/app/views/hr_lite/work_time/_report.html.erb +74 -0
  71. data/app/views/hr_lite/work_time/_timeline.html.erb +26 -0
  72. data/app/views/hr_lite/work_time/show.html.erb +10 -0
  73. data/config/routes.rb +16 -0
  74. data/db/migrate/20261013110000_create_hr_lite_work_locations.rb +23 -0
  75. data/db/migrate/20261013110100_add_work_mode_to_hr_lite_attendance_records.rb +7 -0
  76. data/db/migrate/20261013110200_add_field_staff_to_hr_lite_employee_profiles.rb +5 -0
  77. data/db/migrate/20261013110300_create_hr_lite_work_mode_events.rb +30 -0
  78. data/db/migrate/20261013110400_add_break_minutes_to_hr_lite_settings.rb +6 -0
  79. data/db/migrate/20261013120000_grant_hr_lite_work_analytics.rb +14 -0
  80. data/db/migrate/20261013130000_add_office_location_to_hr_lite_holidays.rb +12 -0
  81. data/db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb +6 -0
  82. data/db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb +9 -0
  83. data/db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb +9 -0
  84. data/lib/hr_lite/configuration.rb +13 -1
  85. data/lib/hr_lite/notifications.rb +9 -4
  86. data/lib/hr_lite/permissions.rb +1 -0
  87. data/lib/hr_lite/role_seeds.rb +4 -2
  88. data/lib/hr_lite/seeds.rb +1 -1
  89. data/lib/hr_lite/version.rb +1 -1
  90. metadata +44 -1
@@ -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", "/wfh_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", "/admin/wfh_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,12 +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
14
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]
15
22
 
16
23
  validates :date, presence: true, uniqueness: { scope: :user_id }
17
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,62 @@
1
+ module HrLite
2
+ # A place an employee may work from at home. Coordinates come from the
3
+ # device at "Register here", never typed; HR approves before it counts.
4
+ class WorkLocation < ApplicationRecord
5
+ STATUSES = %w[pending approved rejected removed].freeze
6
+ MAX_LIVE = 3
7
+ MAX_ACCURACY_M = 100 # a vaguer fix can't pin a home
8
+ FENCE_M = 500
9
+
10
+ belongs_to :user, class_name: HrLite.config.user_class
11
+ belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
12
+
13
+ validates :name, presence: true, length: { maximum: 60 }
14
+ validates :address, length: { maximum: 200 }
15
+ validates :status, inclusion: { in: STATUSES }
16
+ validates :lat, numericality: { greater_than_or_equal_to: -90, less_than_or_equal_to: 90 }
17
+ validates :lng, numericality: { greater_than_or_equal_to: -180, less_than_or_equal_to: 180 }
18
+ validates :accuracy_m, numericality: { greater_than: 0, less_than_or_equal_to: MAX_ACCURACY_M,
19
+ message: "must be %{count} m or better — step outside or wait for GPS and try again" }
20
+ validate :within_limit, on: :create
21
+
22
+ scope :live, -> { where(status: %w[pending approved]) }
23
+ scope :approved, -> { where(status: "approved") }
24
+ scope :pending, -> { where(status: "pending") }
25
+
26
+ STATUSES.each { |s| define_method("#{s}?") { status == s } }
27
+
28
+ def self.active_for(user_id) = approved.find_by(user_id: user_id, active: true)
29
+
30
+ def covers?(lat, lng) = Geo.distance_m(self.lat, self.lng, lat.to_f, lng.to_f) <= FENCE_M
31
+
32
+ def map_url = "https://www.google.com/maps?q=#{lat},#{lng}"
33
+
34
+ def decide!(status, actor:, note: nil)
35
+ raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
36
+
37
+ update!(status: status, decided_by_id: actor.id, decided_at: Time.current, decision_note: note)
38
+ make_active! if approved? && !self.class.approved.where(user_id: user_id, active: true).exists?
39
+ AuditLog.record!(action: "work_location.#{status}", subject: self, actor: actor, changes: { "name" => name, "note" => note }.compact)
40
+ end
41
+
42
+ def make_active!
43
+ transaction do
44
+ self.class.where(user_id: user_id, active: true).where.not(id: id).update_all(active: false)
45
+ update!(active: true)
46
+ end
47
+ end
48
+
49
+ def remove!(actor:)
50
+ update!(status: "removed", active: false)
51
+ AuditLog.record!(action: "work_location.removed", subject: self, actor: actor, changes: { "name" => name })
52
+ end
53
+
54
+ private
55
+
56
+ def within_limit
57
+ return unless self.class.live.where(user_id: user_id).count >= MAX_LIVE
58
+
59
+ errors.add(:base, "You can register at most #{MAX_LIVE} locations. Remove one first.")
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,84 @@
1
+ module HrLite
2
+ # One stretch of a work mode. Open rows (ended_at nil) are the current
3
+ # status — at most one primary and one overlay per person; closed rows are
4
+ # the day's timeline. Never deleted.
5
+ class WorkModeEvent < ApplicationRecord
6
+ # mode => [ label, layer, place shown to teammates ]
7
+ MODES = {
8
+ "office" => [ "In office", "primary", "Office" ],
9
+ "wfh" => [ "Work from home", "primary", "Home" ],
10
+ "travelling" => [ "Travelling", "primary", "Travelling" ],
11
+ "outside_meeting" => [ "Outside office meeting", "primary", "Client meeting" ],
12
+ "offline" => [ "Offline", "primary", nil ],
13
+ "meeting" => [ "Meeting", "overlay", nil ],
14
+ "lunch" => [ "Lunch break", "overlay", nil ],
15
+ "short_break" => [ "Short break", "overlay", nil ],
16
+ "busy" => [ "Busy", "overlay", nil ],
17
+ "out_of_office" => [ "Out of office", "overlay", nil ],
18
+ "custom" => [ "Custom status", "status", nil ]
19
+ }.freeze
20
+ SOURCES = %w[system employee hr].freeze
21
+ END_REASONS = %w[switched expired ended checked_out day_closed].freeze
22
+
23
+ belongs_to :user, class_name: HrLite.config.user_class
24
+ belongs_to :set_by, class_name: HrLite.config.user_class, optional: true
25
+ belongs_to :attendance_record, optional: true
26
+
27
+ validates :mode, inclusion: { in: MODES.keys }
28
+ validates :source, inclusion: { in: SOURCES }
29
+ validates :end_reason, inclusion: { in: END_REASONS }, allow_nil: true
30
+ validates :title, :client, :destination, length: { maximum: 120 }
31
+ validate :custom_status_shape, if: -> { mode == "custom" }
32
+ validate :ends_after_start
33
+
34
+ before_validation { self.layer = MODES.dig(mode, 1) if mode }
35
+
36
+ scope :open, -> { where(ended_at: nil) }
37
+ scope :primary, -> { where(layer: "primary") }
38
+ scope :overlay, -> { where(layer: "overlay") }
39
+ scope :work, -> { where.not(layer: "status") }
40
+
41
+ def self.label(mode) = MODES.dig(mode, 0)
42
+
43
+ # Closes the open row on the same layer and opens this one, atomically.
44
+ def self.start!(user_id:, mode:, source:, actor: nil, at: Time.current, **attrs)
45
+ transaction do
46
+ layer = MODES.dig(mode, 1)
47
+ open.where(user_id: user_id, layer: layer).lock.each { |e| e.close!("switched", at: at) }
48
+ event = create!(user_id: user_id, mode: mode, source: source, started_at: at, set_by_id: actor&.id, **attrs)
49
+ AuditLog.record!(action: "work_mode.#{mode}", subject: event, actor: actor || HrLite::Current.actor,
50
+ changes: { "source" => source, "until" => event.ends_at&.iso8601, "reason" => event.reason }.compact)
51
+ event
52
+ end
53
+ end
54
+
55
+ # Ends every open work row for the person (check-out). A custom status
56
+ # stays: it is the person's own and clears on its own timer.
57
+ def self.close_all!(user_id, reason, at: Time.current)
58
+ open.work.where(user_id: user_id).lock.each { |e| e.close!(reason, at: at) }
59
+ end
60
+
61
+ def label = self.class.label(mode)
62
+ def place = MODES.dig(mode, 2)
63
+ def timed? = ends_at.present?
64
+ def expired?(now = Time.current) = ended_at.nil? && ends_at.present? && ends_at <= now
65
+
66
+ def close!(reason, at: Time.current)
67
+ update!(ended_at: [ at, ends_at ].compact.min, end_reason: reason)
68
+ end
69
+
70
+ private
71
+
72
+ # Text and/or one emoji; the emoji is a single symbol, never words.
73
+ def custom_status_shape
74
+ errors.add(:base, "Write a status or pick an emoji.") if title.blank? && emoji.blank?
75
+ return if emoji.blank?
76
+
77
+ errors.add(:emoji, "must be a single emoji") unless emoji.grapheme_clusters.size == 1 && emoji !~ /[[:alnum:][:space:]]/
78
+ end
79
+
80
+ def ends_after_start
81
+ errors.add(:ends_at, "must be after the start") if ends_at && started_at && ends_at <= started_at
82
+ end
83
+ end
84
+ end