hr_lite 0.61.0 → 0.62.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 +9 -0
- data/README.md +4 -0
- data/app/assets/javascripts/hr_lite/select_all.js +21 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +6 -0
- data/app/controllers/hr_lite/admin/hiring_campaigns_controller.rb +10 -2
- data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +23 -0
- data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +26 -0
- data/app/controllers/hr_lite/hiring_replies_controller.rb +32 -0
- data/app/models/hr_lite/hiring_campaign.rb +3 -1
- data/app/models/hr_lite/hiring_candidate.rb +6 -2
- data/app/models/hr_lite/hiring_reply.rb +144 -0
- data/app/services/hr_lite/hiring_merge.rb +9 -3
- data/app/views/hr_lite/admin/hiring_campaigns/_form.html.erb +1 -0
- data/app/views/hr_lite/admin/hiring_campaigns/show.html.erb +10 -2
- data/app/views/hr_lite/admin/hiring_candidates/show.html.erb +43 -2
- data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +5 -1
- data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +3 -0
- data/app/views/hr_lite/hiring_replies/show.html.erb +105 -0
- data/app/views/layouts/hr_lite/application.html.erb +1 -0
- data/app/views/layouts/hr_lite/public.html.erb +21 -0
- data/config/routes.rb +5 -1
- data/db/migrate/20261022090000_create_hr_lite_hiring_replies.rb +26 -0
- data/db/migrate/20261022090100_add_allow_sunday_to_hr_lite_hiring_campaigns.rb +6 -0
- data/lib/hr_lite/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f779cdbd416cb664617888bbb71b2c6866742b52e3ad5525ac2c5819a9b9908
|
|
4
|
+
data.tar.gz: 346563b5b820eabc5aec4d83076cb1a3dd37f6309c159ad15c7a3f70698032a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 682e1b50a140c01d357a77666d721629e713dc8c1f97465ea8943c92b8d0cee5a5e47933c98f9451d5e80019bdc548879a27f1c72002279a19ab051500873ce6
|
|
7
|
+
data.tar.gz: 57fa8aa33593a9f4ac0d88abf82dcc47dd32f666872924038ffb1de4754cb981312fec76e23fcd9706bc31b2aa75a3dc40a42bd25b8d4c947d26705b2f356f2c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.62.0] - 2026-10-22
|
|
11
|
+
|
|
12
|
+
Hiring replies per round, an AI draft button and the sender's signature. Two migrations: a new `hr_lite_hiring_replies` table, and `allow_sunday` on campaigns.
|
|
13
|
+
|
|
14
|
+
- **Reply links per round.** `@round1_link`, `@round2_link` and `@round3_link` each open the candidate's own `/apply/:token` page for that round, with no login, one reply per link. Every round asks for one of the next ten interview days and one 2-hour slot (10–12, 12–2, 2–4, 4–6, 6–8; at most 4 candidates per slot per day), plus an optional alternate phone. Round 1 also asks for interest, joining time and four yes/no questions. Round 2 asks for a CV and photo ID. Round 3 collects the joining details: marksheets, an Aadhaar number and card, and a PAN number and card. A candidate without a PAN can tick "I don't have a PAN yet", and HR sees "PAN pending". Salary slips for the last 3 months are optional; a fresher ticks "I have no salary slips". A consent box is required. Documents must be PDF, JPG or PNG, checked from the file's bytes, at most 5 MB each. They are encrypted before they reach storage, so a leaked blob is unreadable, and HR downloads them only through an audit-logged route that decrypts them. Aadhaar and PAN numbers are encrypted. HR sees them masked (XXXX XXXX 1234, XXXXX1234X), and "Show full" is an audit-logged POST that shows the number once. Sunday is off unless the campaign ticks "Allow Sunday interviews". A reply moves round 1 candidates to interested or not interested, goes on the timeline, notifies the HR sender, and prefills "Schedule round N". The campaign page shows linked count tiles: invited, replied, interested, and booked for rounds 1, 2 and 3.
|
|
15
|
+
- **Write with AI** on the template form, when the host adds `config.hiring.draft_email(text:, placeholders:)`. It rewrites what HR typed into a template name (if empty), subject and message that use only known @fields, and saves nothing. Output with an unknown field is refused. It is limited to 20 drafts an hour per person and is audit-logged. It posts the same form with `ai=1`, because `formaction` would break per-form CSRF tokens.
|
|
16
|
+
- **Signature preview.** The template preview shows `config.hiring.signature` (sanitized) when the host provides it.
|
|
17
|
+
- **Select all ticks the rows.** "All N in this filter" now ticks and unticks every row, through `select_all.js`.
|
|
18
|
+
|
|
10
19
|
## [0.61.0] - 2026-10-21
|
|
11
20
|
|
|
12
21
|
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.
|
data/README.md
CHANGED
|
@@ -386,8 +386,12 @@ send_email(to:, subject:, body:) # => { message_id:, thread_id: }
|
|
|
386
386
|
create_event(summary:, description:, attendee:, starts_at:, ends_at:) # => { event_id:, meet_link: }
|
|
387
387
|
update_event(event_id:, starts_at:, ends_at:)
|
|
388
388
|
cancel_event(event_id:)
|
|
389
|
+
draft_email(text:, placeholders:) # optional => { subject:, body: } for "Write with AI"
|
|
390
|
+
signature # optional => the sender's HTML signature, for the preview
|
|
389
391
|
```
|
|
390
392
|
|
|
393
|
+
`@round1_link`, `@round2_link` and `@round3_link` point each candidate at their own `/apply/:token` page for that round, on `public_url_base`. The page needs no login. Round 1 asks for interest, joining time and four yes/no questions. Every round asks for a day, one 2-hour slot and an optional alternate phone. Rounds 2 and 3 ask for documents. Sunday is offered only when the campaign allows it.
|
|
394
|
+
|
|
391
395
|
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
396
|
|
|
393
397
|
## Recurring jobs
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// "Select all" for a list of checkboxes in one form.
|
|
2
|
+
// Markup contract: <input type="checkbox" data-hrl-select-all="candidate_ids[]">.
|
|
3
|
+
// Ticking it ticks every box of that name in the same form; unticking any row
|
|
4
|
+
// unticks it again. The server still reads the box itself, so it works without JS.
|
|
5
|
+
(function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
document.addEventListener("change", function (event) {
|
|
9
|
+
var box = event.target;
|
|
10
|
+
var form = box.form;
|
|
11
|
+
if (!form) return;
|
|
12
|
+
var all = form.querySelector("[data-hrl-select-all]");
|
|
13
|
+
if (!all) return;
|
|
14
|
+
var rows = form.querySelectorAll('input[name="' + all.getAttribute("data-hrl-select-all") + '"]');
|
|
15
|
+
if (box === all) {
|
|
16
|
+
rows.forEach(function (row) { row.checked = all.checked; });
|
|
17
|
+
} else if (box.name === all.getAttribute("data-hrl-select-all") && !box.checked) {
|
|
18
|
+
all.checked = false;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
})();
|
|
@@ -673,3 +673,9 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
|
|
|
673
673
|
.hrl-steps .is-now .hrl-steps__dot { background: var(--hrl-accent); border-color: var(--hrl-accent); color: var(--hrl-accent-ink); }
|
|
674
674
|
.hrl-steps .is-now { font-weight: 700; color: var(--hrl-accent); }
|
|
675
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; }
|
|
676
|
+
/* The candidate reply page: no staff shell, one readable column. */
|
|
677
|
+
.hrl-public { max-width: 40rem; margin: 0 auto; padding: var(--hrl-s-6) var(--hrl-gutter) calc(var(--hrl-s-10) + env(safe-area-inset-bottom)); }
|
|
678
|
+
.hrl-hiring-signature { margin-top: var(--hrl-s-3); padding-top: var(--hrl-s-3); border-top: 1px dashed var(--hrl-line); font-size: var(--hrl-t-small); overflow-wrap: anywhere; }
|
|
679
|
+
.hrl-public__logo { display: block; height: 40px; width: auto; margin-bottom: var(--hrl-s-4); }
|
|
680
|
+
.hrl-kpi--link { display: block; min-height: var(--hrl-target); color: inherit; text-decoration: none; }
|
|
681
|
+
.hrl-kpi--link:hover { border-color: var(--hrl-accent); }
|
|
@@ -2,7 +2,7 @@ module HrLite
|
|
|
2
2
|
module Admin
|
|
3
3
|
# Hiring drives: the list, one campaign's pipeline, and its sheet import.
|
|
4
4
|
class HiringCampaignsController < ApplicationController
|
|
5
|
-
FIELDS = %i[title position programme stipend ctc location schedule status].freeze
|
|
5
|
+
FIELDS = %i[title position programme stipend ctc location schedule status allow_sunday].freeze
|
|
6
6
|
|
|
7
7
|
before_action { hr_require_permission!("hiring.manage", scope: :all) }
|
|
8
8
|
before_action :set_campaign, except: %i[index new create]
|
|
@@ -15,9 +15,17 @@ module HrLite
|
|
|
15
15
|
def show
|
|
16
16
|
@stage = params[:stage].presence_in(HiringCandidate::STAGES)
|
|
17
17
|
@campus = params[:campus].presence
|
|
18
|
+
@replied = params[:replied].presence
|
|
19
|
+
@round = params[:round].to_i.presence_in([ 1, 2, 3 ])
|
|
18
20
|
@stage_counts = @campaign.candidates.group(:stage).count
|
|
21
|
+
replies = HiringReply.responded.where(candidate_id: @campaign.candidates.select(:id))
|
|
22
|
+
# Each tile's count is exactly what its link lists.
|
|
23
|
+
@kpis = { "Awaiting reply" => [ @stage_counts["invited"].to_i, { stage: "invited" } ],
|
|
24
|
+
"Replied" => [ replies.distinct.count(:candidate_id), { replied: 1 } ],
|
|
25
|
+
"Interested" => [ @stage_counts["interested"].to_i, { stage: "interested" } ] }
|
|
26
|
+
(1..3).each { |r| @kpis["Booked round #{r}"] = [ replies.where(round: r).where.not(preferred_slot: nil).count, { round: r } ] }
|
|
19
27
|
@campuses = @campaign.candidates.where.not(campus: [ nil, "" ]).distinct.order(:campus).pluck(:campus)
|
|
20
|
-
@candidates = paginate(@campaign.pipeline(stage: @stage, campus: @campus))
|
|
28
|
+
@candidates = paginate(@campaign.pipeline(stage: @stage, campus: @campus, replied: @replied, round: @round))
|
|
21
29
|
@templates = HiringTemplate.alphabetical
|
|
22
30
|
@attention = HiringMessage.joins(:candidate).where(hr_lite_hiring_candidates: { campaign_id: @campaign.id })
|
|
23
31
|
.where(status: %w[failed unknown sending]).includes(:candidate, :template).limit(50)
|
|
@@ -13,6 +13,29 @@ module HrLite
|
|
|
13
13
|
@messages = @candidate.messages.includes(:template).order(:created_at)
|
|
14
14
|
@interviews = @candidate.interviews.order(:round, :created_at)
|
|
15
15
|
@timeline = @candidate.timeline.limit(100)
|
|
16
|
+
@replies = @candidate.replies.responded.order(:round).with_attached_documents
|
|
17
|
+
next_round = [ @candidate.round, *@interviews.select { |i| %w[scheduled unknown].include?(i.status) }.map(&:round) ].max + 1
|
|
18
|
+
@next_reply = @replies.find { |r| r.round == next_round && r.preferred_slot }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A document from the candidate's reply, streamed only to hiring.manage and audit-logged.
|
|
22
|
+
def document
|
|
23
|
+
attachment = ActiveStorage::Attachment.find_by!(id: params[:attachment_id], record_type: "HrLite::HiringReply",
|
|
24
|
+
record_id: @candidate.replies.select(:id))
|
|
25
|
+
AuditLog.record!(action: "hiring.document_viewed", subject: @candidate, changes: { "attachment" => attachment.id })
|
|
26
|
+
bytes = attachment.record.read_document(attachment)
|
|
27
|
+
send_data bytes, filename: attachment.filename.to_s.split("--", 2).last, disposition: "attachment",
|
|
28
|
+
type: Marcel::MimeType.for(StringIO.new(bytes), name: attachment.filename.to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The full Aadhaar or PAN, shown once on this render, audit-logged; never stored in the session.
|
|
32
|
+
def reveal
|
|
33
|
+
reply = @candidate.replies.find(params[:reply_id])
|
|
34
|
+
field = params[:field].presence_in(%w[aadhaar pan]) or raise ActiveRecord::RecordNotFound
|
|
35
|
+
AuditLog.record!(action: "hiring.kyc_revealed", subject: @candidate, changes: { "field" => field, "round" => reply.round })
|
|
36
|
+
@revealed = { reply.id => { field => (field == "aadhaar" ? reply.aadhaar_number : reply.pan_number) } }
|
|
37
|
+
show
|
|
38
|
+
render :show
|
|
16
39
|
end
|
|
17
40
|
|
|
18
41
|
def stage
|
|
@@ -48,7 +48,7 @@ module HrLite
|
|
|
48
48
|
@campaign = HiringCampaign.find(params[:hiring_campaign_id])
|
|
49
49
|
@template = HiringTemplate.find(params[:template_id])
|
|
50
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]))
|
|
51
|
+
scope = params[:all].present? ? @campaign.pipeline(stage: params[:stage], campus: params[:campus], replied: params[:replied], round: params[:round].to_i.presence_in([ 1, 2, 3 ])) : @campaign.pipeline.where(id: Array(params[:candidate_ids]))
|
|
52
52
|
already = HiringMessage.where(template: @template, candidate_id: scope.select(:id)).pluck(:candidate_id).to_set
|
|
53
53
|
@recipients, @left_out = [], []
|
|
54
54
|
scope.includes(:campaign, :interviews).each do |candidate|
|
|
@@ -5,12 +5,16 @@ module HrLite
|
|
|
5
5
|
class HiringTemplatesController < ApplicationController
|
|
6
6
|
before_action { hr_require_permission!("hiring.manage", scope: :all) }
|
|
7
7
|
before_action(only: %i[edit update]) { @template = HiringTemplate.find(params[:id]) }
|
|
8
|
+
# "Write with AI" posts the same form with ai=1 (formaction would break per-form CSRF tokens).
|
|
9
|
+
rate_limit to: 20, within: 1.hour, only: %i[create update], if: -> { params[:ai].present? }, name: "hiring_ai_draft", by: -> { hr_current_user.id },
|
|
10
|
+
with: -> { redirect_to hr_lite.admin_hiring_templates_path, alert: "Twenty AI drafts an hour is the limit. Try again later." }
|
|
8
11
|
|
|
9
12
|
def index = @templates = HiringTemplate.alphabetical
|
|
10
13
|
def new = @template = HiringTemplate.new
|
|
11
14
|
|
|
12
15
|
def create
|
|
13
16
|
@template = HiringTemplate.new(template_params)
|
|
17
|
+
return draft if params[:ai].present?
|
|
14
18
|
return render(:new, status: :unprocessable_entity) unless @template.save
|
|
15
19
|
|
|
16
20
|
redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
|
|
@@ -19,6 +23,7 @@ module HrLite
|
|
|
19
23
|
def edit = load_preview
|
|
20
24
|
|
|
21
25
|
def update
|
|
26
|
+
return draft if params[:ai].present?
|
|
22
27
|
return load_preview && render(:edit, status: :unprocessable_entity) unless @template.update(template_params)
|
|
23
28
|
|
|
24
29
|
redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
|
|
@@ -26,6 +31,27 @@ module HrLite
|
|
|
26
31
|
|
|
27
32
|
private
|
|
28
33
|
|
|
34
|
+
# "Write with AI": rewrites what HR typed into a clean email that uses
|
|
35
|
+
# only known @fields. Nothing is saved; HR reviews it, then saves.
|
|
36
|
+
def draft
|
|
37
|
+
@template.assign_attributes(template_params)
|
|
38
|
+
apply_ai_draft
|
|
39
|
+
@template.persisted? ? load_preview && render(:edit) : render(:new)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def apply_ai_draft
|
|
43
|
+
out = HrLite.config.hiring.draft_email(text: "Subject: #{@template.subject}\n\n#{@template.body}", placeholders: HiringMerge::RESERVED)
|
|
44
|
+
unknown = HiringMerge.tokens("#{out[:subject]} #{out[:body]}") - HiringMerge::RESERVED
|
|
45
|
+
raise HrLite::HiringDeliveryFailed, "it used unknown fields (#{unknown.map { |t| "@#{t}" }.join(', ')})" if unknown.any?
|
|
46
|
+
|
|
47
|
+
@template.assign_attributes(subject: out[:subject], body: out[:body])
|
|
48
|
+
@template.name = out[:name] if @template.name.blank? && out[:name].present?
|
|
49
|
+
AuditLog.record!(action: "hiring.ai_draft", subject: @template)
|
|
50
|
+
flash.now[:notice] = "AI draft ready. Check it, then save."
|
|
51
|
+
rescue HrLite::HiringDeliveryFailed, HrLite::HiringDeliveryUnknown => e
|
|
52
|
+
flash.now[:alert] = "AI could not draft this: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
29
55
|
def load_preview
|
|
30
56
|
@sample = HiringCandidate.includes(:campaign, :interviews).order(updated_at: :desc).limit(200).to_a
|
|
31
57
|
@preview_for = @sample.find { |c| c.id == params[:candidate_id].to_i } || @sample.first
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The candidate's own page for one interview round, from @round1_link …
|
|
3
|
+
# @round3_link: their day and slot, round 1's interest questions, and from
|
|
4
|
+
# round 2 their documents. One reply per link.
|
|
5
|
+
class HiringRepliesController < ApplicationController
|
|
6
|
+
# WHY no login: candidates have no account. The unguessable 24-character
|
|
7
|
+
# token in their link is the key, and it opens only that one reply.
|
|
8
|
+
skip_before_action :hr_authenticate!
|
|
9
|
+
skip_before_action HrLite.config.authenticate_method, raise: false
|
|
10
|
+
layout "hr_lite/public"
|
|
11
|
+
rate_limit to: 10, within: 1.hour, only: :create, name: "hiring_reply",
|
|
12
|
+
with: -> { redirect_to hr_lite.hiring_reply_path(params[:token]), alert: "Too many tries. Please try again in an hour." }
|
|
13
|
+
|
|
14
|
+
before_action do
|
|
15
|
+
@reply = HiringReply.includes(candidate: :campaign).find_by!(token: params[:token].to_s)
|
|
16
|
+
@candidate = @reply.candidate
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show; end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
@reply.submit!(interested: params[:interested].to_s, date: params[:date], slot: params[:slot], joining: params[:joining],
|
|
23
|
+
yes_no: params.permit(*HiringReply::YES_NO.keys).to_h, alternate_phone: params[:alternate_phone].to_s.strip,
|
|
24
|
+
files: params.fetch(:documents, {}).permit(*HiringReply::DOCUMENTS.fetch(@reply.round, {}).keys).to_h,
|
|
25
|
+
kyc: params.permit(:aadhaar_number, :pan_number, :pan_pending, :no_salary_slips, :consent).to_h.symbolize_keys)
|
|
26
|
+
redirect_to hr_lite.hiring_reply_path(@reply.token)
|
|
27
|
+
rescue ArgumentError => e
|
|
28
|
+
flash.now[:alert] = e.message
|
|
29
|
+
render :show, status: :unprocessable_entity
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -11,8 +11,10 @@ module HrLite
|
|
|
11
11
|
scope :newest_first, -> { order(created_at: :desc) }
|
|
12
12
|
|
|
13
13
|
# The pipeline list and "select all" on it are the same filter.
|
|
14
|
-
def pipeline(stage: nil, campus: nil)
|
|
14
|
+
def pipeline(stage: nil, campus: nil, replied: nil, round: nil)
|
|
15
15
|
scope = candidates
|
|
16
|
+
scope = scope.where(id: HiringReply.responded.select(:candidate_id)) if replied.present?
|
|
17
|
+
scope = scope.where(id: HiringReply.responded.where(round: round).where.not(preferred_slot: nil).select(:candidate_id)) if round.present?
|
|
16
18
|
scope = scope.where(stage: stage) if stage.presence_in(HiringCandidate::STAGES)
|
|
17
19
|
scope = scope.where(campus: campus) if campus.present?
|
|
18
20
|
scope.order(:name, :id)
|
|
@@ -19,6 +19,7 @@ module HrLite
|
|
|
19
19
|
belongs_to :campaign, class_name: "HrLite::HiringCampaign", inverse_of: :candidates
|
|
20
20
|
has_many :messages, class_name: "HrLite::HiringMessage", foreign_key: :candidate_id, dependent: :restrict_with_error
|
|
21
21
|
has_many :interviews, class_name: "HrLite::HiringInterview", foreign_key: :candidate_id, dependent: :restrict_with_error
|
|
22
|
+
has_many :replies, class_name: "HrLite::HiringReply", foreign_key: :candidate_id, inverse_of: :candidate, dependent: :restrict_with_error
|
|
22
23
|
|
|
23
24
|
encrypts :phone, :date_of_birth
|
|
24
25
|
normalizes :email, with: ->(email) { email.to_s.strip.downcase }
|
|
@@ -33,7 +34,7 @@ module HrLite
|
|
|
33
34
|
|
|
34
35
|
# The one door for a stage change: refuses a move the pipeline does not
|
|
35
36
|
# 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
|
+
def move_to!(to, reason: nil, note: nil, round: self.round, actor: HrLite::Current.actor)
|
|
37
38
|
raise ArgumentError, "#{stage_label} cannot move to #{to.humanize}." unless MOVES.fetch(stage).include?(to)
|
|
38
39
|
raise ArgumentError, "Pick a rejection reason." if to == "rejected" && !REJECTION_REASONS.include?(reason)
|
|
39
40
|
raise ArgumentError, "Write a note for “Other”." if reason == "Other" && note.blank?
|
|
@@ -41,10 +42,13 @@ module HrLite
|
|
|
41
42
|
transaction do
|
|
42
43
|
from = stage
|
|
43
44
|
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
|
+
log!("stage", actor: actor, from: from, to: to, reason: (reason if to == "rejected"))
|
|
45
46
|
end
|
|
46
47
|
end
|
|
47
48
|
|
|
49
|
+
# This round's reply link, made on first use (a template that names it).
|
|
50
|
+
def reply_for(round) = replies.find_or_create_by!(round: round)
|
|
51
|
+
|
|
48
52
|
def log!(action, actor: HrLite::Current.actor, **changes)
|
|
49
53
|
AuditLog.record!(action: "hiring.#{action}", subject: self, actor: actor, changes: changes.compact)
|
|
50
54
|
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A candidate's reply link for one interview round (@round1_link … @round3_link):
|
|
3
|
+
# the day and 2-hour slot they can do, an optional alternate phone, round 1's
|
|
4
|
+
# interest questions, and from round 2 their documents. One reply per link.
|
|
5
|
+
class HiringReply < ApplicationRecord
|
|
6
|
+
SLOTS = { "10-12" => "10 AM – 12 PM", "12-2" => "12 – 2 PM", "2-4" => "2 – 4 PM", "4-6" => "4 – 6 PM", "6-8" => "6 – 8 PM" }.freeze
|
|
7
|
+
SLOT_START = { "10-12" => "10:00", "12-2" => "12:00", "2-4" => "14:00", "4-6" => "16:00", "6-8" => "18:00" }.freeze
|
|
8
|
+
SLOT_CAPACITY = 4 # candidates per slot per day; HR calls each to confirm
|
|
9
|
+
JOINING = { "immediately" => "Immediately", "two_weeks" => "Within 2 weeks", "one_month" => "Within a month" }.freeze
|
|
10
|
+
YES_NO = { "stipend_ok" => "Fine with the stipend", "location_ok" => "Fine with the office location",
|
|
11
|
+
"schedule_ok" => "Fine with the schedule", "programme_ok" => "Commits to the programme" }.freeze
|
|
12
|
+
DOCUMENTS = { 2 => { "cv" => "CV", "photo_id" => "Photo ID" },
|
|
13
|
+
3 => { "marksheets" => "Marksheets", "aadhaar" => "Aadhaar card", "pan" => "PAN card",
|
|
14
|
+
"salary_slip_1" => "Salary slip (last month)", "salary_slip_2" => "Salary slip (2 months ago)",
|
|
15
|
+
"salary_slip_3" => "Salary slip (3 months ago)" } }.freeze
|
|
16
|
+
PAN_FORMAT = /\A[A-Z]{5}[0-9]{4}[A-Z]\z/
|
|
17
|
+
# Verhoeff tables, for the Aadhaar check digit.
|
|
18
|
+
VERHOEFF_D = [ [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], [ 1, 2, 3, 4, 0, 6, 7, 8, 9, 5 ], [ 2, 3, 4, 0, 1, 7, 8, 9, 5, 6 ],
|
|
19
|
+
[ 3, 4, 0, 1, 2, 8, 9, 5, 6, 7 ], [ 4, 0, 1, 2, 3, 9, 5, 6, 7, 8 ], [ 5, 9, 8, 7, 6, 0, 4, 3, 2, 1 ],
|
|
20
|
+
[ 6, 5, 9, 8, 7, 1, 0, 4, 3, 2 ], [ 7, 6, 5, 9, 8, 2, 1, 0, 4, 3 ], [ 8, 7, 6, 5, 9, 3, 2, 1, 0, 4 ],
|
|
21
|
+
[ 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 ] ].freeze
|
|
22
|
+
VERHOEFF_P = [ [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], [ 1, 5, 7, 6, 2, 8, 3, 0, 9, 4 ], [ 5, 8, 0, 3, 7, 9, 6, 1, 4, 2 ],
|
|
23
|
+
[ 8, 9, 1, 6, 0, 4, 3, 5, 2, 7 ], [ 9, 4, 5, 3, 1, 2, 6, 8, 7, 0 ], [ 4, 2, 8, 6, 5, 7, 3, 9, 0, 1 ],
|
|
24
|
+
[ 2, 7, 9, 3, 8, 0, 6, 4, 1, 5 ], [ 7, 0, 4, 6, 9, 1, 3, 2, 5, 8 ] ].freeze
|
|
25
|
+
DOCUMENT_TYPES = %w[application/pdf image/jpeg image/png].freeze
|
|
26
|
+
DOCUMENT_MAX = 5.megabytes
|
|
27
|
+
|
|
28
|
+
belongs_to :candidate, class_name: "HrLite::HiringCandidate", inverse_of: :replies
|
|
29
|
+
has_many_attached :documents
|
|
30
|
+
encrypts :alternate_phone, :aadhaar_number, :pan_number
|
|
31
|
+
has_secure_token :token, length: 24
|
|
32
|
+
|
|
33
|
+
validates :round, inclusion: { in: 1..3 }
|
|
34
|
+
|
|
35
|
+
scope :responded, -> { where.not(responded_at: nil) }
|
|
36
|
+
|
|
37
|
+
def self.slot_full?(date, slot) = responded.where(preferred_date: date, preferred_slot: slot).count >= SLOT_CAPACITY
|
|
38
|
+
|
|
39
|
+
# The next ten interview days. Sunday is off unless the campaign allows it.
|
|
40
|
+
def self.dates(allow_sunday:, from: Date.current) = (1..20).map { |i| from + i }.reject { |d| d.sunday? && !allow_sunday }.first(10)
|
|
41
|
+
|
|
42
|
+
def self.aadhaar_valid?(number)
|
|
43
|
+
number.to_s.match?(/\A[2-9]\d{11}\z/) &&
|
|
44
|
+
number.reverse.chars.each_with_index.reduce(0) { |c, (digit, i)| VERHOEFF_D[c][VERHOEFF_P[i % 8][digit.to_i]] }.zero?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# What this round asks for; round 3 skips PAN and slips the candidate says they lack.
|
|
48
|
+
def documents_asked
|
|
49
|
+
asked = DOCUMENTS.fetch(round, {})
|
|
50
|
+
asked = asked.except("pan") if pan_pending
|
|
51
|
+
asked = asked.except("salary_slip_1", "salary_slip_2", "salary_slip_3") if no_salary_slips
|
|
52
|
+
asked
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def masked_aadhaar = aadhaar_number && "XXXX XXXX #{aadhaar_number.last(4)}"
|
|
56
|
+
def masked_pan = pan_number && "XXXXX#{pan_number[5, 4]}X"
|
|
57
|
+
|
|
58
|
+
# Documents are stored encrypted; this is the only way back to the bytes.
|
|
59
|
+
def read_document(attachment) = ActiveRecord::Encryption.encryptor.decrypt(attachment.download)
|
|
60
|
+
def document_label(attachment) = documents_asked[attachment.filename.to_s.split("--").first] || "Document"
|
|
61
|
+
|
|
62
|
+
# Saves the reply, moves round 1's candidate to interested / not interested,
|
|
63
|
+
# puts it on their timeline and rings the HR person who emailed them.
|
|
64
|
+
def submit!(interested: nil, date: nil, slot: nil, joining: nil, yes_no: {}, alternate_phone: nil, files: {}, kyc: {})
|
|
65
|
+
raise ArgumentError, "You have already replied. Thank you." if responded_at
|
|
66
|
+
|
|
67
|
+
assign_kyc!(kyc) if round == 3
|
|
68
|
+
|
|
69
|
+
declined = round == 1 && interested == "no"
|
|
70
|
+
raise ArgumentError, "Tell us whether you want to go ahead." if round == 1 && !%w[yes no].include?(interested)
|
|
71
|
+
|
|
72
|
+
answers = round == 1 ? { "interested" => interested } : {}
|
|
73
|
+
unless declined
|
|
74
|
+
date = self.class.dates(allow_sunday: candidate.campaign.allow_sunday).find { |day| day.iso8601 == date.to_s }
|
|
75
|
+
raise ArgumentError, "Pick a day for the interview." unless date
|
|
76
|
+
raise ArgumentError, "Pick a time slot." unless SLOTS.key?(slot)
|
|
77
|
+
raise ArgumentError, "That slot just filled up. Pick another one." if self.class.slot_full?(date, slot)
|
|
78
|
+
raise ArgumentError, "That phone number does not look right." if alternate_phone.present? && !alternate_phone.to_s.gsub(/[\s-]/, "").match?(/\A\+?\d{7,15}\z/)
|
|
79
|
+
|
|
80
|
+
if round == 1
|
|
81
|
+
raise ArgumentError, "Tell us how soon you can join." unless JOINING.key?(joining)
|
|
82
|
+
|
|
83
|
+
answers.merge!("joining" => joining, **YES_NO.keys.to_h { |key| [ key, yes_no[key] == "yes" ? "yes" : "no" ] })
|
|
84
|
+
end
|
|
85
|
+
check_files!(files)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
transaction do
|
|
89
|
+
update!(responded_at: Time.current, answers: answers, alternate_phone: alternate_phone.presence,
|
|
90
|
+
preferred_date: (date unless declined), preferred_slot: (slot unless declined))
|
|
91
|
+
files.slice(*documents_asked.keys).each do |kind, file|
|
|
92
|
+
# Encrypted before it reaches storage: a leaked blob is ciphertext.
|
|
93
|
+
cipher = ActiveRecord::Encryption.encryptor.encrypt(file.tempfile.read.b)
|
|
94
|
+
documents.attach(io: StringIO.new(cipher), filename: "#{kind}--#{file.original_filename}", content_type: "application/octet-stream",
|
|
95
|
+
identify: false)
|
|
96
|
+
end
|
|
97
|
+
if round == 1
|
|
98
|
+
to = declined ? "not_interested" : "interested"
|
|
99
|
+
candidate.move_to!(to, actor: nil) if HiringCandidate::MOVES.fetch(candidate.stage).include?(to)
|
|
100
|
+
end
|
|
101
|
+
candidate.log!("replied", actor: nil, round: round, interested: answers["interested"],
|
|
102
|
+
slot: ([ date.strftime("%-d %b"), SLOTS[slot] ].join(", ") unless declined))
|
|
103
|
+
end
|
|
104
|
+
notify_sender
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Round 3's joining KYC. Errors name the field, never the number.
|
|
110
|
+
def assign_kyc!(kyc)
|
|
111
|
+
raise ArgumentError, "Tick the consent box to share your documents." unless kyc[:consent] == "1"
|
|
112
|
+
|
|
113
|
+
aadhaar = kyc[:aadhaar_number].to_s.gsub(/\s/, "")
|
|
114
|
+
raise ArgumentError, "That Aadhaar number does not look right." unless self.class.aadhaar_valid?(aadhaar)
|
|
115
|
+
|
|
116
|
+
self.pan_pending = kyc[:pan_pending] == "1"
|
|
117
|
+
pan = kyc[:pan_number].to_s.strip.upcase
|
|
118
|
+
raise ArgumentError, "That PAN does not look right." unless pan_pending || pan.match?(PAN_FORMAT)
|
|
119
|
+
|
|
120
|
+
self.no_salary_slips = kyc[:no_salary_slips] == "1"
|
|
121
|
+
self.aadhaar_number = aadhaar
|
|
122
|
+
self.pan_number = (pan unless pan_pending)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Every asked document, a real PDF/JPG/PNG (read from the bytes, not the
|
|
126
|
+
# browser's claim), at most 5 MB.
|
|
127
|
+
def check_files!(files)
|
|
128
|
+
documents_asked.each do |kind, label|
|
|
129
|
+
file = files[kind]
|
|
130
|
+
raise ArgumentError, "Attach your #{label}." unless file.respond_to?(:original_filename)
|
|
131
|
+
raise ArgumentError, "#{label} must be a PDF, JPG or PNG." unless DOCUMENT_TYPES.include?(sniff(file))
|
|
132
|
+
raise ArgumentError, "#{label} must be 5 MB or smaller." if file.size > DOCUMENT_MAX
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def sniff(file) = Marcel::MimeType.for(file.tempfile, name: file.original_filename).tap { file.tempfile.rewind }
|
|
137
|
+
|
|
138
|
+
def notify_sender
|
|
139
|
+
user = candidate.messages.where(status: "sent").order(:sent_at).last&.sent_by or return
|
|
140
|
+
HrLite.notify(user: user, kind: "hiring.reply", body: candidate.campaign.title, path: "/admin/hiring_candidates/#{candidate_id}",
|
|
141
|
+
title: "#{candidate.name} replied to round #{round}#{": #{answers['interested'] == 'yes' ? 'interested' : 'not interested'}" if round == 1}")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -6,11 +6,14 @@ module HrLite
|
|
|
6
6
|
TOKEN = /(?<![\w.@])@([a-z][a-z0-9_]*)/
|
|
7
7
|
GROUPS = { "Candidate" => %w[first_name name course branch campus],
|
|
8
8
|
"Offer" => %w[position programme stipend ctc location schedule],
|
|
9
|
-
"Interview" => %w[interview_date interview_time meet_link sender_name]
|
|
9
|
+
"Interview" => %w[interview_date interview_time meet_link sender_name],
|
|
10
|
+
"Reply links" => %w[round1_link round2_link round3_link] }.freeze
|
|
10
11
|
RESERVED = GROUPS.values.flatten.freeze
|
|
11
12
|
|
|
12
13
|
Result = Data.define(:subject, :body, :missing)
|
|
13
14
|
|
|
15
|
+
def self.tokens(text) = text.to_s.scan(TOKEN).flatten.uniq
|
|
16
|
+
|
|
14
17
|
def initialize(candidate, sender_name:)
|
|
15
18
|
@extra = candidate.extra.to_h.transform_keys(&:to_s)
|
|
16
19
|
@values = @extra.merge(reserved(candidate, sender_name))
|
|
@@ -21,7 +24,8 @@ module HrLite
|
|
|
21
24
|
fill = ->(text) {
|
|
22
25
|
text.to_s.gsub(TOKEN) do
|
|
23
26
|
key = Regexp.last_match(1)
|
|
24
|
-
value = @values[key]
|
|
27
|
+
value = @values[key]
|
|
28
|
+
value = (value.respond_to?(:call) ? value.call : value).to_s.strip
|
|
25
29
|
next value unless value.empty?
|
|
26
30
|
|
|
27
31
|
missing << "@#{key} (#{RESERVED.include?(key) || @extra.key?(key) ? 'empty' : 'unknown'})"
|
|
@@ -42,7 +46,9 @@ module HrLite
|
|
|
42
46
|
"programme" => campaign.programme, "stipend" => campaign.stipend, "ctc" => campaign.ctc,
|
|
43
47
|
"location" => campaign.location, "schedule" => campaign.schedule,
|
|
44
48
|
"interview_date" => at&.strftime("%-d %B %Y"), "interview_time" => at&.strftime("%-l:%M %p"),
|
|
45
|
-
"meet_link" => interview&.meet_link, "sender_name" => sender_name
|
|
49
|
+
"meet_link" => interview&.meet_link, "sender_name" => sender_name,
|
|
50
|
+
# Lazy: a round's link (and its row) is made only when a template names it.
|
|
51
|
+
**(1..3).to_h { |r| [ "round#{r}_link", -> { HrLite.public_url("/apply/#{candidate.reply_for(r).token}") } ] } }
|
|
46
52
|
end
|
|
47
53
|
end
|
|
48
54
|
end
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<div class="hrl-field"><%= f.label :ctc, "Full-time CTC" %><%= f.text_field :ctc %></div>
|
|
9
9
|
<div class="hrl-field"><%= f.label :location, "Work location" %><%= f.text_area :location, rows: 2 %></div>
|
|
10
10
|
<div class="hrl-field"><%= f.label :schedule, "Work schedule" %><%= f.text_field :schedule %></div>
|
|
11
|
+
<div class="hrl-field"><label class="hrl-field--check"><%= f.check_box :allow_sunday %> Allow Sunday interviews (Sunday is off by default)</label></div>
|
|
11
12
|
<% if campaign.persisted? %>
|
|
12
13
|
<div class="hrl-field"><%= f.label :status %><%= f.select :status, [ %w[Open open], %w[Closed closed] ] %></div>
|
|
13
14
|
<% end %>
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
</section>
|
|
18
18
|
<% end %>
|
|
19
19
|
|
|
20
|
+
<div class="hrl-kpis">
|
|
21
|
+
<% @kpis.each do |label, (count, filter)| %>
|
|
22
|
+
<a class="hrl-kpi hrl-kpi--link" href="<%= hr_lite.admin_hiring_campaign_path(@campaign, filter) %>"><div class="hrl-kpi__value"><%= count %></div><div class="hrl-kpi__label"><%= label %></div></a>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
20
26
|
<% if @attention.any? %>
|
|
21
27
|
<section class="hrl-card hrl-card--warn">
|
|
22
28
|
<h2 class="hrl-card__title">Emails that need a look</h2>
|
|
@@ -56,6 +62,8 @@
|
|
|
56
62
|
</select>
|
|
57
63
|
</div>
|
|
58
64
|
<% end %>
|
|
65
|
+
<label class="hrl-field--check"><input type="checkbox" name="replied" value="1" <%= "checked" if @replied %>> Replied via link</label>
|
|
66
|
+
<input type="hidden" name="round" value="<%= @round %>">
|
|
59
67
|
<div class="hrl-form-actions"><button class="hrl-btn" type="submit">Filter</button></div>
|
|
60
68
|
</form>
|
|
61
69
|
</section>
|
|
@@ -72,8 +80,8 @@
|
|
|
72
80
|
<% @templates.each do |template| %><option value="<%= template.id %>"><%= template.name %></option><% end %>
|
|
73
81
|
</select>
|
|
74
82
|
</div>
|
|
75
|
-
<label class="hrl-field--check"><input type="checkbox" name="all" value="1"> All <%= @total_count %> in this filter</label>
|
|
76
|
-
<input type="hidden" name="stage" value="<%= @stage %>"><input type="hidden" name="campus" value="<%= @campus %>">
|
|
83
|
+
<label class="hrl-field--check"><input type="checkbox" name="all" value="1" data-hrl-select-all="candidate_ids[]"> All <%= @total_count %> in this filter</label>
|
|
84
|
+
<input type="hidden" name="stage" value="<%= @stage %>"><input type="hidden" name="campus" value="<%= @campus %>"><input type="hidden" name="replied" value="<%= @replied %>"><input type="hidden" name="round" value="<%= @round %>">
|
|
77
85
|
<div class="hrl-form-actions"><button class="hrl-btn hrl-btn--primary" type="submit">Review email</button></div>
|
|
78
86
|
</div>
|
|
79
87
|
<div class="hrl-table-wrap">
|
|
@@ -33,8 +33,10 @@
|
|
|
33
33
|
<p class="hrl-small hrl-muted">Next step</p>
|
|
34
34
|
<h2 class="hrl-card__title">Schedule round <%= @interviews.select { |i| %w[scheduled unknown].include?(i.status) }.map(&:round).max.to_i + 1 %></h2>
|
|
35
35
|
<%= form_with url: hr_lite.admin_hiring_candidate_hiring_interviews_path(@candidate), method: :post, local: true do %>
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
<% picked = @next_reply %>
|
|
37
|
+
<% if picked %><p class="hrl-small">They picked <strong><%= picked.preferred_date.strftime("%A, %-d %B") %>, <%= HrLite::HiringReply::SLOTS[picked.preferred_slot] %></strong>. Call to confirm, then send the invite.</p><% end %>
|
|
38
|
+
<div class="hrl-field"><label for="date">Date</label><input type="date" name="date" id="date" required value="<%= picked&.preferred_date&.iso8601 %>"></div>
|
|
39
|
+
<div class="hrl-field"><label for="time">Time</label><input type="time" name="time" id="time" required value="<%= HrLite::HiringReply::SLOT_START[picked&.preferred_slot] %>"></div>
|
|
38
40
|
<div class="hrl-field">
|
|
39
41
|
<label for="minutes">Length</label>
|
|
40
42
|
<select name="minutes" id="minutes"><% HrLite::Admin::HiringInterviewsController::LENGTHS.each do |m| %><option value="<%= m %>"><%= m %> minutes</option><% end %></select>
|
|
@@ -80,6 +82,45 @@
|
|
|
80
82
|
<% end %>
|
|
81
83
|
</div>
|
|
82
84
|
|
|
85
|
+
<% @replies.each do |reply| %>
|
|
86
|
+
<section class="hrl-card">
|
|
87
|
+
<h2 class="hrl-card__title">Round <%= reply.round %> reply</h2>
|
|
88
|
+
<dl class="hrl-deflist">
|
|
89
|
+
<% if reply.round == 1 %><dt>Interested</dt><dd><%= reply.answers["interested"] == "yes" ? "Yes" : "No" %></dd><% end %>
|
|
90
|
+
<% if reply.preferred_slot %><dt>Slot</dt><dd><%= reply.preferred_date.strftime("%A, %-d %B") %>, <%= HrLite::HiringReply::SLOTS[reply.preferred_slot] %></dd><% end %>
|
|
91
|
+
<% if reply.answers["joining"] %>
|
|
92
|
+
<dt>Can join</dt><dd><%= HrLite::HiringReply::JOINING[reply.answers["joining"]] %></dd>
|
|
93
|
+
<% HrLite::HiringReply::YES_NO.each do |key, label| %><dt><%= label %></dt><dd><%= reply.answers[key] == "yes" ? "Yes" : "No" %></dd><% end %>
|
|
94
|
+
<% end %>
|
|
95
|
+
<% if reply.round == 3 %>
|
|
96
|
+
<% { "aadhaar" => [ "Aadhaar", reply.masked_aadhaar ], "pan" => [ "PAN", reply.masked_pan ] }.each do |field, (label, masked)| %>
|
|
97
|
+
<dt><%= label %></dt>
|
|
98
|
+
<dd>
|
|
99
|
+
<% if (full = @revealed&.dig(reply.id, field)) %>
|
|
100
|
+
<strong><%= full %></strong>
|
|
101
|
+
<% elsif masked %>
|
|
102
|
+
<%= masked %>
|
|
103
|
+
<%= button_to "Show full #{label}", hr_lite.reveal_admin_hiring_candidate_path(@candidate), params: { reply_id: reply.id, field: field }, class: "hrl-btn" %>
|
|
104
|
+
<% else %>
|
|
105
|
+
<span class="hrl-badge hrl-badge--warn">PAN pending: collect at joining</span>
|
|
106
|
+
<% end %>
|
|
107
|
+
</dd>
|
|
108
|
+
<% end %>
|
|
109
|
+
<% if reply.no_salary_slips %><dt>Salary slips</dt><dd>None (fresher)</dd><% end %>
|
|
110
|
+
<% end %>
|
|
111
|
+
<% if reply.alternate_phone.present? %><dt>Alternate phone</dt><dd><a class="hrl-btn" href="tel:<%= reply.alternate_phone.gsub(/[^\d+]/, "") %>">Call <%= reply.alternate_phone %></a></dd><% end %>
|
|
112
|
+
<dt>Replied</dt><dd><%= reply.responded_at.strftime("%-d %b %Y, %-l:%M %p") %></dd>
|
|
113
|
+
</dl>
|
|
114
|
+
<% if reply.documents.attached? %>
|
|
115
|
+
<div class="hrl-form-actions">
|
|
116
|
+
<% reply.documents.each do |doc| %>
|
|
117
|
+
<a class="hrl-btn" href="<%= hr_lite.document_admin_hiring_candidate_path(@candidate, attachment_id: doc.id) %>"><%= reply.document_label(doc) %></a>
|
|
118
|
+
<% end %>
|
|
119
|
+
</div>
|
|
120
|
+
<% end %>
|
|
121
|
+
</section>
|
|
122
|
+
<% end %>
|
|
123
|
+
|
|
83
124
|
<section class="hrl-card">
|
|
84
125
|
<h2 class="hrl-card__title">Profile from the sheet</h2>
|
|
85
126
|
<dl class="hrl-deflist">
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
<%= form_with model: template, url: (template.persisted? ? hr_lite.admin_hiring_template_path(template) : hr_lite.admin_hiring_templates_path), local: true do |f| %>
|
|
2
2
|
<%= render "hr_lite/shared/form_errors", record: template %>
|
|
3
|
-
<div class="hrl-field"><%= f.label :name %><%= f.text_field :name, required: true %></div>
|
|
3
|
+
<div class="hrl-field"><%= f.label :name, "Template name" %><%= f.text_field :name, required: true %></div>
|
|
4
4
|
<div class="hrl-field"><%= f.label :subject %><%= f.text_field :subject, required: true %></div>
|
|
5
5
|
<div class="hrl-field"><%= f.label :body, "Message" %><%= f.text_area :body, rows: 16, required: true %></div>
|
|
6
|
+
<% if HrLite.config.hiring.respond_to?(:draft_email) %>
|
|
7
|
+
<button class="hrl-btn" type="submit" name="ai" value="1" formnovalidate>Write with AI</button>
|
|
8
|
+
<p class="hrl-small hrl-muted">Paste or type the email the way you would write it to one person. AI fixes the format, names the template if it has no name yet, writes the subject, and swaps names and offer details for the fields below. Nothing is saved until you click Save.</p>
|
|
9
|
+
<% end %>
|
|
6
10
|
<h3 class="hrl-small"><strong>Fields, filled per candidate</strong></h3>
|
|
7
11
|
<% HrLite::HiringMerge::GROUPS.each do |group, names| %>
|
|
8
12
|
<p class="hrl-small hrl-muted"><%= group %></p>
|
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
<span class="hrl-small hrl-muted"><%= HrLite.config.hiring&.sender&.dig(:email) || "The hiring mailbox" %> to <%= @preview_for.email %></span>
|
|
32
32
|
</div>
|
|
33
33
|
<div class="hrl-hiring-preview"><%= @preview.body %></div>
|
|
34
|
+
<% if (signature = HrLite.config.hiring.try(:signature)).present? %>
|
|
35
|
+
<div class="hrl-hiring-signature"><%= sanitize(signature) %></div>
|
|
36
|
+
<% end %>
|
|
34
37
|
</article>
|
|
35
38
|
<p class="hrl-small hrl-muted">Sent as plain text through Gmail, so it lands in the mailbox's Sent folder.</p>
|
|
36
39
|
<% else %>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<% campaign = @candidate.campaign %>
|
|
2
|
+
<% round = @reply.round %>
|
|
3
|
+
<% content_for(:page_title) { campaign.position } %>
|
|
4
|
+
<div class="hrl-page__head">
|
|
5
|
+
<h1 class="hrl-page__title"><%= campaign.position %></h1>
|
|
6
|
+
<p class="hrl-page__sub">Hi <%= @candidate.first_name %>, <%= round == 1 ? "thank you for your interest." : "congratulations on reaching round #{round}." %></p>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<% if @reply.responded_at %>
|
|
10
|
+
<section class="hrl-card">
|
|
11
|
+
<h2 class="hrl-card__title">Thanks, we have your answers</h2>
|
|
12
|
+
<p><%= @reply.answers["interested"] == "no" ? "We have noted that you do not want to go ahead. All the best." : "Our HR team will call you to confirm your round #{round} interview slot." %></p>
|
|
13
|
+
</section>
|
|
14
|
+
<% else %>
|
|
15
|
+
<%= form_with url: hr_lite.hiring_reply_path(@reply.token), method: :post, local: true, multipart: true do %>
|
|
16
|
+
<% if round == 1 %>
|
|
17
|
+
<section class="hrl-card">
|
|
18
|
+
<fieldset class="hrl-field">
|
|
19
|
+
<legend><strong>Do you want to go ahead with the interview?</strong></legend>
|
|
20
|
+
<label class="hrl-field--check"><input type="radio" name="interested" value="yes" required <%= "checked" if params[:interested] == "yes" %>> Yes, I am interested</label>
|
|
21
|
+
<label class="hrl-field--check"><input type="radio" name="interested" value="no" <%= "checked" if params[:interested] == "no" %>> No, not now</label>
|
|
22
|
+
</fieldset>
|
|
23
|
+
</section>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<section class="hrl-card">
|
|
27
|
+
<h2 class="hrl-card__title"><%= round == 1 ? "If yes: pick" : "Pick" %> a day and one time slot for round <%= round %></h2>
|
|
28
|
+
<p class="hrl-small hrl-muted">Each slot is 2 hours. HR will call you to confirm.</p>
|
|
29
|
+
<fieldset class="hrl-field">
|
|
30
|
+
<legend>Day</legend>
|
|
31
|
+
<% HrLite::HiringReply.dates(allow_sunday: campaign.allow_sunday).each do |day| %>
|
|
32
|
+
<label class="hrl-field--check"><input type="radio" name="date" value="<%= day.iso8601 %>" <%= "checked" if params[:date] == day.iso8601 %>> <%= day.strftime("%A, %-d %B") %></label>
|
|
33
|
+
<% end %>
|
|
34
|
+
</fieldset>
|
|
35
|
+
<fieldset class="hrl-field">
|
|
36
|
+
<legend>Time slot</legend>
|
|
37
|
+
<% HrLite::HiringReply::SLOTS.each do |key, label| %>
|
|
38
|
+
<label class="hrl-field--check"><input type="radio" name="slot" value="<%= key %>" <%= "checked" if params[:slot] == key %>> <%= label %></label>
|
|
39
|
+
<% end %>
|
|
40
|
+
</fieldset>
|
|
41
|
+
<div class="hrl-field">
|
|
42
|
+
<label for="alternate_phone">Alternate phone number (optional)</label>
|
|
43
|
+
<input type="tel" name="alternate_phone" id="alternate_phone" inputmode="tel" autocomplete="tel" value="<%= params[:alternate_phone] %>">
|
|
44
|
+
</div>
|
|
45
|
+
</section>
|
|
46
|
+
|
|
47
|
+
<% if round == 1 %>
|
|
48
|
+
<section class="hrl-card">
|
|
49
|
+
<fieldset class="hrl-field">
|
|
50
|
+
<legend>How soon can you join?</legend>
|
|
51
|
+
<% HrLite::HiringReply::JOINING.each do |key, label| %>
|
|
52
|
+
<label class="hrl-field--check"><input type="radio" name="joining" value="<%= key %>" <%= "checked" if params[:joining] == key %>> <%= label %></label>
|
|
53
|
+
<% end %>
|
|
54
|
+
</fieldset>
|
|
55
|
+
<% { "stipend_ok" => "Are you fine with the monthly stipend#{" of #{campaign.stipend}" if campaign.stipend.present?}?",
|
|
56
|
+
"location_ok" => "Can you work from our office#{" at #{campaign.location}" if campaign.location.present?}?",
|
|
57
|
+
"schedule_ok" => "Are you fine with the work schedule#{": #{campaign.schedule}" if campaign.schedule.present?}?",
|
|
58
|
+
"programme_ok" => "Can you commit to the full programme#{" (#{campaign.programme})" if campaign.programme.present?}?" }.each do |key, question| %>
|
|
59
|
+
<fieldset class="hrl-field">
|
|
60
|
+
<legend><%= question %></legend>
|
|
61
|
+
<label class="hrl-field--check"><input type="radio" name="<%= key %>" value="yes" <%= "checked" if params[key] == "yes" %>> Yes</label>
|
|
62
|
+
<label class="hrl-field--check"><input type="radio" name="<%= key %>" value="no" <%= "checked" if params[key] == "no" %>> No</label>
|
|
63
|
+
</fieldset>
|
|
64
|
+
<% end %>
|
|
65
|
+
</section>
|
|
66
|
+
<% end %>
|
|
67
|
+
|
|
68
|
+
<% if round == 3 %>
|
|
69
|
+
<section class="hrl-card">
|
|
70
|
+
<h2 class="hrl-card__title">Joining details</h2>
|
|
71
|
+
<p class="hrl-small hrl-muted">Stored encrypted. Only our HR team can see them.</p>
|
|
72
|
+
<div class="hrl-field">
|
|
73
|
+
<label for="aadhaar_number">Aadhaar number</label>
|
|
74
|
+
<input type="text" name="aadhaar_number" id="aadhaar_number" inputmode="numeric" autocomplete="off" maxlength="14" required>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="hrl-field">
|
|
77
|
+
<label for="pan_number">PAN</label>
|
|
78
|
+
<input type="text" name="pan_number" id="pan_number" autocomplete="off" maxlength="10" autocapitalize="characters">
|
|
79
|
+
</div>
|
|
80
|
+
<label class="hrl-field--check"><input type="checkbox" name="pan_pending" value="1" <%= "checked" if params[:pan_pending] == "1" %>> I don't have a PAN yet (HR will ask for it when you join)</label>
|
|
81
|
+
<label class="hrl-field--check"><input type="checkbox" name="no_salary_slips" value="1" <%= "checked" if params[:no_salary_slips] == "1" %>> I have no salary slips (fresher)</label>
|
|
82
|
+
</section>
|
|
83
|
+
<% end %>
|
|
84
|
+
|
|
85
|
+
<% kinds = HrLite::HiringReply::DOCUMENTS.fetch(round, {}) %>
|
|
86
|
+
<% if kinds.any? %>
|
|
87
|
+
<section class="hrl-card">
|
|
88
|
+
<h2 class="hrl-card__title">Your documents</h2>
|
|
89
|
+
<p class="hrl-small hrl-muted">PDF, JPG or PNG, up to 5 MB each. Stored encrypted.<%= " Skip PAN or salary slips if you ticked the box above." if round == 3 %></p>
|
|
90
|
+
<% kinds.each do |kind, label| %>
|
|
91
|
+
<div class="hrl-field">
|
|
92
|
+
<label for="doc_<%= kind %>"><%= label %><%= " (if you have it)" if kind.start_with?("salary_slip", "pan") %></label>
|
|
93
|
+
<input type="file" name="documents[<%= kind %>]" id="doc_<%= kind %>" accept=".pdf,.jpg,.jpeg,.png" <%= "required" unless kind.start_with?("salary_slip", "pan") %>>
|
|
94
|
+
</div>
|
|
95
|
+
<% end %>
|
|
96
|
+
</section>
|
|
97
|
+
<% end %>
|
|
98
|
+
|
|
99
|
+
<% if round == 3 %>
|
|
100
|
+
<label class="hrl-field--check"><input type="checkbox" name="consent" value="1" required> I agree to share these details and documents with <%= HrLite.config.company.call[:brand].presence || "the company" %> for hiring and joining.</label>
|
|
101
|
+
<% end %>
|
|
102
|
+
|
|
103
|
+
<div class="hrl-form-actions"><button class="hrl-btn hrl-btn--primary" type="submit">Send my answers</button></div>
|
|
104
|
+
<% end %>
|
|
105
|
+
<% end %>
|
|
@@ -121,6 +121,7 @@
|
|
|
121
121
|
<%= javascript_include_tag "hr_lite/mention", defer: true %>
|
|
122
122
|
<%= javascript_include_tag "hr_lite/nav", defer: true %>
|
|
123
123
|
<%= javascript_include_tag "hr_lite/select_filter", defer: true %>
|
|
124
|
+
<%= javascript_include_tag "hr_lite/select_all", defer: true %>
|
|
124
125
|
<%= javascript_include_tag "hr_lite/search", defer: true %>
|
|
125
126
|
<dialog class="hrl-search" data-hrl-search aria-label="Search">
|
|
126
127
|
<form method="dialog" class="hrl-search__close"><button class="hrl-btn" type="submit">Close</button></form>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<% company = HrLite.config.company.call %>
|
|
5
|
+
<title><%= [ content_for(:page_title), company[:brand].presence || company[:name] ].compact.join(" · ") %></title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
7
|
+
<meta name="robots" content="noindex, nofollow">
|
|
8
|
+
<%= csrf_meta_tags %>
|
|
9
|
+
<%= csp_meta_tag %>
|
|
10
|
+
<%= stylesheet_link_tag "hr_lite/hr_lite", media: "all" %>
|
|
11
|
+
<% HrLite.config.extra_stylesheets.each do |sheet| %><%= stylesheet_link_tag sheet, media: "all" %><% end %>
|
|
12
|
+
</head>
|
|
13
|
+
<body class="hrl-body">
|
|
14
|
+
<main id="hrl-main" class="hrl-public">
|
|
15
|
+
<% if company[:logo_url].present? %><img class="hrl-public__logo" src="<%= company[:logo_url] %>" alt="<%= company[:brand].presence || company[:name] %>"><% end %>
|
|
16
|
+
<% if flash[:alert].present? %><div class="hrl-flash hrl-flash--warn" role="alert"><%= flash[:alert] %></div><% end %>
|
|
17
|
+
<%= yield %>
|
|
18
|
+
</main>
|
|
19
|
+
<%= javascript_include_tag "hr_lite/confirm", defer: true %>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
data/config/routes.rb
CHANGED
|
@@ -69,6 +69,10 @@ HrLite::Engine.routes.draw do
|
|
|
69
69
|
get "career", to: "career#show"
|
|
70
70
|
resources :appraisals, only: %i[index show]
|
|
71
71
|
|
|
72
|
+
# A candidate's reply page for one round, from @round1_link … @round3_link (no login: the token is the key).
|
|
73
|
+
get "apply/:token", to: "hiring_replies#show", as: :hiring_reply
|
|
74
|
+
post "apply/:token", to: "hiring_replies#create"
|
|
75
|
+
|
|
72
76
|
namespace :admin do
|
|
73
77
|
resources :policies, only: %i[index new create]
|
|
74
78
|
resources :hiring_campaigns, except: %i[destroy] do
|
|
@@ -76,7 +80,7 @@ HrLite::Engine.routes.draw do
|
|
|
76
80
|
resources :hiring_sends, path: "send", only: %i[new create]
|
|
77
81
|
end
|
|
78
82
|
resources :hiring_candidates, only: %i[show] do
|
|
79
|
-
member { patch :stage }
|
|
83
|
+
member { patch :stage; get :document; post :reveal }
|
|
80
84
|
resources :hiring_interviews, path: "interviews", only: %i[create update destroy]
|
|
81
85
|
end
|
|
82
86
|
resources :hiring_templates, except: %i[show destroy]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class CreateHrLiteHiringReplies < ActiveRecord::Migration[8.1]
|
|
2
|
+
# One reply link per candidate per interview round: their answers, the day
|
|
3
|
+
# and 2-hour slot they picked, an optional alternate phone, and (from round
|
|
4
|
+
# 2) their documents through Active Storage, stored encrypted. Round 3 adds
|
|
5
|
+
# the joining KYC: Aadhaar, PAN (or "PAN pending") and salary slips.
|
|
6
|
+
def change
|
|
7
|
+
create_table :hr_lite_hiring_replies do |t|
|
|
8
|
+
t.references :candidate, null: false, foreign_key: { to_table: :hr_lite_hiring_candidates }
|
|
9
|
+
t.integer :round, null: false
|
|
10
|
+
t.string :token, null: false
|
|
11
|
+
t.json :answers, null: false, default: {}
|
|
12
|
+
t.text :alternate_phone
|
|
13
|
+
t.text :aadhaar_number
|
|
14
|
+
t.text :pan_number
|
|
15
|
+
t.boolean :pan_pending, null: false, default: false
|
|
16
|
+
t.boolean :no_salary_slips, null: false, default: false
|
|
17
|
+
t.date :preferred_date
|
|
18
|
+
t.string :preferred_slot
|
|
19
|
+
t.datetime :responded_at
|
|
20
|
+
t.timestamps
|
|
21
|
+
end
|
|
22
|
+
add_index :hr_lite_hiring_replies, :token, unique: true
|
|
23
|
+
add_index :hr_lite_hiring_replies, %i[candidate_id round], unique: true
|
|
24
|
+
add_index :hr_lite_hiring_replies, %i[preferred_date preferred_slot]
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/hr_lite/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hr_lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.62.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -75,6 +75,7 @@ files:
|
|
|
75
75
|
- app/assets/javascripts/hr_lite/photo_crop.js
|
|
76
76
|
- app/assets/javascripts/hr_lite/print.js
|
|
77
77
|
- app/assets/javascripts/hr_lite/search.js
|
|
78
|
+
- app/assets/javascripts/hr_lite/select_all.js
|
|
78
79
|
- app/assets/javascripts/hr_lite/select_filter.js
|
|
79
80
|
- app/assets/javascripts/hr_lite/share.js
|
|
80
81
|
- app/assets/javascripts/hr_lite/tax_preview.js
|
|
@@ -141,6 +142,7 @@ files:
|
|
|
141
142
|
- app/controllers/hr_lite/documents_controller.rb
|
|
142
143
|
- app/controllers/hr_lite/employee_profiles_controller.rb
|
|
143
144
|
- app/controllers/hr_lite/expenses_controller.rb
|
|
145
|
+
- app/controllers/hr_lite/hiring_replies_controller.rb
|
|
144
146
|
- app/controllers/hr_lite/holidays_controller.rb
|
|
145
147
|
- app/controllers/hr_lite/home_controller.rb
|
|
146
148
|
- app/controllers/hr_lite/hr_requests_controller.rb
|
|
@@ -215,6 +217,7 @@ files:
|
|
|
215
217
|
- app/models/hr_lite/hiring_candidate.rb
|
|
216
218
|
- app/models/hr_lite/hiring_interview.rb
|
|
217
219
|
- app/models/hr_lite/hiring_message.rb
|
|
220
|
+
- app/models/hr_lite/hiring_reply.rb
|
|
218
221
|
- app/models/hr_lite/hiring_template.rb
|
|
219
222
|
- app/models/hr_lite/holiday.rb
|
|
220
223
|
- app/models/hr_lite/hr_request.rb
|
|
@@ -407,6 +410,7 @@ files:
|
|
|
407
410
|
- app/views/hr_lite/event_mailer/leadership.text.erb
|
|
408
411
|
- app/views/hr_lite/expenses/index.html.erb
|
|
409
412
|
- app/views/hr_lite/expenses/new.html.erb
|
|
413
|
+
- app/views/hr_lite/hiring_replies/show.html.erb
|
|
410
414
|
- app/views/hr_lite/holidays/_next_holiday.html.erb
|
|
411
415
|
- app/views/hr_lite/holidays/index.html.erb
|
|
412
416
|
- app/views/hr_lite/home/_today_checkins.html.erb
|
|
@@ -457,6 +461,7 @@ files:
|
|
|
457
461
|
- app/views/layouts/hr_lite/mailer.html.erb
|
|
458
462
|
- app/views/layouts/hr_lite/mailer.text.erb
|
|
459
463
|
- app/views/layouts/hr_lite/pdf.html.erb
|
|
464
|
+
- app/views/layouts/hr_lite/public.html.erb
|
|
460
465
|
- config/routes.rb
|
|
461
466
|
- db/migrate/20260718190726_create_hr_lite_kudos.rb
|
|
462
467
|
- db/migrate/20260718190727_create_hr_lite_kudo_mentions.rb
|
|
@@ -540,6 +545,8 @@ files:
|
|
|
540
545
|
- db/migrate/20261021100300_create_hr_lite_hiring_messages.rb
|
|
541
546
|
- db/migrate/20261021100400_create_hr_lite_hiring_interviews.rb
|
|
542
547
|
- db/migrate/20261021100500_seed_hr_lite_hiring.rb
|
|
548
|
+
- db/migrate/20261022090000_create_hr_lite_hiring_replies.rb
|
|
549
|
+
- db/migrate/20261022090100_add_allow_sunday_to_hr_lite_hiring_campaigns.rb
|
|
543
550
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
544
551
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
545
552
|
- lib/generators/hr_lite/install/templates/initializer.rb
|