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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -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/wfh_requests_controller.rb +103 -0
- 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/wfh_requests_controller.rb +45 -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 +8 -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/wfh_request.rb +108 -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 +79 -4
- 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 +9 -0
- 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 +7 -1
- data/app/views/hr_lite/admin/wfh_requests/index.html.erb +91 -0
- data/app/views/hr_lite/admin/wfh_requests/show.html.erb +40 -0
- 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 +23 -0
- 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/wfh_requests/index.html.erb +70 -0
- 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 +22 -0
- data/db/migrate/20261013100000_create_hr_lite_wfh_requests.rb +22 -0
- data/db/migrate/20261013100100_add_wfh_request_id_to_hr_lite_attendance_records.rb +7 -0
- data/db/migrate/20261013100200_add_permanent_wfh_to_hr_lite_employee_profiles.rb +5 -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 +16 -1
- data/lib/hr_lite/notifications.rb +13 -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 +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: "
|
|
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,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.
|
|
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,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
|