hr_lite 0.31.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/app/assets/javascripts/hr_lite/chart_tip.js +33 -0
- data/app/assets/javascripts/hr_lite/countdown.js +12 -0
- data/app/assets/javascripts/hr_lite/crm_picker.js +27 -0
- data/app/assets/javascripts/hr_lite/emoji_pick.js +11 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +47 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +8 -5
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/holidays_controller.rb +17 -9
- data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/work_locations_controller.rb +41 -0
- data/app/controllers/hr_lite/admin/work_modes_controller.rb +72 -0
- data/app/controllers/hr_lite/admin/work_time_controller.rb +39 -0
- data/app/controllers/hr_lite/attendance_controller.rb +2 -1
- data/app/controllers/hr_lite/calendar_controller.rb +3 -2
- data/app/controllers/hr_lite/comp_off_requests_controller.rb +1 -1
- data/app/controllers/hr_lite/holidays_controller.rb +3 -2
- data/app/controllers/hr_lite/team_controller.rb +13 -0
- data/app/controllers/hr_lite/work_locations_controller.rb +44 -0
- data/app/controllers/hr_lite/work_modes_controller.rb +40 -0
- data/app/controllers/hr_lite/work_time_controller.rb +14 -0
- data/app/helpers/hr_lite/application_helper.rb +43 -2
- data/app/jobs/hr_lite/attendance_close_job.rb +12 -5
- data/app/jobs/hr_lite/calendar_meeting_start_job.rb +31 -0
- data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +55 -0
- data/app/jobs/hr_lite/work_mode_expire_job.rb +12 -0
- data/app/models/hr_lite/attendance_penalty.rb +2 -2
- data/app/models/hr_lite/attendance_record.rb +7 -0
- data/app/models/hr_lite/comp_off_request.rb +2 -2
- data/app/models/hr_lite/employee_profile.rb +2 -0
- data/app/models/hr_lite/holiday.rb +11 -5
- data/app/models/hr_lite/leave_balance.rb +1 -1
- data/app/models/hr_lite/leave_request.rb +1 -1
- data/app/models/hr_lite/regularization_request.rb +1 -1
- data/app/models/hr_lite/setting.rb +2 -0
- data/app/models/hr_lite/work_location.rb +62 -0
- data/app/models/hr_lite/work_mode_event.rb +84 -0
- data/app/services/hr_lite/attendance_puncher.rb +60 -19
- data/app/services/hr_lite/day_status.rb +1 -1
- data/app/services/hr_lite/leave_day_counter.rb +5 -7
- data/app/services/hr_lite/team_day.rb +6 -6
- data/app/services/hr_lite/today_checkins.rb +41 -0
- data/app/services/hr_lite/work_mode_durations.rb +237 -0
- data/app/services/hr_lite/work_mode_switch.rb +144 -0
- data/app/services/hr_lite/work_status.rb +60 -0
- data/app/services/hr_lite/working_calendar.rb +11 -2
- data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
- data/app/views/hr_lite/admin/employees/_form.html.erb +7 -1
- data/app/views/hr_lite/admin/holidays/index.html.erb +10 -1
- data/app/views/hr_lite/admin/settings/edit.html.erb +9 -0
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +4 -1
- data/app/views/hr_lite/admin/wfh_requests/index.html.erb +2 -2
- data/app/views/hr_lite/admin/work_locations/index.html.erb +42 -0
- data/app/views/hr_lite/admin/work_modes/index.html.erb +105 -0
- data/app/views/hr_lite/admin/work_time/index.html.erb +42 -0
- data/app/views/hr_lite/attendance/_calendar_connection.html.erb +15 -0
- data/app/views/hr_lite/attendance/_crm_picker.html.erb +9 -0
- data/app/views/hr_lite/attendance/_custom_status.html.erb +26 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +19 -2
- data/app/views/hr_lite/attendance/_work_mode.html.erb +92 -0
- data/app/views/hr_lite/attendance/show.html.erb +4 -1
- data/app/views/hr_lite/holidays/_next_holiday.html.erb +1 -1
- data/app/views/hr_lite/home/_today_checkins.html.erb +31 -0
- data/app/views/hr_lite/home/index.html.erb +5 -0
- data/app/views/hr_lite/team/show.html.erb +25 -4
- data/app/views/hr_lite/work_locations/index.html.erb +52 -0
- data/app/views/hr_lite/work_time/_bars.html.erb +44 -0
- data/app/views/hr_lite/work_time/_donut.html.erb +36 -0
- data/app/views/hr_lite/work_time/_report.html.erb +74 -0
- data/app/views/hr_lite/work_time/_timeline.html.erb +26 -0
- data/app/views/hr_lite/work_time/show.html.erb +10 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20261013110000_create_hr_lite_work_locations.rb +23 -0
- data/db/migrate/20261013110100_add_work_mode_to_hr_lite_attendance_records.rb +7 -0
- data/db/migrate/20261013110200_add_field_staff_to_hr_lite_employee_profiles.rb +5 -0
- data/db/migrate/20261013110300_create_hr_lite_work_mode_events.rb +30 -0
- data/db/migrate/20261013110400_add_break_minutes_to_hr_lite_settings.rb +6 -0
- data/db/migrate/20261013120000_grant_hr_lite_work_analytics.rb +14 -0
- data/db/migrate/20261013130000_add_office_location_to_hr_lite_holidays.rb +12 -0
- data/db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb +6 -0
- data/db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb +9 -0
- data/db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb +9 -0
- data/lib/hr_lite/configuration.rb +13 -1
- data/lib/hr_lite/notifications.rb +9 -4
- data/lib/hr_lite/permissions.rb +1 -0
- data/lib/hr_lite/role_seeds.rb +4 -2
- data/lib/hr_lite/seeds.rb +1 -1
- data/lib/hr_lite/version.rb +1 -1
- metadata +44 -1
|
@@ -0,0 +1,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: "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|