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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d57f2981c5062477e62d5cf7518fb074dfedccd73de6338d3263ce57143dd824
|
|
4
|
+
data.tar.gz: eb27ceb943445189d60db425d2fd7250d89fbb6e118f48b456d0a33eb22dfc4b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 128a548490e3eb5c3028c197eb352e14419d200bbff36c79c7237bb4f355b666dd525ad7a0e54a7d1c8f6814d3b8165e3311215ab8f17dd81ac20460ae47b926
|
|
7
|
+
data.tar.gz: 84179555ec4cf9ea6623071e0f72da671ff3f17f69f9d4771fbe6a833e6ba77ca6edcab85bbeebbf835d6aee2d2a23ee1f61d1cba42f7f26186e6d61d246ca14
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.61.0] - 2026-10-21
|
|
11
|
+
|
|
12
|
+
Hiring panel. Six migrations: five new tables and one that grants `hiring.manage` to HR, Leadership and Super Admin and seeds the first outreach email.
|
|
13
|
+
|
|
14
|
+
- **Campaigns.** One per opening. Position, programme, stipend, CTC, location and schedule live on the campaign and fill the email's @placeholders.
|
|
15
|
+
- **Sheet import with a preview.** Headers are matched through an alias list after case-folding and dropping punctuation, so each university's spelling works. Unknown columns are kept as extra data and usable as @placeholders. Two columns claiming one field wait for HR to pick one. Every skipped row says why, a repeat upload updates instead of duplicating, and nothing is saved before confirm. Phone and date of birth are encrypted. `config.read_sheet` reads the file (default: .csv only).
|
|
16
|
+
- **Bulk email from one mailbox.** A confirm page names everyone left out (already sent, or a placeholder empty or unknown). One spaced job per recipient goes through `config.hiring.send_email`, and a unique index plus a claimed row stop double sends. An answer that never came is marked for a person to check, never resent. Sends are limited to ten an hour per person.
|
|
17
|
+
- **Pipeline and timeline.** The stages are imported, invited, interested, not interested, interview (round N), selected, rejected and on hold. Moves the pipeline does not allow are refused, a rejection needs a listed reason, and every change goes on the candidate's timeline with who made it.
|
|
18
|
+
- **Interview invites.** Rounds go on the hiring mailbox's Google Calendar through `config.hiring`, with reschedule and cancel. Each round is its own row.
|
|
19
|
+
|
|
20
|
+
## [0.60.0] - 2026-10-21
|
|
21
|
+
|
|
22
|
+
Background failures reach a person. No migrations.
|
|
23
|
+
|
|
24
|
+
- **"Failed in the background" card on the HR overview.** A failed event email, leadership email or bell, host notify hook, WFH or work-location notice, audit row, onboarding checklist or calendar read used to write only a log line. Each is now recorded once in the audit trail (`failure.<event>`, with the error class, a 200-character message and the affected user) through `HrLite.record_failure`, and the ten most recent show on the overview to anyone who can open the audit trail. Money-tier subjects stay superadmin-only, as on the audit trail.
|
|
25
|
+
|
|
26
|
+
## [0.59.0] - 2026-10-21
|
|
27
|
+
|
|
28
|
+
No silent failures where a person is waiting on the answer. No migrations. Audit: `docs/audits/silent_failures.md`.
|
|
29
|
+
|
|
30
|
+
- **Decisions say why they failed.** 24 admin and employee actions answered every failed save with a stock line such as "Only pending requests can be decided." They now show the record's own reason, and keep the stock line for the genuine not-pending case.
|
|
31
|
+
- **Offboarding no longer claims access was revoked when it was not.** A failing `offboard_user` hook (offboard, or accepting a resignation on its last day) or `on_designation_change` hook now shows an alert with the cause and what to do by hand.
|
|
32
|
+
- **Search, live tax, lead search and @mentions say when they could not load.** They no longer look like "no results" or show a stale figure. An unreadable ID-card photo (HEIC on desktop) now says so.
|
|
33
|
+
|
|
10
34
|
## [0.58.0] - 2026-10-21
|
|
11
35
|
|
|
12
36
|
Leave and birthdays for the whole team, Keka-style. No migrations. Hosts schedule `HrLite::TeamNoticesJob` daily at 10am.
|
data/README.md
CHANGED
|
@@ -376,6 +376,20 @@ permission reaches (`leave.view`, `payroll.view`, `expense.approve`,
|
|
|
376
376
|
Leadership picks Employee of the Month, Quarter and Year at `/admin/awards`,
|
|
377
377
|
and Home shows the current winners to everyone.
|
|
378
378
|
|
|
379
|
+
## Hiring
|
|
380
|
+
|
|
381
|
+
Admin › Hiring (`hiring.manage`): campaigns, candidate sheet import, bulk email, interview rounds and decisions. The engine never talks to Google itself. Set `config.hiring` to an object with:
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
384
|
+
sender # => { email:, name:, ready:, connect_url: }
|
|
385
|
+
send_email(to:, subject:, body:) # => { message_id:, thread_id: } (plain text)
|
|
386
|
+
create_event(summary:, description:, attendee:, starts_at:, ends_at:) # => { event_id:, meet_link: }
|
|
387
|
+
update_event(event_id:, starts_at:, ends_at:)
|
|
388
|
+
cancel_event(event_id:)
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Raise `HrLite::HiringDeliveryFailed` when nothing was sent and `HrLite::HiringDeliveryUnknown` when the provider may have acted (timeouts, 5xx). Never put the recipient in the error text: it is stored. `config.read_sheet = ->(path, filename) { rows }` reads uploads (default: .csv).
|
|
392
|
+
|
|
379
393
|
## Recurring jobs
|
|
380
394
|
|
|
381
395
|
Schedule these on the host's job scheduler (cron, GoodJob, Sidekiq-cron,
|
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
if (q.length < 2) return;
|
|
13
13
|
timer = setTimeout(function () {
|
|
14
14
|
fetch(box.dataset.hrlCrm + "?q=" + encodeURIComponent(q), { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
|
15
|
-
.then(function (r) {
|
|
15
|
+
.then(function (r) { if (!r.ok) throw r; return r.json(); })
|
|
16
16
|
.then(function (leads) {
|
|
17
17
|
select.length = 1;
|
|
18
|
+
select.options[0].text = "No lead";
|
|
18
19
|
leads.forEach(function (lead) { select.add(new Option(lead.label, lead.id)); });
|
|
19
20
|
if (leads.length) select.selectedIndex = 1;
|
|
20
|
-
})
|
|
21
|
+
})
|
|
22
|
+
.catch(function () { select.length = 1; select.options[0].text = "Lead search failed. Type again to retry."; });
|
|
21
23
|
}, 250);
|
|
22
24
|
});
|
|
23
25
|
}
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
|
|
60
60
|
var lookup = debounce(function (query) {
|
|
61
61
|
fetch(url + "?q=" + encodeURIComponent(query), { headers: { Accept: "application/json" } })
|
|
62
|
-
.then(function (r) {
|
|
62
|
+
.then(function (r) { if (!r.ok) throw r; return r.json(); })
|
|
63
63
|
.then(renderResults)
|
|
64
|
-
.catch(close);
|
|
64
|
+
.catch(function () { close(); menu.textContent = "Could not load names. Keep typing to retry."; menu.hidden = false; });
|
|
65
65
|
}, 200);
|
|
66
66
|
|
|
67
67
|
textarea.addEventListener("input", function () {
|
|
@@ -31,8 +31,9 @@
|
|
|
31
31
|
clearTimeout(timer);
|
|
32
32
|
timer = setTimeout(function () {
|
|
33
33
|
fetch(form.action + "?frame=1&q=" + encodeURIComponent(input.value), { credentials: "same-origin", cache: "no-store" })
|
|
34
|
-
.then(function (response) {
|
|
35
|
-
.then(function (html) { out.innerHTML = html; })
|
|
34
|
+
.then(function (response) { if (!response.ok) throw response; return response.text(); })
|
|
35
|
+
.then(function (html) { out.innerHTML = html; })
|
|
36
|
+
.catch(function () { out.innerHTML = '<p class="hrl-errors" role="alert">Search failed. Check your connection and try again.</p>'; });
|
|
36
37
|
}, 180);
|
|
37
38
|
});
|
|
38
39
|
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
Array.from(data.keys()).forEach(function (key) { if (/\[proofs\]/.test(key)) data.delete(key); });
|
|
14
14
|
data.delete("_method");
|
|
15
15
|
fetch(form.dataset.hrlTaxPreview, { method: "POST", body: data, credentials: "same-origin", headers: { "Accept": "text/html" } })
|
|
16
|
-
.then(function (response) {
|
|
17
|
-
.then(function (html) {
|
|
16
|
+
.then(function (response) { if (!response.ok) throw response; return response.text(); })
|
|
17
|
+
.then(function (html) { panel.innerHTML = html; })
|
|
18
|
+
.catch(function () { panel.innerHTML = '<p class="hrl-errors" role="alert">Could not work out the tax just now. Check your connection; Save still works.</p>'; });
|
|
18
19
|
}
|
|
19
20
|
var rupees = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
|
|
20
21
|
// The limit bar and "₹X of the ₹Y limit counts" follow the amount as typed.
|
|
@@ -653,3 +653,23 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
|
|
|
653
653
|
.hrl-search__close { display: flex; justify-content: flex-end; margin: 0 0 var(--hrl-s-2); }
|
|
654
654
|
.hrl-search::backdrop { background: rgb(15 23 42 / .4); }
|
|
655
655
|
.hrl-search__list { list-style: none; margin: var(--hrl-s-1) 0 0; padding: 0; display: grid; gap: var(--hrl-s-2); }
|
|
656
|
+
/* Hiring email preview: plain text, so its line breaks are the email's. */
|
|
657
|
+
.hrl-hiring-preview { white-space: pre-line; overflow-wrap: anywhere; padding: var(--hrl-s-4); border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius-sm); background: var(--hrl-bg); }
|
|
658
|
+
/* Hiring candidate: actions first on a phone, a right-hand column on desktop. */
|
|
659
|
+
@media (min-width: 1024px) {
|
|
660
|
+
.hrl-grid--side { grid-template-columns: 2fr 1fr; align-items: start; }
|
|
661
|
+
.hrl-grid--side > .hrl-grid__aside { grid-column: 2; grid-row: 1 / span 9; }
|
|
662
|
+
}
|
|
663
|
+
.hrl-card--next { background: var(--hrl-info-bg); border-color: transparent; }
|
|
664
|
+
.hrl-hiring-decide > .button_to { flex: 1 1 8rem; }
|
|
665
|
+
.hrl-hiring-decide > .button_to .hrl-btn { width: 100%; }
|
|
666
|
+
.hrl-hiring-person { display: flex; align-items: center; gap: var(--hrl-s-3); width: 100%; }
|
|
667
|
+
.hrl-hiring-avatar { flex: none; width: 52px; height: 52px; border-radius: var(--hrl-radius-pill); background: var(--hrl-info-bg); color: var(--hrl-accent); display: grid; place-items: center; font: 700 var(--hrl-t-lead) var(--hrl-font-head); }
|
|
668
|
+
/* Pipeline stepper: a number until reached, a tick once passed. */
|
|
669
|
+
.hrl-steps { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--hrl-s-1); text-align: center; }
|
|
670
|
+
.hrl-steps li { display: grid; justify-items: center; gap: var(--hrl-s-1); font-size: var(--hrl-t-micro); color: var(--hrl-ink-2); }
|
|
671
|
+
.hrl-steps__dot { width: 28px; height: 28px; border-radius: var(--hrl-radius-pill); display: grid; place-items: center; font-weight: 700; border: 1px solid var(--hrl-line); background: var(--hrl-card); color: var(--hrl-muted); }
|
|
672
|
+
.hrl-steps .is-done .hrl-steps__dot { background: var(--hrl-ok); border-color: var(--hrl-ok); color: var(--hrl-accent-ink); }
|
|
673
|
+
.hrl-steps .is-now .hrl-steps__dot { background: var(--hrl-accent); border-color: var(--hrl-accent); color: var(--hrl-accent-ink); }
|
|
674
|
+
.hrl-steps .is-now { font-weight: 700; color: var(--hrl-accent); }
|
|
675
|
+
.hrl-mail__head { display: grid; gap: var(--hrl-s-2); padding-bottom: var(--hrl-s-3); margin-bottom: var(--hrl-s-3); border-bottom: 1px solid var(--hrl-line); overflow-wrap: anywhere; }
|
|
@@ -28,8 +28,8 @@ module HrLite
|
|
|
28
28
|
user = HrLite.user_klass.find(params[:user_id])
|
|
29
29
|
asset.assign_to!(user, actor: hr_current_user)
|
|
30
30
|
redirect_to admin_assets_path, notice: "#{asset.name} given to #{HrLite.display_name(user)}."
|
|
31
|
-
rescue ActiveRecord::RecordInvalid
|
|
32
|
-
redirect_to admin_assets_path, alert: "#{asset.name} is already with somebody."
|
|
31
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
32
|
+
redirect_to admin_assets_path, alert: reason_for(e, "#{asset.name} is already with somebody.")
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def take_back
|
|
@@ -37,8 +37,8 @@ module HrLite
|
|
|
37
37
|
asset.return!(actor: hr_current_user, condition: params[:condition_note],
|
|
38
38
|
status: params[:status].presence_in(Asset::STATUSES) || "available")
|
|
39
39
|
redirect_to admin_assets_path, notice: "#{asset.name} taken back."
|
|
40
|
-
rescue ActiveRecord::RecordInvalid
|
|
41
|
-
redirect_to admin_assets_path, alert: "#{asset.name} is not with anybody."
|
|
40
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
41
|
+
redirect_to admin_assets_path, alert: reason_for(e, "#{asset.name} is not with anybody.")
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
private
|
|
@@ -87,7 +87,7 @@ module HrLite
|
|
|
87
87
|
notice: "Nothing to remove for that day."
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
record.destroy
|
|
90
|
+
record.destroy!
|
|
91
91
|
# Its late penalties go too, so checking in again is not charged twice.
|
|
92
92
|
AttendancePenalty.active.where(attendance_record_id: record.id)
|
|
93
93
|
.find_each { |p| p.update!(removed_at: Time.current, removed_by_id: hr_current_user.id, removal_reason: note) }
|
|
@@ -51,8 +51,8 @@ module HrLite
|
|
|
51
51
|
def resolve
|
|
52
52
|
CaReview.find(params[:id]).resolve!(actor: hr_current_user, note: params[:note])
|
|
53
53
|
redirect_back_to_list notice: "Marked resolved."
|
|
54
|
-
rescue ActiveRecord::RecordInvalid
|
|
55
|
-
redirect_back_to_list alert: "Only a flagged item can be resolved."
|
|
54
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
55
|
+
redirect_back_to_list alert: reason_for(e, "Only a flagged item can be resolved.")
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
private
|
|
@@ -18,8 +18,8 @@ module HrLite
|
|
|
18
18
|
notice: "Approved — #{request.credit_days.to_f} day credited."
|
|
19
19
|
rescue CompOffRequest::MissingCompOffType, CompOffRequest::StaleOffDay => e
|
|
20
20
|
redirect_to admin_comp_off_request_path(request), alert: e.message
|
|
21
|
-
rescue ActiveRecord::RecordInvalid
|
|
22
|
-
redirect_to admin_comp_off_request_path(request), alert: "Only pending requests can be decided."
|
|
21
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
22
|
+
redirect_to admin_comp_off_request_path(request), alert: reason_for(e, "Only pending requests 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_comp_off_requests_path, notice: "Request rejected."
|
|
34
|
-
rescue ActiveRecord::RecordInvalid
|
|
35
|
-
redirect_to admin_comp_off_request_path(request), alert: "Only pending requests can be decided."
|
|
34
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
35
|
+
redirect_to admin_comp_off_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
private
|
|
@@ -12,7 +12,10 @@ module HrLite
|
|
|
12
12
|
@change = DesignationChange.new(change_params.merge(
|
|
13
13
|
user_id: @profile.user_id, created_by_id: hr_current_user.id
|
|
14
14
|
))
|
|
15
|
-
if @change.save
|
|
15
|
+
if @change.save && @change.hook_error
|
|
16
|
+
redirect_to admin_employee_path(@profile),
|
|
17
|
+
alert: "Role change recorded, but it could not be applied to their login (#{@change.hook_error}) — update their access by hand."
|
|
18
|
+
elsif @change.persisted?
|
|
16
19
|
redirect_to admin_employee_path(@profile), notice: "Role change recorded."
|
|
17
20
|
else
|
|
18
21
|
render :new, status: :unprocessable_entity
|
|
@@ -101,13 +101,15 @@ module HrLite
|
|
|
101
101
|
# since an unreturned laptop and un-revoked access both outlive the
|
|
102
102
|
# employment.
|
|
103
103
|
ChecklistItem.open_for!(@profile.user, kind: "offboarding", anchor_date: exit_date)
|
|
104
|
+
done = "Offboarded — last day #{exit_date.strftime('%d %b %Y')}"
|
|
104
105
|
begin
|
|
105
106
|
HrLite.config.offboard_user.call(@profile.user)
|
|
106
107
|
rescue => e
|
|
107
108
|
Rails.logger.error("[hr_lite] offboard_user failed: #{e.class}: #{e.message}")
|
|
109
|
+
return redirect_to admin_employee_path(@profile),
|
|
110
|
+
alert: "#{done}, but access was NOT revoked (#{e.message.truncate(120)}) — revoke the login by hand."
|
|
108
111
|
end
|
|
109
|
-
redirect_to admin_employee_path(@profile),
|
|
110
|
-
notice: "Offboarded — last day #{exit_date.strftime('%d %b %Y')}, access revoked."
|
|
112
|
+
redirect_to admin_employee_path(@profile), notice: "#{done}, access revoked."
|
|
111
113
|
rescue ActiveRecord::RecordInvalid => e
|
|
112
114
|
redirect_to admin_employee_path(@profile), alert: e.record.errors.full_messages.to_sentence
|
|
113
115
|
end
|
|
@@ -15,8 +15,8 @@ module HrLite
|
|
|
15
15
|
expense = decidable.find(params[:id])
|
|
16
16
|
expense.approve!(actor: hr_current_user, note: params[:decision_note].presence)
|
|
17
17
|
redirect_to admin_expenses_path, notice: "Claim approved."
|
|
18
|
-
rescue ActiveRecord::RecordInvalid
|
|
19
|
-
redirect_to admin_expenses_path, alert: "Only a submitted claim can be approved."
|
|
18
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
19
|
+
redirect_to admin_expenses_path, alert: reason_for(e, "Only a submitted claim can be approved.")
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def reject
|
|
@@ -26,8 +26,8 @@ module HrLite
|
|
|
26
26
|
|
|
27
27
|
expense.reject!(actor: hr_current_user, note: note)
|
|
28
28
|
redirect_to admin_expenses_path, notice: "Claim rejected."
|
|
29
|
-
rescue ActiveRecord::RecordInvalid
|
|
30
|
-
redirect_to admin_expenses_path, alert: "Only a submitted claim can be rejected."
|
|
29
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
30
|
+
redirect_to admin_expenses_path, alert: reason_for(e, "Only a submitted claim can be rejected.")
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def reimburse
|
|
@@ -35,8 +35,8 @@ module HrLite
|
|
|
35
35
|
month = parse_month_param(params[:period_month])
|
|
36
36
|
expense.reimburse!(actor: hr_current_user, period_month: month)
|
|
37
37
|
redirect_to admin_expenses_path, notice: "Will be paid with #{month.strftime('%B %Y')} payroll."
|
|
38
|
-
rescue ActiveRecord::RecordInvalid
|
|
39
|
-
redirect_to admin_expenses_path, alert: "Only an approved claim can go on payroll, and only on a month not yet paid."
|
|
38
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
39
|
+
redirect_to admin_expenses_path, alert: reason_for(e, "Only an approved claim can go on payroll, and only on a month not yet paid.")
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
# The receipt the employee had to upload, for the person deciding; same rules as a document download.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Hiring drives: the list, one campaign's pipeline, and its sheet import.
|
|
4
|
+
class HiringCampaignsController < ApplicationController
|
|
5
|
+
FIELDS = %i[title position programme stipend ctc location schedule status].freeze
|
|
6
|
+
|
|
7
|
+
before_action { hr_require_permission!("hiring.manage", scope: :all) }
|
|
8
|
+
before_action :set_campaign, except: %i[index new create]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@campaigns = paginate(HiringCampaign.newest_first)
|
|
12
|
+
@counts = HiringCandidate.where(campaign_id: @campaigns.map(&:id)).group(:campaign_id, :stage).count
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def show
|
|
16
|
+
@stage = params[:stage].presence_in(HiringCandidate::STAGES)
|
|
17
|
+
@campus = params[:campus].presence
|
|
18
|
+
@stage_counts = @campaign.candidates.group(:stage).count
|
|
19
|
+
@campuses = @campaign.candidates.where.not(campus: [ nil, "" ]).distinct.order(:campus).pluck(:campus)
|
|
20
|
+
@candidates = paginate(@campaign.pipeline(stage: @stage, campus: @campus))
|
|
21
|
+
@templates = HiringTemplate.alphabetical
|
|
22
|
+
@attention = HiringMessage.joins(:candidate).where(hr_lite_hiring_candidates: { campaign_id: @campaign.id })
|
|
23
|
+
.where(status: %w[failed unknown sending]).includes(:candidate, :template).limit(50)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A new campaign starts from the last one's offer: HR edits what changed.
|
|
27
|
+
def new
|
|
28
|
+
@campaign = HiringCampaign.new(HiringCampaign.newest_first.first&.slice(*FIELDS.excluding(:title, :status)))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create
|
|
32
|
+
@campaign = HiringCampaign.new(campaign_params)
|
|
33
|
+
return render(:new, status: :unprocessable_entity) unless @campaign.save
|
|
34
|
+
|
|
35
|
+
redirect_to hr_lite.admin_hiring_campaign_path(@campaign), notice: "Campaign created. Upload the candidate sheet next."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def edit; end
|
|
39
|
+
|
|
40
|
+
def update
|
|
41
|
+
return render(:edit, status: :unprocessable_entity) unless @campaign.update(campaign_params)
|
|
42
|
+
|
|
43
|
+
redirect_to hr_lite.admin_hiring_campaign_path(@campaign), notice: "Saved."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# GET: the upload form. POST with a file: the preview. POST with the
|
|
47
|
+
# sealed rows and confirm: the import. Nothing is saved before confirm.
|
|
48
|
+
def import
|
|
49
|
+
return if request.get?
|
|
50
|
+
|
|
51
|
+
rows = params[:sheet].present? ? read_sheet(params[:sheet]) : sealer.decrypt_and_verify(params[:rows].to_s)
|
|
52
|
+
@import = HiringImport.new(@campaign, rows, picks: params.fetch(:picks, {}).permit!.to_h)
|
|
53
|
+
if params[:confirm].present? && @import.import!
|
|
54
|
+
counts = @import.counts
|
|
55
|
+
return redirect_to hr_lite.admin_hiring_campaign_path(@campaign),
|
|
56
|
+
notice: "#{counts['create']} added, #{counts['update']} updated, #{counts['skip']} skipped."
|
|
57
|
+
end
|
|
58
|
+
@sealed = sealer.encrypt_and_sign(rows, expires_in: 1.hour)
|
|
59
|
+
render :preview
|
|
60
|
+
rescue ArgumentError => e
|
|
61
|
+
redirect_to hr_lite.import_admin_hiring_campaign_path(@campaign), alert: e.message
|
|
62
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
63
|
+
redirect_to hr_lite.import_admin_hiring_campaign_path(@campaign), alert: "That preview expired. Upload the sheet again."
|
|
64
|
+
rescue ActiveRecord::RecordNotUnique
|
|
65
|
+
redirect_to hr_lite.import_admin_hiring_campaign_path(@campaign), alert: "Another import ran at the same time. Upload the sheet again."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def set_campaign = @campaign = HiringCampaign.find(params[:id])
|
|
71
|
+
def campaign_params = params.require(:hiring_campaign).permit(*FIELDS)
|
|
72
|
+
|
|
73
|
+
def read_sheet(file)
|
|
74
|
+
rows = HrLite.config.read_sheet.call(file.tempfile.path, file.original_filename)
|
|
75
|
+
Array(rows).map { |row| Array(row).map { |cell| HiringImport.text(cell) } }
|
|
76
|
+
rescue ArgumentError
|
|
77
|
+
raise
|
|
78
|
+
rescue StandardError
|
|
79
|
+
raise ArgumentError, "That file could not be read. Save it as .xlsx or .csv with the headers in the first row."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The previewed rows ride in the form, encrypted, so nothing touches disk.
|
|
83
|
+
def sealer
|
|
84
|
+
ActiveSupport::MessageEncryptor.new(Rails.application.key_generator.generate_key("hr_lite.hiring_import", 32))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# One candidate: what we know, every email and interview, the timeline,
|
|
4
|
+
# and the decisions HR makes by hand.
|
|
5
|
+
class HiringCandidatesController < ApplicationController
|
|
6
|
+
# Invited and interview come from sending and scheduling, never a button.
|
|
7
|
+
MANUAL = %w[interested not_interested selected rejected on_hold].freeze
|
|
8
|
+
|
|
9
|
+
before_action { hr_require_permission!("hiring.manage", scope: :all) }
|
|
10
|
+
before_action { @candidate = HiringCandidate.includes(:campaign).find(params[:id]) }
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
@messages = @candidate.messages.includes(:template).order(:created_at)
|
|
14
|
+
@interviews = @candidate.interviews.order(:round, :created_at)
|
|
15
|
+
@timeline = @candidate.timeline.limit(100)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def stage
|
|
19
|
+
to = params[:to].presence_in(MANUAL) or raise ArgumentError, "That is not a decision HR makes here."
|
|
20
|
+
@candidate.move_to!(to, reason: params[:reason], note: params[:note])
|
|
21
|
+
redirect_to hr_lite.admin_hiring_candidate_path(@candidate), notice: "Moved to #{@candidate.stage_label}."
|
|
22
|
+
rescue ArgumentError => e
|
|
23
|
+
redirect_to hr_lite.admin_hiring_candidate_path(@candidate), alert: e.message
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Interview rounds on the hiring mailbox's Google Calendar. The row is
|
|
4
|
+
# written before Google is called, so an answer that never arrives still
|
|
5
|
+
# leaves a record ("unknown") for HR to check.
|
|
6
|
+
class HiringInterviewsController < ApplicationController
|
|
7
|
+
LENGTHS = [ 30, 45, 60 ].freeze
|
|
8
|
+
|
|
9
|
+
before_action { hr_require_permission!("hiring.manage", scope: :all) }
|
|
10
|
+
before_action { @candidate = HiringCandidate.includes(:campaign).find(params[:hiring_candidate_id]) }
|
|
11
|
+
before_action(except: :create) { @interview = @candidate.interviews.find(params[:id]) }
|
|
12
|
+
before_action :require_calendar!
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
return back(alert: "#{@candidate.stage_label} cannot be interviewed.") unless HiringCandidate::MOVES.fetch(@candidate.stage).include?("interview")
|
|
16
|
+
return back(alert: "Pick a date and time in the future.") unless (starts, ends = slot)
|
|
17
|
+
|
|
18
|
+
round = @candidate.interviews.where(status: %w[scheduled unknown]).maximum(:round).to_i + 1
|
|
19
|
+
@interview = @candidate.interviews.create!(round: round, starts_at: starts, ends_at: ends)
|
|
20
|
+
ids = adapter.create_event(summary: "Interview: #{@candidate.campaign.position} (round #{round})",
|
|
21
|
+
description: "#{@candidate.campaign.title}. Join with the Google Meet link in this invite.",
|
|
22
|
+
attendee: @candidate.email, starts_at: starts, ends_at: ends)
|
|
23
|
+
@interview.update!(status: "scheduled", event_id: ids[:event_id], meet_link: ids[:meet_link])
|
|
24
|
+
@candidate.move_to!("interview", round: round)
|
|
25
|
+
@candidate.log!("interview_scheduled", round: round, starts_at: starts.iso8601)
|
|
26
|
+
back(notice: "Invite sent for round #{round}.")
|
|
27
|
+
rescue HrLite::HiringDeliveryUnknown, HrLite::HiringDeliveryFailed => e
|
|
28
|
+
google_trouble(e, "interview_scheduled")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def update
|
|
32
|
+
return back(alert: "Pick a date and time in the future.") unless (starts, ends = slot)
|
|
33
|
+
adapter.update_event(event_id: @interview.event_id, starts_at: starts, ends_at: ends)
|
|
34
|
+
@interview.update!(starts_at: starts, ends_at: ends)
|
|
35
|
+
@candidate.log!("interview_rescheduled", round: @interview.round, starts_at: starts.iso8601)
|
|
36
|
+
back(notice: "Round #{@interview.round} moved. Google told the candidate.")
|
|
37
|
+
rescue HrLite::HiringDeliveryUnknown, HrLite::HiringDeliveryFailed => e
|
|
38
|
+
google_trouble(e, "interview_rescheduled")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def destroy
|
|
42
|
+
adapter.cancel_event(event_id: @interview.event_id) if @interview.event_id
|
|
43
|
+
@interview.update!(status: "cancelled")
|
|
44
|
+
@candidate.log!("interview_cancelled", round: @interview.round)
|
|
45
|
+
back(notice: "Round #{@interview.round} cancelled. Google told the candidate.")
|
|
46
|
+
rescue HrLite::HiringDeliveryUnknown, HrLite::HiringDeliveryFailed => e
|
|
47
|
+
google_trouble(e, "interview_cancelled")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def adapter = HrLite.config.hiring
|
|
53
|
+
def back(**flash) = redirect_to(hr_lite.admin_hiring_candidate_path(@candidate), **flash)
|
|
54
|
+
|
|
55
|
+
def require_calendar!
|
|
56
|
+
back(alert: "Connect the hiring mailbox before scheduling.") unless adapter&.sender&.dig(:ready)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def slot
|
|
60
|
+
starts = Time.zone.parse("#{params[:date]} #{params[:time]}")
|
|
61
|
+
return unless starts&.future?
|
|
62
|
+
|
|
63
|
+
minutes = LENGTHS.include?(params[:minutes].to_i) ? params[:minutes].to_i : LENGTHS.first
|
|
64
|
+
[ starts, starts + minutes.minutes ]
|
|
65
|
+
rescue ArgumentError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def google_trouble(error, action)
|
|
70
|
+
unknown = error.is_a?(HrLite::HiringDeliveryUnknown)
|
|
71
|
+
@interview.update!(status: unknown ? "unknown" : "failed", error: error.message.to_s.truncate(200)) if action == "interview_scheduled"
|
|
72
|
+
@candidate.log!("#{action}_#{unknown ? 'unknown' : 'failed'}", round: @interview.round)
|
|
73
|
+
back(alert: unknown ? "Google did not confirm. Check the hiring calendar before trying again." : "Google refused: #{error.message}")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Bulk email: a confirm page that says exactly who gets it and who is
|
|
4
|
+
# left out (and why), then one queued job per recipient, spaced out.
|
|
5
|
+
class HiringSendsController < ApplicationController
|
|
6
|
+
SPACING = 3.seconds
|
|
7
|
+
|
|
8
|
+
before_action { hr_require_permission!("hiring.manage", scope: :all) }
|
|
9
|
+
rate_limit to: 10, within: 1.hour, only: :create, name: "hiring_send", by: -> { hr_current_user.id },
|
|
10
|
+
with: -> { redirect_to hr_lite.admin_hiring_campaigns_path, alert: "Ten sends in an hour is the limit. Try again later." }
|
|
11
|
+
|
|
12
|
+
def new
|
|
13
|
+
load_recipients
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create
|
|
17
|
+
load_recipients
|
|
18
|
+
unless @sender&.dig(:ready)
|
|
19
|
+
return redirect_to hr_lite.admin_hiring_campaign_path(@campaign), alert: "Connect the hiring mailbox before sending."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
batch = SecureRandom.uuid
|
|
23
|
+
queued = @recipients.filter_map do |candidate|
|
|
24
|
+
HiringMessage.create!(candidate: candidate, template: @template, batch: batch, sent_by: hr_current_user)
|
|
25
|
+
rescue ActiveRecord::RecordNotUnique
|
|
26
|
+
nil # already queued by another click
|
|
27
|
+
end
|
|
28
|
+
queued.each_with_index { |message, i| HiringSendJob.set(wait: SPACING * i).perform_later(message.id) }
|
|
29
|
+
AuditLog.record!(action: "hiring.batch", subject: @campaign,
|
|
30
|
+
changes: { "template" => @template.name, "recipients" => queued.size, "batch" => batch })
|
|
31
|
+
redirect_to hr_lite.admin_hiring_campaign_path(@campaign), notice: "#{queued.size} emails queued from #{@sender[:email]}."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Only a definite failure is sent again; "unknown" may already be in Gmail.
|
|
35
|
+
def retry
|
|
36
|
+
message = HiringMessage.find(params[:id])
|
|
37
|
+
if message.status == "failed"
|
|
38
|
+
message.update!(status: "queued", error: nil, sent_by: hr_current_user)
|
|
39
|
+
HiringSendJob.perform_later(message.id)
|
|
40
|
+
end
|
|
41
|
+
redirect_to hr_lite.admin_hiring_campaign_path(message.candidate.campaign_id), notice: "Queued again."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Recomputed on every request from the ids: the confirm page is never trusted.
|
|
47
|
+
def load_recipients
|
|
48
|
+
@campaign = HiringCampaign.find(params[:hiring_campaign_id])
|
|
49
|
+
@template = HiringTemplate.find(params[:template_id])
|
|
50
|
+
@sender = HrLite.config.hiring&.sender
|
|
51
|
+
scope = params[:all].present? ? @campaign.pipeline(stage: params[:stage], campus: params[:campus]) : @campaign.pipeline.where(id: Array(params[:candidate_ids]))
|
|
52
|
+
already = HiringMessage.where(template: @template, candidate_id: scope.select(:id)).pluck(:candidate_id).to_set
|
|
53
|
+
@recipients, @left_out = [], []
|
|
54
|
+
scope.includes(:campaign, :interviews).each do |candidate|
|
|
55
|
+
missing = HiringMerge.new(candidate, sender_name: @sender&.dig(:name)).render(@template).missing
|
|
56
|
+
reason = already.include?(candidate.id) ? "already sent this template" : (missing.join(", ") if missing.any?)
|
|
57
|
+
reason ? @left_out << [ candidate, reason ] : @recipients << candidate
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Hiring emails HR writes once and sends to many, with a preview filled
|
|
4
|
+
# for a real candidate.
|
|
5
|
+
class HiringTemplatesController < ApplicationController
|
|
6
|
+
before_action { hr_require_permission!("hiring.manage", scope: :all) }
|
|
7
|
+
before_action(only: %i[edit update]) { @template = HiringTemplate.find(params[:id]) }
|
|
8
|
+
|
|
9
|
+
def index = @templates = HiringTemplate.alphabetical
|
|
10
|
+
def new = @template = HiringTemplate.new
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
@template = HiringTemplate.new(template_params)
|
|
14
|
+
return render(:new, status: :unprocessable_entity) unless @template.save
|
|
15
|
+
|
|
16
|
+
redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def edit = load_preview
|
|
20
|
+
|
|
21
|
+
def update
|
|
22
|
+
return load_preview && render(:edit, status: :unprocessable_entity) unless @template.update(template_params)
|
|
23
|
+
|
|
24
|
+
redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def load_preview
|
|
30
|
+
@sample = HiringCandidate.includes(:campaign, :interviews).order(updated_at: :desc).limit(200).to_a
|
|
31
|
+
@preview_for = @sample.find { |c| c.id == params[:candidate_id].to_i } || @sample.first
|
|
32
|
+
return true unless @preview_for
|
|
33
|
+
|
|
34
|
+
sender_name = HrLite.config.hiring&.sender&.dig(:name)
|
|
35
|
+
@preview = HiringMerge.new(@preview_for, sender_name:).render(@template)
|
|
36
|
+
# Readiness across the previewed candidate's campaign: who a send would reach.
|
|
37
|
+
@campaign_size = @preview_for.campaign.candidates.count
|
|
38
|
+
@ready = @preview_for.campaign.candidates.includes(:campaign, :interviews).count { |c| HiringMerge.new(c, sender_name:).render(@template).missing.empty? }
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def template_params = params.require(:hiring_template).permit(:name, :subject, :body)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -20,8 +20,8 @@ module HrLite
|
|
|
20
20
|
assignee = HrLite.user_klass.find(params[:assignee_id])
|
|
21
21
|
request.assign!(actor: hr_current_user, assignee: assignee)
|
|
22
22
|
redirect_to admin_hr_request_path(request), notice: "Assigned to #{HrLite.display_name(assignee)}."
|
|
23
|
-
rescue ActiveRecord::RecordInvalid
|
|
24
|
-
redirect_to admin_hr_request_path(request), alert: "This request is already settled."
|
|
23
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
24
|
+
redirect_to admin_hr_request_path(request), alert: reason_for(e, "This request is already settled.")
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def resolve
|
|
@@ -30,8 +30,8 @@ module HrLite
|
|
|
30
30
|
redirect_to admin_hr_requests_path, notice: "Answered."
|
|
31
31
|
rescue ArgumentError
|
|
32
32
|
redirect_to admin_hr_request_path(request), alert: "Write an answer before resolving."
|
|
33
|
-
rescue ActiveRecord::RecordInvalid
|
|
34
|
-
redirect_to admin_hr_request_path(request), alert: "This request is already settled."
|
|
33
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
34
|
+
redirect_to admin_hr_request_path(request), alert: reason_for(e, "This request is already settled.")
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
private
|