hr_lite 0.58.0 → 0.61.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 +24 -0
- data/README.md +14 -0
- data/app/assets/javascripts/hr_lite/crm_picker.js +4 -2
- data/app/assets/javascripts/hr_lite/mention.js +2 -2
- data/app/assets/javascripts/hr_lite/photo_crop.js +4 -0
- data/app/assets/javascripts/hr_lite/search.js +3 -2
- data/app/assets/javascripts/hr_lite/tax_preview.js +3 -2
- data/app/assets/stylesheets/hr_lite/hr_lite.css +20 -0
- data/app/controllers/hr_lite/admin/assets_controller.rb +4 -4
- data/app/controllers/hr_lite/admin/attendances_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/ca_reviews_controller.rb +2 -2
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +4 -4
- data/app/controllers/hr_lite/admin/designation_changes_controller.rb +4 -1
- data/app/controllers/hr_lite/admin/employees_controller.rb +4 -2
- data/app/controllers/hr_lite/admin/expenses_controller.rb +6 -6
- data/app/controllers/hr_lite/admin/hiring_campaigns_controller.rb +88 -0
- data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +27 -0
- data/app/controllers/hr_lite/admin/hiring_interviews_controller.rb +77 -0
- data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +62 -0
- data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +45 -0
- data/app/controllers/hr_lite/admin/hr_requests_controller.rb +4 -4
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +4 -4
- data/app/controllers/hr_lite/admin/overview_controller.rb +5 -0
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +4 -4
- data/app/controllers/hr_lite/admin/resignations_controller.rb +4 -0
- data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +2 -2
- data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +4 -4
- data/app/controllers/hr_lite/admin/work_locations_controller.rb +3 -3
- data/app/controllers/hr_lite/application_controller.rb +5 -0
- data/app/controllers/hr_lite/comp_off_requests_controller.rb +2 -2
- data/app/controllers/hr_lite/hr_requests_controller.rb +2 -2
- data/app/controllers/hr_lite/regularization_requests_controller.rb +2 -2
- data/app/controllers/hr_lite/tax_declarations_controller.rb +2 -2
- data/app/controllers/hr_lite/wfh_requests_controller.rb +2 -2
- data/app/controllers/hr_lite/work_locations_controller.rb +1 -1
- data/app/helpers/hr_lite/application_helper.rb +38 -0
- data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +1 -1
- data/app/jobs/hr_lite/hiring_send_job.rb +42 -0
- data/app/models/concerns/hr_lite/audited.rb +2 -2
- data/app/models/hr_lite/designation_change.rb +4 -0
- data/app/models/hr_lite/employee_profile.rb +1 -1
- data/app/models/hr_lite/hiring_campaign.rb +21 -0
- data/app/models/hr_lite/hiring_candidate.rb +52 -0
- data/app/models/hr_lite/hiring_interview.rb +13 -0
- data/app/models/hr_lite/hiring_message.rb +14 -0
- data/app/models/hr_lite/hiring_template.rb +11 -0
- data/app/models/hr_lite/resignation.rb +4 -0
- data/app/models/hr_lite/wfh_request.rb +1 -1
- data/app/services/hr_lite/hiring_import.rb +106 -0
- data/app/services/hr_lite/hiring_merge.rb +48 -0
- data/app/views/hr_lite/admin/hiring_campaigns/_form.html.erb +18 -0
- data/app/views/hr_lite/admin/hiring_campaigns/edit.html.erb +3 -0
- data/app/views/hr_lite/admin/hiring_campaigns/import.html.erb +18 -0
- data/app/views/hr_lite/admin/hiring_campaigns/index.html.erb +21 -0
- data/app/views/hr_lite/admin/hiring_campaigns/new.html.erb +3 -0
- data/app/views/hr_lite/admin/hiring_campaigns/preview.html.erb +62 -0
- data/app/views/hr_lite/admin/hiring_campaigns/show.html.erb +98 -0
- data/app/views/hr_lite/admin/hiring_candidates/show.html.erb +145 -0
- data/app/views/hr_lite/admin/hiring_sends/new.html.erb +34 -0
- data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +16 -0
- data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +40 -0
- data/app/views/hr_lite/admin/hiring_templates/index.html.erb +15 -0
- data/app/views/hr_lite/admin/hiring_templates/new.html.erb +3 -0
- data/app/views/hr_lite/admin/overview/index.html.erb +17 -0
- data/config/routes.rb +10 -0
- data/db/migrate/20261021100000_create_hr_lite_hiring_campaigns.rb +15 -0
- data/db/migrate/20261021100100_create_hr_lite_hiring_candidates.rb +22 -0
- data/db/migrate/20261021100200_create_hr_lite_hiring_templates.rb +10 -0
- data/db/migrate/20261021100300_create_hr_lite_hiring_messages.rb +19 -0
- data/db/migrate/20261021100400_create_hr_lite_hiring_interviews.rb +16 -0
- data/db/migrate/20261021100500_seed_hr_lite_hiring.rb +43 -0
- data/lib/hr_lite/configuration.rb +14 -1
- data/lib/hr_lite/notifications.rb +5 -5
- data/lib/hr_lite/permissions.rb +2 -1
- data/lib/hr_lite/role_seeds.rb +2 -2
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +17 -1
- metadata +33 -1
|
@@ -65,8 +65,8 @@ module HrLite
|
|
|
65
65
|
redirect_to admin_leave_request_path(request),
|
|
66
66
|
alert: "Cannot approve — comp-off credit no longer covers this request."
|
|
67
67
|
end
|
|
68
|
-
rescue ActiveRecord::RecordInvalid
|
|
69
|
-
redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
|
|
68
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
69
|
+
redirect_to admin_leave_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
# LeaveRequest#cancellable_by? has always allowed an admin to call off
|
|
@@ -96,8 +96,8 @@ module HrLite
|
|
|
96
96
|
|
|
97
97
|
request.reject!(actor: hr_current_user, note: note)
|
|
98
98
|
redirect_to admin_leave_requests_path, notice: "Leave rejected."
|
|
99
|
-
rescue ActiveRecord::RecordInvalid
|
|
100
|
-
redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
|
|
99
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
100
|
+
redirect_to admin_leave_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
private
|
|
@@ -6,6 +6,11 @@ module HrLite
|
|
|
6
6
|
def index
|
|
7
7
|
@query = OverviewQuery.new(user_ids: hr_access.visible_user_ids("leave.view"))
|
|
8
8
|
@kpis = @query.kpis
|
|
9
|
+
# Emails, bells and audit rows that failed in the background; the audit trail's own gate.
|
|
10
|
+
return unless hr_can?("profile.manage", scope: :all)
|
|
11
|
+
|
|
12
|
+
failures = AuditLog.recent.where("action LIKE ?", "failure.%").includes(:subject).limit(SECTION_CAP)
|
|
13
|
+
@failures = hr_superadmin? ? failures : failures.outside_money_tier
|
|
9
14
|
end
|
|
10
15
|
end
|
|
11
16
|
end
|
|
@@ -18,8 +18,8 @@ module HrLite
|
|
|
18
18
|
rescue RegularizationRequest::InvalidMerge => e
|
|
19
19
|
redirect_to admin_regularization_request_path(request),
|
|
20
20
|
alert: "Cannot apply — #{e.message}. Fix the day manually or reject with a note."
|
|
21
|
-
rescue ActiveRecord::RecordInvalid
|
|
22
|
-
redirect_to admin_regularization_request_path(request), alert: "Only pending tickets can be decided."
|
|
21
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
22
|
+
redirect_to admin_regularization_request_path(request), alert: reason_for(e, "Only pending tickets can be decided.")
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def reject
|
|
@@ -31,8 +31,8 @@ module HrLite
|
|
|
31
31
|
|
|
32
32
|
request.reject!(actor: hr_current_user, note: note)
|
|
33
33
|
redirect_to admin_regularization_requests_path, notice: "Ticket rejected."
|
|
34
|
-
rescue ActiveRecord::RecordInvalid
|
|
35
|
-
redirect_to admin_regularization_request_path(request), alert: "Only pending tickets can be decided."
|
|
34
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
35
|
+
redirect_to admin_regularization_request_path(request), alert: reason_for(e, "Only pending tickets can be decided.")
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
private
|
|
@@ -17,6 +17,10 @@ module HrLite
|
|
|
17
17
|
"#{hr_display_name(resignation.user)}, so no exit date was recorded — " \
|
|
18
18
|
"create the profile to clip attendance and payroll."
|
|
19
19
|
end
|
|
20
|
+
if resignation.access_error
|
|
21
|
+
return redirect_to admin_employees_path,
|
|
22
|
+
alert: "#{notice} But access was NOT revoked (#{resignation.access_error}) — revoke the login by hand."
|
|
23
|
+
end
|
|
20
24
|
redirect_to admin_employees_path, notice: notice
|
|
21
25
|
rescue ActiveRecord::RecordInvalid => e
|
|
22
26
|
# A rejected exit date used to be reported as "not pending", which was
|
|
@@ -62,8 +62,8 @@ module HrLite
|
|
|
62
62
|
declaration.verify!(actor: hr_current_user, note: "Proof accepted")
|
|
63
63
|
end
|
|
64
64
|
redirect_to admin_tax_declarations_path, notice: "Accepted and verified — payroll uses these amounts now."
|
|
65
|
-
rescue ActiveRecord::RecordInvalid
|
|
66
|
-
redirect_to admin_tax_declaration_path(declaration), alert: "Only a submitted declaration can be accepted."
|
|
65
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
66
|
+
redirect_to admin_tax_declaration_path(declaration), alert: reason_for(e, "Only a submitted declaration can be accepted.")
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
def update
|
|
@@ -49,8 +49,8 @@ module HrLite
|
|
|
49
49
|
request = decidable.find(params[:id])
|
|
50
50
|
request.approve!(actor: hr_current_user, note: params[:decision_note].to_s.strip.presence)
|
|
51
51
|
redirect_to admin_wfh_requests_path, notice: "WFH approved for #{hr_display_name(request.user)}."
|
|
52
|
-
rescue ActiveRecord::RecordInvalid
|
|
53
|
-
redirect_to admin_wfh_request_path(request), alert: "Only pending requests can be decided."
|
|
52
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
53
|
+
redirect_to admin_wfh_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def reject
|
|
@@ -60,8 +60,8 @@ module HrLite
|
|
|
60
60
|
|
|
61
61
|
request.reject!(actor: hr_current_user, note: note)
|
|
62
62
|
redirect_to admin_wfh_requests_path, notice: "WFH rejected."
|
|
63
|
-
rescue ActiveRecord::RecordInvalid
|
|
64
|
-
redirect_to admin_wfh_request_path(request), alert: "Only pending requests can be decided."
|
|
63
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
64
|
+
redirect_to admin_wfh_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
# HR changes an approved or rejected request: dates (past allowed) and/or decision, with a reason.
|
|
@@ -38,11 +38,11 @@ module HrLite
|
|
|
38
38
|
body: note, path: "/work_locations",
|
|
39
39
|
bell_to: [ location.user ], email_to: [ location.user ])
|
|
40
40
|
rescue => e
|
|
41
|
-
|
|
41
|
+
HrLite.record_failure("work_location.#{status}", e, subject: location.user)
|
|
42
42
|
end
|
|
43
43
|
redirect_to admin_work_locations_path, notice: "Location #{status}."
|
|
44
|
-
rescue ActiveRecord::RecordInvalid
|
|
45
|
-
redirect_to admin_work_locations_path, alert: "Only pending locations can be decided."
|
|
44
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
45
|
+
redirect_to admin_work_locations_path, alert: reason_for(e, "Only pending locations can be decided.")
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -100,6 +100,11 @@ module HrLite
|
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
# The record's own reason when it has one; `fallback` names the usual cause.
|
|
104
|
+
def reason_for(error, fallback)
|
|
105
|
+
error.record.errors.full_messages.to_sentence.presence || fallback
|
|
106
|
+
end
|
|
107
|
+
|
|
103
108
|
# Strict param parsing: anything that isn't the exact expected format
|
|
104
109
|
# falls back to today (never 500s on a mangled URL).
|
|
105
110
|
def parse_month_param(value)
|
|
@@ -27,8 +27,8 @@ module HrLite
|
|
|
27
27
|
request = CompOffRequest.where(user_id: hr_current_user.id).find(params[:id])
|
|
28
28
|
request.cancel!(actor: hr_current_user)
|
|
29
29
|
redirect_to comp_off_requests_path, notice: "Request cancelled."
|
|
30
|
-
rescue ActiveRecord::RecordInvalid
|
|
31
|
-
redirect_to comp_off_requests_path, alert: "Only pending requests can be cancelled."
|
|
30
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
31
|
+
redirect_to comp_off_requests_path, alert: reason_for(e, "Only pending requests can be cancelled.")
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
private
|
|
@@ -30,8 +30,8 @@ module HrLite
|
|
|
30
30
|
request = own.find(params[:id])
|
|
31
31
|
request.cancel!(actor: hr_current_user)
|
|
32
32
|
redirect_to hr_requests_path, notice: "Request withdrawn."
|
|
33
|
-
rescue ActiveRecord::RecordInvalid
|
|
34
|
-
redirect_to hr_requests_path, alert: "That request has already been answered."
|
|
33
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
34
|
+
redirect_to hr_requests_path, alert: reason_for(e, "That request has already been answered.")
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
private
|
|
@@ -23,8 +23,8 @@ module HrLite
|
|
|
23
23
|
request = RegularizationRequest.where(user_id: hr_current_user.id).find(params[:id])
|
|
24
24
|
request.cancel!(actor: hr_current_user)
|
|
25
25
|
redirect_to regularization_requests_path, notice: "Day fix cancelled."
|
|
26
|
-
rescue ActiveRecord::RecordInvalid
|
|
27
|
-
redirect_to regularization_requests_path, alert: "Only pending day fixes can be cancelled."
|
|
26
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
27
|
+
redirect_to regularization_requests_path, alert: reason_for(e, "Only pending day fixes can be cancelled.")
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
private
|
|
@@ -54,8 +54,8 @@ module HrLite
|
|
|
54
54
|
|
|
55
55
|
@declaration.submit!(actor: hr_current_user)
|
|
56
56
|
redirect_to tax_declaration_path, notice: "Submitted — HR will check the proof."
|
|
57
|
-
rescue ActiveRecord::RecordInvalid
|
|
58
|
-
redirect_to tax_declaration_path, alert: "Only a draft can be submitted."
|
|
57
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
58
|
+
redirect_to tax_declaration_path, alert: reason_for(e, "Only a draft can be submitted.")
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
private
|
|
@@ -26,8 +26,8 @@ module HrLite
|
|
|
26
26
|
def cancel
|
|
27
27
|
WfhRequest.where(user_id: hr_current_user.id).find(params[:id]).cancel!(actor: hr_current_user)
|
|
28
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."
|
|
29
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
30
|
+
redirect_to wfh_requests_path, alert: reason_for(e, "Only pending requests can be cancelled.")
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
private
|
|
@@ -50,7 +50,7 @@ module HrLite
|
|
|
50
50
|
def notify(event, title, path, to)
|
|
51
51
|
Notifications.publish(event, title: title, path: path, bell_to: to)
|
|
52
52
|
rescue => e
|
|
53
|
-
|
|
53
|
+
HrLite.record_failure(event, e, subject: hr_current_user)
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
end
|
|
@@ -88,6 +88,10 @@ module HrLite
|
|
|
88
88
|
{ label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ], if: -> { hr_can?("hr_request.manage", scope: :all) } },
|
|
89
89
|
{ label: "Manage policies", path: :admin_policies_path, match: [ "/admin/policies" ], if: -> { hr_can?("policy.manage", scope: :all) } }
|
|
90
90
|
] },
|
|
91
|
+
{ label: "Hiring", tabs: [
|
|
92
|
+
{ label: "Campaigns", path: :admin_hiring_campaigns_path, match: [ "/admin/hiring_campaigns", "/admin/hiring_candidates" ], if: -> { hr_can?("hiring.manage", scope: :all) } },
|
|
93
|
+
{ label: "Email templates", path: :admin_hiring_templates_path, match: [ "/admin/hiring_templates" ], if: -> { hr_can?("hiring.manage", scope: :all) } }
|
|
94
|
+
] },
|
|
91
95
|
{ label: "Admin", divider: true, tabs: [
|
|
92
96
|
{ label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/expense_categories" ], if: LEAD },
|
|
93
97
|
{ label: "HR settings", path: :edit_admin_setting_path, match: [ "/admin/setting" ], if: LEAD },
|
|
@@ -194,6 +198,7 @@ module HrLite
|
|
|
194
198
|
"People" => '<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.9M16 3.1a4 4 0 0 1 0 7.8"/>',
|
|
195
199
|
"Me" => '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
|
|
196
200
|
"Help" => '<circle cx="12" cy="12" r="9"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/>',
|
|
201
|
+
"Hiring" => '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
|
|
197
202
|
"Admin" => '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
|
198
203
|
"More" => '<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>',
|
|
199
204
|
"Search" => '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'
|
|
@@ -253,6 +258,39 @@ module HrLite
|
|
|
253
258
|
hr_lite.public_send(helper, approval.subject_id)
|
|
254
259
|
end
|
|
255
260
|
|
|
261
|
+
HIRING_BADGES = { "imported" => "muted", "invited" => "info", "interested" => "ok", "not_interested" => "bad",
|
|
262
|
+
"interview" => "warn", "selected" => "ok", "rejected" => "bad", "on_hold" => "muted" }.freeze
|
|
263
|
+
|
|
264
|
+
def hrl_hiring_badge(candidate)
|
|
265
|
+
modifier = HIRING_BADGES.fetch(candidate.stage)
|
|
266
|
+
tag.span(candidate.stage_label, class: [ "hrl-badge", ("hrl-badge--#{modifier}" unless modifier == "info") ])
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The five steps of the candidate stepper: [label, :done|:now|:todo].
|
|
270
|
+
HIRING_STEPS = %w[Imported Invited Interested Interview Decision].freeze
|
|
271
|
+
HIRING_STEP_OF = { "imported" => 0, "invited" => 1, "interested" => 2, "not_interested" => 2, "interview" => 3,
|
|
272
|
+
"selected" => 4, "rejected" => 4, "on_hold" => 4 }.freeze
|
|
273
|
+
|
|
274
|
+
def hrl_hiring_steps(candidate)
|
|
275
|
+
at = HIRING_STEP_OF.fetch(candidate.stage)
|
|
276
|
+
HIRING_STEPS.each_with_index.map do |label, i|
|
|
277
|
+
label = candidate.stage_label if i == at && %w[not_interested interview selected rejected on_hold].include?(candidate.stage)
|
|
278
|
+
[ label, i < at ? :done : (i == at ? :now : :todo) ]
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# One line per hiring timeline row, from the whitelisted changes it stored.
|
|
283
|
+
def hrl_hiring_event(log)
|
|
284
|
+
c = log.audited_changes.to_h
|
|
285
|
+
at = c["starts_at"] && Time.zone.parse(c["starts_at"]).strftime("%-d %b %Y, %-l:%M %p")
|
|
286
|
+
case log.action.delete_prefix("hiring.")
|
|
287
|
+
when "stage" then [ "Moved to #{c['to'].to_s.humanize}", c["reason"] ].compact.join(": ")
|
|
288
|
+
when /\Aemail_(\w+)\z/ then "Email #{Regexp.last_match(1)}: #{c['template']}"
|
|
289
|
+
when /\Ainterview_(\w+)\z/ then [ "Round #{c['round']} #{Regexp.last_match(1).tr('_', ' ')}", at ].compact.join(" for ")
|
|
290
|
+
else log.action.delete_prefix("hiring.").humanize
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
256
294
|
def hrl_pagination
|
|
257
295
|
render "hr_lite/shared/pagination"
|
|
258
296
|
end
|
|
@@ -33,7 +33,7 @@ module HrLite
|
|
|
33
33
|
.select { |m| m[:id].present? && m[:starts_at] && m[:ends_at] }
|
|
34
34
|
.each { |m| m[:id] = m[:id].to_s }
|
|
35
35
|
rescue => e
|
|
36
|
-
|
|
36
|
+
HrLite.record_failure("calendar_read", e, subject: user)
|
|
37
37
|
nil
|
|
38
38
|
end
|
|
39
39
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Sends one queued hiring email through the host's mail adapter. The first
|
|
3
|
+
# step claims the row, so a redelivered or parallel job finds it taken and
|
|
4
|
+
# stops: a send is attempted at most once. A crash after the claim leaves
|
|
5
|
+
# "sending", which the screens show as "check Gmail", never as "send again".
|
|
6
|
+
# Adapter errors must not quote the recipient: their text is stored.
|
|
7
|
+
class HiringSendJob < ApplicationJob
|
|
8
|
+
queue_as :default
|
|
9
|
+
|
|
10
|
+
def perform(message_id)
|
|
11
|
+
return unless HiringMessage.where(id: message_id, status: "queued").update_all(status: "sending", updated_at: Time.current) == 1
|
|
12
|
+
|
|
13
|
+
message = HiringMessage.find(message_id)
|
|
14
|
+
HrLite::Current.set(actor: message.sent_by) { deliver(message) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def deliver(message)
|
|
20
|
+
candidate = message.candidate
|
|
21
|
+
adapter = HrLite.config.hiring
|
|
22
|
+
sender = adapter&.sender
|
|
23
|
+
return finish(message, "failed", error: "No sender mailbox is connected") unless sender&.dig(:ready)
|
|
24
|
+
|
|
25
|
+
mail = HiringMerge.new(candidate, sender_name: sender[:name]).render(message.template)
|
|
26
|
+
return finish(message, "failed", error: "Missing #{mail.missing.join(', ')}") if mail.missing.any?
|
|
27
|
+
|
|
28
|
+
ids = adapter.send_email(to: candidate.email, subject: mail.subject, body: mail.body)
|
|
29
|
+
finish(message, "sent", sent_at: Time.current, gmail_message_id: ids[:message_id], gmail_thread_id: ids[:thread_id])
|
|
30
|
+
candidate.move_to!("invited") if candidate.stage == "imported"
|
|
31
|
+
rescue HrLite::HiringDeliveryUnknown => e
|
|
32
|
+
finish(message, "unknown", error: e.message.to_s.truncate(200))
|
|
33
|
+
rescue HrLite::HiringDeliveryFailed => e
|
|
34
|
+
finish(message, "failed", error: e.message.to_s.truncate(200))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def finish(message, status, **attributes)
|
|
38
|
+
message.update!(status: status, **attributes)
|
|
39
|
+
message.candidate.log!("email_#{status}", template: message.template.name)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -37,8 +37,8 @@ module HrLite
|
|
|
37
37
|
hr_lite_publish_policy_change(log)
|
|
38
38
|
rescue => e
|
|
39
39
|
# The trail is best-effort by design: an audit hiccup must never roll
|
|
40
|
-
# back the domain write it describes.
|
|
41
|
-
|
|
40
|
+
# back the domain write it describes. Shown to HR admins instead.
|
|
41
|
+
HrLite.record_failure("audit.#{action}", e, subject: self)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def hr_lite_audited_changes(action)
|
|
@@ -17,6 +17,9 @@ module HrLite
|
|
|
17
17
|
after_create :apply_to_profile
|
|
18
18
|
after_create :notify
|
|
19
19
|
|
|
20
|
+
# Why the host's on_designation_change hook failed, for the screen to say so.
|
|
21
|
+
attr_reader :hook_error
|
|
22
|
+
|
|
20
23
|
def readonly?
|
|
21
24
|
persisted? && !destroyed?
|
|
22
25
|
end
|
|
@@ -45,6 +48,7 @@ module HrLite
|
|
|
45
48
|
HrLite.config.on_designation_change.call(user, to_designation)
|
|
46
49
|
rescue => e
|
|
47
50
|
Rails.logger.error("[hr_lite] on_designation_change failed: #{e.class}: #{e.message}")
|
|
51
|
+
@hook_error = e.message.truncate(120)
|
|
48
52
|
end
|
|
49
53
|
end
|
|
50
54
|
|
|
@@ -234,7 +234,7 @@ module HrLite
|
|
|
234
234
|
ChecklistItem.open_for!(user, kind: "onboarding", anchor_date: date_of_joining)
|
|
235
235
|
rescue => e
|
|
236
236
|
# A checklist must never be the reason an employee cannot be created.
|
|
237
|
-
|
|
237
|
+
HrLite.record_failure("onboarding_checklist", e, subject: user)
|
|
238
238
|
end
|
|
239
239
|
end
|
|
240
240
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One hiring drive for one opening. Its fields fill the @placeholders in
|
|
3
|
+
# every email, so the offer lives here, never in template copy.
|
|
4
|
+
class HiringCampaign < ApplicationRecord
|
|
5
|
+
has_many :candidates, class_name: "HrLite::HiringCandidate", foreign_key: :campaign_id,
|
|
6
|
+
inverse_of: :campaign, dependent: :restrict_with_error
|
|
7
|
+
|
|
8
|
+
validates :title, :position, presence: true
|
|
9
|
+
validates :status, inclusion: { in: %w[open closed] }
|
|
10
|
+
|
|
11
|
+
scope :newest_first, -> { order(created_at: :desc) }
|
|
12
|
+
|
|
13
|
+
# The pipeline list and "select all" on it are the same filter.
|
|
14
|
+
def pipeline(stage: nil, campus: nil)
|
|
15
|
+
scope = candidates
|
|
16
|
+
scope = scope.where(stage: stage) if stage.presence_in(HiringCandidate::STAGES)
|
|
17
|
+
scope = scope.where(campus: campus) if campus.present?
|
|
18
|
+
scope.order(:name, :id)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A person from a university's sheet, moving through one campaign. Every
|
|
3
|
+
# change goes on their timeline (AuditLog rows whose subject is them).
|
|
4
|
+
class HiringCandidate < ApplicationRecord
|
|
5
|
+
STAGES = %w[imported invited interested not_interested interview selected rejected on_hold].freeze
|
|
6
|
+
# Where a stage may go next. Sending moves imported → invited; scheduling moves to interview.
|
|
7
|
+
MOVES = {
|
|
8
|
+
"imported" => %w[invited interested not_interested],
|
|
9
|
+
"invited" => %w[interested not_interested],
|
|
10
|
+
"interested" => %w[interview not_interested on_hold rejected],
|
|
11
|
+
"not_interested" => %w[interested],
|
|
12
|
+
"interview" => %w[interview selected rejected on_hold],
|
|
13
|
+
"on_hold" => %w[interview selected rejected],
|
|
14
|
+
"selected" => [], "rejected" => []
|
|
15
|
+
}.freeze
|
|
16
|
+
REJECTION_REASONS = [ "Communication skills", "Not available for 6 months", "Location / relocation",
|
|
17
|
+
"Stipend not acceptable", "Did not attend interview", "Other" ].freeze
|
|
18
|
+
|
|
19
|
+
belongs_to :campaign, class_name: "HrLite::HiringCampaign", inverse_of: :candidates
|
|
20
|
+
has_many :messages, class_name: "HrLite::HiringMessage", foreign_key: :candidate_id, dependent: :restrict_with_error
|
|
21
|
+
has_many :interviews, class_name: "HrLite::HiringInterview", foreign_key: :candidate_id, dependent: :restrict_with_error
|
|
22
|
+
|
|
23
|
+
encrypts :phone, :date_of_birth
|
|
24
|
+
normalizes :email, with: ->(email) { email.to_s.strip.downcase }
|
|
25
|
+
|
|
26
|
+
validates :name, presence: true
|
|
27
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: { scope: :campaign_id }
|
|
28
|
+
validates :stage, inclusion: { in: STAGES }
|
|
29
|
+
|
|
30
|
+
def first_name = name.to_s.split.first
|
|
31
|
+
def stage_label = stage == "interview" ? "Interview · round #{round}" : stage.humanize
|
|
32
|
+
def timeline = AuditLog.where(subject_type: self.class.name, subject_id: id).includes(:actor).order(created_at: :desc, id: :desc)
|
|
33
|
+
|
|
34
|
+
# The one door for a stage change: refuses a move the pipeline does not
|
|
35
|
+
# allow, needs a listed reason to reject, and writes the timeline row.
|
|
36
|
+
def move_to!(to, reason: nil, note: nil, round: self.round)
|
|
37
|
+
raise ArgumentError, "#{stage_label} cannot move to #{to.humanize}." unless MOVES.fetch(stage).include?(to)
|
|
38
|
+
raise ArgumentError, "Pick a rejection reason." if to == "rejected" && !REJECTION_REASONS.include?(reason)
|
|
39
|
+
raise ArgumentError, "Write a note for “Other”." if reason == "Other" && note.blank?
|
|
40
|
+
|
|
41
|
+
transaction do
|
|
42
|
+
from = stage
|
|
43
|
+
update!(stage: to, round: round, rejection_reason: (to == "rejected" ? [ reason, note.presence ].compact.join(": ") : rejection_reason))
|
|
44
|
+
log!("stage", from: from, to: to, reason: (reason if to == "rejected"))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def log!(action, actor: HrLite::Current.actor, **changes)
|
|
49
|
+
AuditLog.record!(action: "hiring.#{action}", subject: self, actor: actor, changes: changes.compact)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One interview round on the sender's Google Calendar. A new round is a new
|
|
3
|
+
# row, so earlier rounds keep their own history.
|
|
4
|
+
class HiringInterview < ApplicationRecord
|
|
5
|
+
STATUSES = %w[unknown scheduled failed cancelled].freeze
|
|
6
|
+
|
|
7
|
+
belongs_to :candidate, class_name: "HrLite::HiringCandidate"
|
|
8
|
+
|
|
9
|
+
validates :status, inclusion: { in: STATUSES }
|
|
10
|
+
validates :round, numericality: { greater_than: 0 }
|
|
11
|
+
validates :starts_at, :ends_at, presence: true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One template sent (or being sent) to one candidate. The unique index on
|
|
3
|
+
# candidate + template is what makes a repeated click or job a no-op.
|
|
4
|
+
# "sending" left behind by a crash, and "unknown", both mean: look in Gmail.
|
|
5
|
+
class HiringMessage < ApplicationRecord
|
|
6
|
+
STATUSES = %w[queued sending sent failed unknown].freeze
|
|
7
|
+
|
|
8
|
+
belongs_to :candidate, class_name: "HrLite::HiringCandidate"
|
|
9
|
+
belongs_to :template, class_name: "HrLite::HiringTemplate"
|
|
10
|
+
belongs_to :sent_by, class_name: HrLite.config.user_class, optional: true
|
|
11
|
+
|
|
12
|
+
validates :status, inclusion: { in: STATUSES }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# An email HR writes once and sends to many. @placeholders are filled per
|
|
3
|
+
# candidate by HiringMerge at send time.
|
|
4
|
+
class HiringTemplate < ApplicationRecord
|
|
5
|
+
has_many :messages, class_name: "HrLite::HiringMessage", foreign_key: :template_id, dependent: :restrict_with_error
|
|
6
|
+
|
|
7
|
+
validates :name, :subject, :body, presence: true
|
|
8
|
+
|
|
9
|
+
scope :alphabetical, -> { order(:name) }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -68,6 +68,9 @@ module HrLite
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
# False when the person had no employee profile to stamp — see accept!.
|
|
71
|
+
# Why the host could not revoke access, for the screen to say so.
|
|
72
|
+
attr_reader :access_error
|
|
73
|
+
|
|
71
74
|
def exit_date_recorded?
|
|
72
75
|
@exit_date_recorded
|
|
73
76
|
end
|
|
@@ -82,6 +85,7 @@ module HrLite
|
|
|
82
85
|
HrLite.config.offboard_user.call(user)
|
|
83
86
|
rescue => e
|
|
84
87
|
Rails.logger.error("[hr_lite] offboard_user failed: #{e.class}: #{e.message}")
|
|
88
|
+
@access_error = e.message.truncate(120)
|
|
85
89
|
end
|
|
86
90
|
|
|
87
91
|
def notify_submitted
|
|
@@ -96,7 +96,7 @@ module HrLite
|
|
|
96
96
|
Notifications.publish(event, title: title, body: body.presence, path: path, bell_to: to,
|
|
97
97
|
email_to: event == "wfh.requested" ? [] : to)
|
|
98
98
|
rescue => e
|
|
99
|
-
|
|
99
|
+
HrLite.record_failure(event, e, subject: user)
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
def dates_in_window
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Reads a university's candidate sheet into a preview, then imports it.
|
|
3
|
+
# Headers match an alias list after case-folding and dropping punctuation
|
|
4
|
+
# and spaces. A column nobody recognises is kept in `extra`, never dropped;
|
|
5
|
+
# two columns claiming one field wait for HR to pick one.
|
|
6
|
+
class HiringImport
|
|
7
|
+
ALIASES = {
|
|
8
|
+
"name" => %w[studentname name candidatename fullname],
|
|
9
|
+
"email" => %w[emailid email emailaddress mailid],
|
|
10
|
+
"phone" => %w[contactno contactnumber phone phoneno mobile mobileno],
|
|
11
|
+
"date_of_birth" => %w[dob dobddmmyyyy dateofbirth],
|
|
12
|
+
"campus" => %w[campus college],
|
|
13
|
+
"course" => %w[coursepursuing course programme program],
|
|
14
|
+
"branch" => %w[branchspecialization branch specialization],
|
|
15
|
+
"cv_url" => %w[linkforcv cv cvlink resume resumelink]
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
Column = Data.define(:index, :header, :field, :extra_key)
|
|
19
|
+
Row = Data.define(:number, :attributes, :extra, :outcome, :reason)
|
|
20
|
+
|
|
21
|
+
def self.normalize(header) = header.to_s.downcase.gsub(/[^a-z0-9]/, "")
|
|
22
|
+
|
|
23
|
+
# Cell values arrive as the sheet stored them: never evaluated, never fetched.
|
|
24
|
+
def self.text(value)
|
|
25
|
+
case value
|
|
26
|
+
when nil then ""
|
|
27
|
+
when Float then value == value.floor ? value.to_i.to_s : value.to_s
|
|
28
|
+
when Date, Time then value.strftime("%d/%m/%Y")
|
|
29
|
+
else value.to_s.strip
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :columns, :conflicts, :rows
|
|
34
|
+
|
|
35
|
+
# rows: arrays of cell values, headers first. picks: { "phone" => "3" }
|
|
36
|
+
# names the column that wins a field two columns claim.
|
|
37
|
+
def initialize(campaign, rows, picks: {})
|
|
38
|
+
@campaign = campaign
|
|
39
|
+
header, *body = rows
|
|
40
|
+
map_columns(Array(header), picks.to_h.transform_values(&:to_i))
|
|
41
|
+
@rows = classify(body)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def counts = %w[create update skip].index_with { |outcome| rows.count { |row| row.outcome == outcome } }
|
|
45
|
+
|
|
46
|
+
def import!
|
|
47
|
+
return false if conflicts.any?
|
|
48
|
+
|
|
49
|
+
HiringCandidate.transaction do
|
|
50
|
+
rows.reject { |row| row.outcome == "skip" }.each do |row|
|
|
51
|
+
candidate = @campaign.candidates.find_or_initialize_by(email: row.attributes["email"])
|
|
52
|
+
created = candidate.new_record?
|
|
53
|
+
candidate.assign_attributes(row.attributes.merge("extra" => candidate.extra.to_h.merge(row.extra)))
|
|
54
|
+
candidate.save!
|
|
55
|
+
candidate.log!(created ? "imported" : "reimported") if created || candidate.saved_changes.any?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def map_columns(header, picks)
|
|
64
|
+
@columns = header.each_with_index.map do |cell, index|
|
|
65
|
+
field = ALIASES.find { |_, names| names.include?(self.class.normalize(cell)) }&.first
|
|
66
|
+
Column.new(index:, header: cell.to_s.strip, field:, extra_key: nil)
|
|
67
|
+
end
|
|
68
|
+
claimed = @columns.select(&:field).group_by(&:field)
|
|
69
|
+
@conflicts = claimed.select { |field, cols| cols.size > 1 && cols.none? { |c| c.index == picks[field] } }
|
|
70
|
+
# A column that lost its field (or is unknown) keeps its data in `extra`.
|
|
71
|
+
taken = HiringMerge::RESERVED + ALIASES.keys
|
|
72
|
+
@columns = @columns.map do |col|
|
|
73
|
+
loses = col.field && claimed[col.field].size > 1 && picks[col.field] != col.index
|
|
74
|
+
next col if col.field && !loses
|
|
75
|
+
|
|
76
|
+
key = col.header.parameterize(separator: "_").presence || "column_#{col.index + 1}"
|
|
77
|
+
key = "sheet_#{key}" if taken.include?(key) || !key.match?(/\A[a-z]/) # @placeholders start with a letter
|
|
78
|
+
taken << key
|
|
79
|
+
col.with(field: nil, extra_key: key)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def classify(body)
|
|
84
|
+
seen = {}
|
|
85
|
+
existing = @campaign.candidates.pluck(:email).to_set
|
|
86
|
+
body.each_with_index.filter_map do |cells, i|
|
|
87
|
+
number = i + 2 # sheet row, counting the header
|
|
88
|
+
values = @columns.to_h { |col| [ col, self.class.text(Array(cells)[col.index]) ] }
|
|
89
|
+
next if values.values.all?(&:blank?) # an empty row in the sheet is not a candidate
|
|
90
|
+
|
|
91
|
+
attributes = values.select { |col, _| col.field }.to_h { |col, v| [ col.field, v.presence ] }
|
|
92
|
+
attributes["email"] = attributes["email"].to_s.strip.downcase.presence
|
|
93
|
+
extra = values.select { |col, v| col.extra_key && v.present? }.to_h { |col, v| [ col.extra_key, v ] }
|
|
94
|
+
outcome, reason =
|
|
95
|
+
if attributes["name"].blank? then [ "skip", "no name" ]
|
|
96
|
+
elsif attributes["email"].blank? then [ "skip", "no email address" ]
|
|
97
|
+
elsif !attributes["email"].match?(URI::MailTo::EMAIL_REGEXP) then [ "skip", "email address looks wrong" ]
|
|
98
|
+
elsif seen[attributes["email"]] then [ "skip", "same email as row #{seen[attributes['email']]}" ]
|
|
99
|
+
else [ existing.include?(attributes["email"]) ? "update" : "create", nil ]
|
|
100
|
+
end
|
|
101
|
+
seen[attributes["email"]] ||= number if attributes["email"]
|
|
102
|
+
Row.new(number:, attributes:, extra:, outcome:, reason:)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|