hr_lite 0.61.0 → 0.64.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 +30 -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 +10 -0
- data/app/controllers/hr_lite/admin/hiring_campaigns_controller.rb +10 -2
- data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +39 -1
- data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +2 -2
- data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +33 -4
- data/app/controllers/hr_lite/hiring_replies_controller.rb +32 -0
- data/app/helpers/hr_lite/application_helper.rb +1 -0
- data/app/models/hr_lite/attendance_penalty.rb +13 -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 +146 -0
- data/app/models/hr_lite/hiring_template.rb +8 -0
- data/app/services/hr_lite/hiring_merge.rb +13 -4
- 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 +45 -4
- data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +9 -1
- data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +7 -1
- data/app/views/hr_lite/admin/hiring_templates/index.html.erb +22 -1
- data/app/views/hr_lite/admin/hiring_templates/new.html.erb +4 -1
- data/app/views/hr_lite/attendance/_penalties.html.erb +12 -6
- data/app/views/hr_lite/hiring_replies/show.html.erb +112 -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/db/migrate/20261022120000_add_purpose_to_hr_lite_hiring_templates.rb +8 -0
- data/lib/hr_lite/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 86b218a8a46bb4b5c3527df1fb637aee4b7617e8ef8abfd4e018274bc08e05af
|
|
4
|
+
data.tar.gz: 3e645c3511f89479289ac9169317183232d54c7443846208062d9b764bf692f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ac6f7b8261f2dbaa74acb697effd3deb137d6f3d84647df9260c602ea457d7cb7067b631bdcbef4c09d56555f5688884bcac7607b48ae52002b0946291e4597
|
|
7
|
+
data.tar.gz: dfd0bb1099a90b72051f91569303e321d1d3a4cd216baf3cd9ba11f4dae720e5738be651fb584863b2fbe080702f0b8b517c70f49c9223f3ae6563cf72c0b6e6
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.64.0] - 2026-10-22
|
|
11
|
+
|
|
12
|
+
Decision emails. One migration: `purpose` on hiring templates.
|
|
13
|
+
|
|
14
|
+
- **Selected, Rejected and On hold each send a saved email.** The first time a button is used with no email set, HR is taken to write it, with Write with AI on hand. Then they return to the candidate and click again. After that, every click moves the stage and sends that email through the HR person's own Gmail, once per candidate. Decisions are refused while the mailbox is not connected.
|
|
15
|
+
- **Decision emails can be edited, never deleted.** There is one per decision, and the purpose never changes. The templates page lists them first, flagging any that are not set.
|
|
16
|
+
|
|
17
|
+
## [0.63.1] - 2026-10-22
|
|
18
|
+
|
|
19
|
+
A reply link can only be answered by the candidate it belongs to. No migrations.
|
|
20
|
+
|
|
21
|
+
- **Previews never show a real reply link.** The template preview and the send-confirm check render "[Name's round N link]" instead, so HR can no longer open a previewed candidate's link and answer as them. A real link is made only when an email is actually sent.
|
|
22
|
+
- **The reply page names its owner and checks their email.** It says "This form is for <full name>" and asks for the email the link was sent to. A different email is refused and nothing is saved.
|
|
23
|
+
|
|
24
|
+
## [0.63.0] - 2026-10-22
|
|
25
|
+
|
|
26
|
+
Every penalty row now says why it exists. No migrations.
|
|
27
|
+
|
|
28
|
+
- **Penalty cause, behind an "i".** Tap a row in "Penalties in <month>" (or its "i") to see the day and punch that caused it and what it adds to the day it sits on, e.g. "You checked in at 19:39 on Mon 19 Oct, after 11:00, so +1h goes on your next 2 working days. So Thu 22 Oct needs 60 min more before checkout." A carried penalty (late after 11:00, or left early) used to show only the day it landed on, so it read as a penalty for a day the person was on time.
|
|
29
|
+
- **How to avoid or clear them.** The Penalty hours card adds: check in by your slot and stay until the required checkout; if a punch was wrong, fix that day and ask HR to remove the penalties it caused.
|
|
30
|
+
|
|
31
|
+
## [0.62.0] - 2026-10-22
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
- **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.
|
|
36
|
+
- **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.
|
|
37
|
+
- **Signature preview.** The template preview shows `config.hiring.signature` (sanitized) when the host provides it.
|
|
38
|
+
- **Select all ticks the rows.** "All N in this filter" now ticks and unticks every row, through `select_all.js`.
|
|
39
|
+
|
|
10
40
|
## [0.61.0] - 2026-10-21
|
|
11
41
|
|
|
12
42
|
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
|
+
})();
|
|
@@ -294,6 +294,10 @@ details > form > button[type=submit], details > form > .hrl-btn { margin-top: .2
|
|
|
294
294
|
.hrl-btn[disabled] { opacity: .55; cursor: default; }
|
|
295
295
|
summary.hrl-btn { list-style: none; }
|
|
296
296
|
summary.hrl-btn::-webkit-details-marker { display: none; } /* Safari before 18.4 draws it whatever the display */
|
|
297
|
+
/* A line with an "i": tap anywhere on it for why; the reason drops below. */
|
|
298
|
+
.hrl-why > summary { display: flex; align-items: center; justify-content: space-between; gap: .5rem; min-height: 44px; list-style: none; cursor: pointer; }
|
|
299
|
+
.hrl-why > summary::-webkit-details-marker { display: none; }
|
|
300
|
+
.hrl-why > summary > .hrl-icon { color: var(--hrl-accent); }
|
|
297
301
|
|
|
298
302
|
/* Badges & chips */
|
|
299
303
|
.hrl-badge {
|
|
@@ -673,3 +677,9 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
|
|
|
673
677
|
.hrl-steps .is-now .hrl-steps__dot { background: var(--hrl-accent); border-color: var(--hrl-accent); color: var(--hrl-accent-ink); }
|
|
674
678
|
.hrl-steps .is-now { font-weight: 700; color: var(--hrl-accent); }
|
|
675
679
|
.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; }
|
|
680
|
+
/* The candidate reply page: no staff shell, one readable column. */
|
|
681
|
+
.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)); }
|
|
682
|
+
.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; }
|
|
683
|
+
.hrl-public__logo { display: block; height: 40px; width: auto; margin-bottom: var(--hrl-s-4); }
|
|
684
|
+
.hrl-kpi--link { display: block; min-height: var(--hrl-target); color: inherit; text-decoration: none; }
|
|
685
|
+
.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,12 +13,50 @@ 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 }
|
|
16
19
|
end
|
|
17
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
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Selected / Rejected / On hold also send that decision's saved email. With
|
|
42
|
+
# none saved yet, HR writes it first; the decision waits until it exists.
|
|
18
43
|
def stage
|
|
19
44
|
to = params[:to].presence_in(MANUAL) or raise ArgumentError, "That is not a decision HR makes here."
|
|
45
|
+
raise ArgumentError, "#{@candidate.stage_label} cannot move to #{to.humanize}." unless HiringCandidate::MOVES.fetch(@candidate.stage).include?(to)
|
|
46
|
+
|
|
47
|
+
template = HiringTemplate.for_decision(to)
|
|
48
|
+
if HiringTemplate::PURPOSES.key?(to) && !template
|
|
49
|
+
return redirect_to hr_lite.new_admin_hiring_template_path(purpose: to, candidate_id: @candidate.id),
|
|
50
|
+
alert: "Write the #{HiringTemplate::PURPOSES[to]} email first. Every #{HiringTemplate::PURPOSES[to]} click sends it."
|
|
51
|
+
end
|
|
52
|
+
raise ArgumentError, "Connect your Google account for hiring before sending decision emails." if template && !HrLite.config.hiring&.sender&.dig(:ready)
|
|
53
|
+
|
|
20
54
|
@candidate.move_to!(to, reason: params[:reason], note: params[:note])
|
|
21
|
-
|
|
55
|
+
if template && (message = HiringMessage.create_or_find_by!(candidate: @candidate, template: template) { |m| m.batch = SecureRandom.uuid; m.sent_by = hr_current_user }).previously_new_record?
|
|
56
|
+
HiringSendJob.perform_later(message.id)
|
|
57
|
+
end
|
|
58
|
+
redirect_to hr_lite.admin_hiring_candidate_path(@candidate),
|
|
59
|
+
notice: "Moved to #{@candidate.stage_label}.#{" The #{template.name} email is on its way." if template}"
|
|
22
60
|
rescue ArgumentError => e
|
|
23
61
|
redirect_to hr_lite.admin_hiring_candidate_path(@candidate), alert: e.message
|
|
24
62
|
end
|
|
@@ -48,11 +48,11 @@ 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|
|
|
55
|
-
missing = HiringMerge.new(candidate, sender_name: @sender&.dig(:name)).render(@template).missing
|
|
55
|
+
missing = HiringMerge.new(candidate, sender_name: @sender&.dig(:name), preview: true).render(@template).missing
|
|
56
56
|
reason = already.include?(candidate.id) ? "already sent this template" : (missing.join(", ") if missing.any?)
|
|
57
57
|
reason ? @left_out << [ candidate, reason ] : @recipients << candidate
|
|
58
58
|
end
|
|
@@ -5,20 +5,28 @@ 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
|
-
def new = @template = HiringTemplate.new
|
|
13
|
+
def new = @template = HiringTemplate.new(purpose: params[:purpose].presence_in(HiringTemplate::PURPOSES.keys))
|
|
11
14
|
|
|
12
15
|
def create
|
|
13
|
-
@template = HiringTemplate.new(template_params)
|
|
16
|
+
@template = HiringTemplate.new(template_params.merge(purpose: params.dig(:hiring_template, :purpose).presence))
|
|
17
|
+
return draft if params[:ai].present?
|
|
14
18
|
return render(:new, status: :unprocessable_entity) unless @template.save
|
|
15
19
|
|
|
20
|
+
candidate = HiringCandidate.find_by(id: params[:candidate_id]) if @template.purpose
|
|
21
|
+
return redirect_to(hr_lite.admin_hiring_candidate_path(candidate), notice: "Saved. Click #{HiringTemplate::PURPOSES[@template.purpose]} again to send it.") if candidate
|
|
22
|
+
|
|
16
23
|
redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
|
|
17
24
|
end
|
|
18
25
|
|
|
19
26
|
def edit = load_preview
|
|
20
27
|
|
|
21
28
|
def update
|
|
29
|
+
return draft if params[:ai].present?
|
|
22
30
|
return load_preview && render(:edit, status: :unprocessable_entity) unless @template.update(template_params)
|
|
23
31
|
|
|
24
32
|
redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
|
|
@@ -26,16 +34,37 @@ module HrLite
|
|
|
26
34
|
|
|
27
35
|
private
|
|
28
36
|
|
|
37
|
+
# "Write with AI": rewrites what HR typed into a clean email that uses
|
|
38
|
+
# only known @fields. Nothing is saved; HR reviews it, then saves.
|
|
39
|
+
def draft
|
|
40
|
+
@template.assign_attributes(template_params)
|
|
41
|
+
apply_ai_draft
|
|
42
|
+
@template.persisted? ? load_preview && render(:edit) : render(:new)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def apply_ai_draft
|
|
46
|
+
out = HrLite.config.hiring.draft_email(text: "Subject: #{@template.subject}\n\n#{@template.body}", placeholders: HiringMerge::RESERVED)
|
|
47
|
+
unknown = HiringMerge.tokens("#{out[:subject]} #{out[:body]}") - HiringMerge::RESERVED
|
|
48
|
+
raise HrLite::HiringDeliveryFailed, "it used unknown fields (#{unknown.map { |t| "@#{t}" }.join(', ')})" if unknown.any?
|
|
49
|
+
|
|
50
|
+
@template.assign_attributes(subject: out[:subject], body: out[:body])
|
|
51
|
+
@template.name = out[:name] if @template.name.blank? && out[:name].present?
|
|
52
|
+
AuditLog.record!(action: "hiring.ai_draft", subject: @template)
|
|
53
|
+
flash.now[:notice] = "AI draft ready. Check it, then save."
|
|
54
|
+
rescue HrLite::HiringDeliveryFailed, HrLite::HiringDeliveryUnknown => e
|
|
55
|
+
flash.now[:alert] = "AI could not draft this: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
29
58
|
def load_preview
|
|
30
59
|
@sample = HiringCandidate.includes(:campaign, :interviews).order(updated_at: :desc).limit(200).to_a
|
|
31
60
|
@preview_for = @sample.find { |c| c.id == params[:candidate_id].to_i } || @sample.first
|
|
32
61
|
return true unless @preview_for
|
|
33
62
|
|
|
34
63
|
sender_name = HrLite.config.hiring&.sender&.dig(:name)
|
|
35
|
-
@preview = HiringMerge.new(@preview_for, sender_name:).render(@template)
|
|
64
|
+
@preview = HiringMerge.new(@preview_for, sender_name:, preview: true).render(@template)
|
|
36
65
|
# Readiness across the previewed candidate's campaign: who a send would reach.
|
|
37
66
|
@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? }
|
|
67
|
+
@ready = @preview_for.campaign.candidates.includes(:campaign, :interviews).count { |c| HiringMerge.new(c, sender_name:, preview: true).render(@template).missing.empty? }
|
|
39
68
|
true
|
|
40
69
|
end
|
|
41
70
|
|
|
@@ -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!(email: params[:email], 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
|
|
@@ -198,6 +198,7 @@ module HrLite
|
|
|
198
198
|
"People" => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/>',
|
|
199
199
|
"Me" => '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
|
|
200
200
|
"Help" => '<circle cx="12" cy="12" r="9"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/>',
|
|
201
|
+
"Info" => '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7.5h.01"/>',
|
|
201
202
|
"Hiring" => '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
|
|
202
203
|
"Admin" => '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
|
203
204
|
"More" => '<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>',
|
|
@@ -36,5 +36,18 @@ module HrLite
|
|
|
36
36
|
def self.days_for(minutes) = (minutes / 300) * 0.5
|
|
37
37
|
|
|
38
38
|
def label = REASONS.fetch(reason)
|
|
39
|
+
|
|
40
|
+
# Why this row exists, naming the day and punch that caused it.
|
|
41
|
+
def cause
|
|
42
|
+
r = attendance_record
|
|
43
|
+
t = ->(at) { at.nil? ? "—" : at.in_time_zone(HrLite.config.time_zone).then { |l| l.strftime(l.to_date == r.date ? "%H:%M" : "%H:%M on %a %-d %b") } }
|
|
44
|
+
on = r.date.strftime("%a %-d %b")
|
|
45
|
+
why = case reason
|
|
46
|
+
when "late" then "You checked in at #{t[r.check_in_at]} on #{on}, after your slot."
|
|
47
|
+
when "late_carry" then "You checked in at #{t[r.check_in_at]} on #{on}, after 11:00, so +1h goes on your next 2 working days."
|
|
48
|
+
else "You checked out at #{t[r.check_out_at]} on #{on}, before that day's required checkout of #{t[r.required_check_out_at]}."
|
|
49
|
+
end
|
|
50
|
+
"#{why} So #{date.strftime('%a %-d %b')} needs #{minutes} min more before checkout."
|
|
51
|
+
end
|
|
39
52
|
end
|
|
40
53
|
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,146 @@
|
|
|
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!(email: nil, 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
|
+
# The link is personal: the email it was sent to proves the right person opened it.
|
|
67
|
+
raise ArgumentError, "That email does not match this link. Use the link from your own email." unless email.to_s.strip.casecmp?(candidate.email)
|
|
68
|
+
|
|
69
|
+
assign_kyc!(kyc) if round == 3
|
|
70
|
+
|
|
71
|
+
declined = round == 1 && interested == "no"
|
|
72
|
+
raise ArgumentError, "Tell us whether you want to go ahead." if round == 1 && !%w[yes no].include?(interested)
|
|
73
|
+
|
|
74
|
+
answers = round == 1 ? { "interested" => interested } : {}
|
|
75
|
+
unless declined
|
|
76
|
+
date = self.class.dates(allow_sunday: candidate.campaign.allow_sunday).find { |day| day.iso8601 == date.to_s }
|
|
77
|
+
raise ArgumentError, "Pick a day for the interview." unless date
|
|
78
|
+
raise ArgumentError, "Pick a time slot." unless SLOTS.key?(slot)
|
|
79
|
+
raise ArgumentError, "That slot just filled up. Pick another one." if self.class.slot_full?(date, slot)
|
|
80
|
+
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/)
|
|
81
|
+
|
|
82
|
+
if round == 1
|
|
83
|
+
raise ArgumentError, "Tell us how soon you can join." unless JOINING.key?(joining)
|
|
84
|
+
|
|
85
|
+
answers.merge!("joining" => joining, **YES_NO.keys.to_h { |key| [ key, yes_no[key] == "yes" ? "yes" : "no" ] })
|
|
86
|
+
end
|
|
87
|
+
check_files!(files)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
transaction do
|
|
91
|
+
update!(responded_at: Time.current, answers: answers, alternate_phone: alternate_phone.presence,
|
|
92
|
+
preferred_date: (date unless declined), preferred_slot: (slot unless declined))
|
|
93
|
+
files.slice(*documents_asked.keys).each do |kind, file|
|
|
94
|
+
# Encrypted before it reaches storage: a leaked blob is ciphertext.
|
|
95
|
+
cipher = ActiveRecord::Encryption.encryptor.encrypt(file.tempfile.read.b)
|
|
96
|
+
documents.attach(io: StringIO.new(cipher), filename: "#{kind}--#{file.original_filename}", content_type: "application/octet-stream",
|
|
97
|
+
identify: false)
|
|
98
|
+
end
|
|
99
|
+
if round == 1
|
|
100
|
+
to = declined ? "not_interested" : "interested"
|
|
101
|
+
candidate.move_to!(to, actor: nil) if HiringCandidate::MOVES.fetch(candidate.stage).include?(to)
|
|
102
|
+
end
|
|
103
|
+
candidate.log!("replied", actor: nil, round: round, interested: answers["interested"],
|
|
104
|
+
slot: ([ date.strftime("%-d %b"), SLOTS[slot] ].join(", ") unless declined))
|
|
105
|
+
end
|
|
106
|
+
notify_sender
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# Round 3's joining KYC. Errors name the field, never the number.
|
|
112
|
+
def assign_kyc!(kyc)
|
|
113
|
+
raise ArgumentError, "Tick the consent box to share your documents." unless kyc[:consent] == "1"
|
|
114
|
+
|
|
115
|
+
aadhaar = kyc[:aadhaar_number].to_s.gsub(/\s/, "")
|
|
116
|
+
raise ArgumentError, "That Aadhaar number does not look right." unless self.class.aadhaar_valid?(aadhaar)
|
|
117
|
+
|
|
118
|
+
self.pan_pending = kyc[:pan_pending] == "1"
|
|
119
|
+
pan = kyc[:pan_number].to_s.strip.upcase
|
|
120
|
+
raise ArgumentError, "That PAN does not look right." unless pan_pending || pan.match?(PAN_FORMAT)
|
|
121
|
+
|
|
122
|
+
self.no_salary_slips = kyc[:no_salary_slips] == "1"
|
|
123
|
+
self.aadhaar_number = aadhaar
|
|
124
|
+
self.pan_number = (pan unless pan_pending)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Every asked document, a real PDF/JPG/PNG (read from the bytes, not the
|
|
128
|
+
# browser's claim), at most 5 MB.
|
|
129
|
+
def check_files!(files)
|
|
130
|
+
documents_asked.each do |kind, label|
|
|
131
|
+
file = files[kind]
|
|
132
|
+
raise ArgumentError, "Attach your #{label}." unless file.respond_to?(:original_filename)
|
|
133
|
+
raise ArgumentError, "#{label} must be a PDF, JPG or PNG." unless DOCUMENT_TYPES.include?(sniff(file))
|
|
134
|
+
raise ArgumentError, "#{label} must be 5 MB or smaller." if file.size > DOCUMENT_MAX
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def sniff(file) = Marcel::MimeType.for(file.tempfile, name: file.original_filename).tap { file.tempfile.rewind }
|
|
139
|
+
|
|
140
|
+
def notify_sender
|
|
141
|
+
user = candidate.messages.where(status: "sent").order(:sent_at).last&.sent_by or return
|
|
142
|
+
HrLite.notify(user: user, kind: "hiring.reply", body: candidate.campaign.title, path: "/admin/hiring_candidates/#{candidate_id}",
|
|
143
|
+
title: "#{candidate.name} replied to round #{round}#{": #{answers['interested'] == 'yes' ? 'interested' : 'not interested'}" if round == 1}")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -4,7 +4,15 @@ module HrLite
|
|
|
4
4
|
class HiringTemplate < ApplicationRecord
|
|
5
5
|
has_many :messages, class_name: "HrLite::HiringMessage", foreign_key: :template_id, dependent: :restrict_with_error
|
|
6
6
|
|
|
7
|
+
# The emails the Selected / Rejected / On hold buttons send. One each; edited,
|
|
8
|
+
# never deleted (there is no destroy), and the purpose never changes.
|
|
9
|
+
PURPOSES = { "selected" => "Selected", "rejected" => "Rejected", "on_hold" => "On hold" }.freeze
|
|
10
|
+
|
|
7
11
|
validates :name, :subject, :body, presence: true
|
|
12
|
+
validates :purpose, inclusion: { in: PURPOSES.keys }, uniqueness: true, allow_nil: true
|
|
13
|
+
attr_readonly :purpose
|
|
14
|
+
|
|
15
|
+
def self.for_decision(stage) = find_by(purpose: stage)
|
|
8
16
|
|
|
9
17
|
scope :alphabetical, -> { order(:name) }
|
|
10
18
|
end
|
|
@@ -6,13 +6,19 @@ 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
|
|
|
14
|
-
def
|
|
15
|
+
def self.tokens(text) = text.to_s.scan(TOKEN).flatten.uniq
|
|
16
|
+
|
|
17
|
+
# preview: true shows a label for each reply link instead of the real one, so
|
|
18
|
+
# nobody can open a candidate's link from a preview and answer as them.
|
|
19
|
+
def initialize(candidate, sender_name:, preview: false)
|
|
15
20
|
@extra = candidate.extra.to_h.transform_keys(&:to_s)
|
|
21
|
+
@preview = preview
|
|
16
22
|
@values = @extra.merge(reserved(candidate, sender_name))
|
|
17
23
|
end
|
|
18
24
|
|
|
@@ -21,7 +27,8 @@ module HrLite
|
|
|
21
27
|
fill = ->(text) {
|
|
22
28
|
text.to_s.gsub(TOKEN) do
|
|
23
29
|
key = Regexp.last_match(1)
|
|
24
|
-
value = @values[key]
|
|
30
|
+
value = @values[key]
|
|
31
|
+
value = (value.respond_to?(:call) ? value.call : value).to_s.strip
|
|
25
32
|
next value unless value.empty?
|
|
26
33
|
|
|
27
34
|
missing << "@#{key} (#{RESERVED.include?(key) || @extra.key?(key) ? 'empty' : 'unknown'})"
|
|
@@ -42,7 +49,9 @@ module HrLite
|
|
|
42
49
|
"programme" => campaign.programme, "stipend" => campaign.stipend, "ctc" => campaign.ctc,
|
|
43
50
|
"location" => campaign.location, "schedule" => campaign.schedule,
|
|
44
51
|
"interview_date" => at&.strftime("%-d %B %Y"), "interview_time" => at&.strftime("%-l:%M %p"),
|
|
45
|
-
"meet_link" => interview&.meet_link, "sender_name" => sender_name
|
|
52
|
+
"meet_link" => interview&.meet_link, "sender_name" => sender_name,
|
|
53
|
+
# Lazy: a round's link (and its row) is made only when a template names it.
|
|
54
|
+
**(1..3).to_h { |r| [ "round#{r}_link", -> { @preview ? "[#{candidate.name}'s round #{r} link]" : HrLite.public_url("/apply/#{candidate.reply_for(r).token}") } ] } }
|
|
46
55
|
end
|
|
47
56
|
end
|
|
48
57
|
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>
|
|
@@ -56,7 +58,7 @@
|
|
|
56
58
|
<div class="hrl-form-actions hrl-hiring-decide">
|
|
57
59
|
<% (manual - [ "rejected" ]).each do |to| %>
|
|
58
60
|
<%= button_to to.humanize, hr_lite.stage_admin_hiring_candidate_path(@candidate), method: :patch, params: { to: to }, class: "hrl-btn",
|
|
59
|
-
form: { data: { turbo_confirm: ("Mark #{@candidate.name}
|
|
61
|
+
form: { data: { turbo_confirm: ("Mark #{@candidate.name} #{to.humanize.downcase}?#{" This is final." if to == "selected"}#{" They get the #{to.humanize} email." if HrLite::HiringTemplate::PURPOSES.key?(to)}" if HrLite::HiringTemplate::PURPOSES.key?(to)) } } %>
|
|
60
62
|
<% end %>
|
|
61
63
|
</div>
|
|
62
64
|
<% if manual.include?("rejected") %>
|
|
@@ -72,7 +74,7 @@
|
|
|
72
74
|
</select>
|
|
73
75
|
</div>
|
|
74
76
|
<div class="hrl-field"><label for="note">Note (needed for “Other”)</label><textarea name="note" id="note" rows="2"></textarea></div>
|
|
75
|
-
<div class="hrl-form-actions"><button class="hrl-btn hrl-btn--danger" type="submit" data-turbo-confirm="Reject <%= @candidate.name %>? This is final.">Confirm rejection</button></div>
|
|
77
|
+
<div class="hrl-form-actions"><button class="hrl-btn hrl-btn--danger" type="submit" data-turbo-confirm="Reject <%= @candidate.name %>? This is final. They get the Rejected email.">Confirm rejection</button></div>
|
|
76
78
|
<% end %>
|
|
77
79
|
</details>
|
|
78
80
|
<% end %>
|
|
@@ -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,16 @@
|
|
|
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
|
-
|
|
3
|
+
<% if template.new_record? && template.purpose %>
|
|
4
|
+
<input type="hidden" name="hiring_template[purpose]" value="<%= template.purpose %>">
|
|
5
|
+
<% if params[:candidate_id].present? %><input type="hidden" name="candidate_id" value="<%= params[:candidate_id] %>"><% end %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<div class="hrl-field"><%= f.label :name, "Template name" %><%= f.text_field :name, required: true %></div>
|
|
4
8
|
<div class="hrl-field"><%= f.label :subject %><%= f.text_field :subject, required: true %></div>
|
|
5
9
|
<div class="hrl-field"><%= f.label :body, "Message" %><%= f.text_area :body, rows: 16, required: true %></div>
|
|
10
|
+
<% if HrLite.config.hiring.respond_to?(:draft_email) %>
|
|
11
|
+
<button class="hrl-btn" type="submit" name="ai" value="1" formnovalidate>Write with AI</button>
|
|
12
|
+
<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>
|
|
13
|
+
<% end %>
|
|
6
14
|
<h3 class="hrl-small"><strong>Fields, filled per candidate</strong></h3>
|
|
7
15
|
<% HrLite::HiringMerge::GROUPS.each do |group, names| %>
|
|
8
16
|
<p class="hrl-small hrl-muted"><%= group %></p>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<% content_for(:page_title) { @template.name } %>
|
|
2
|
-
<div class="hrl-page__head"
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= @template.name %></h1>
|
|
4
|
+
<% if @template.purpose %><p class="hrl-page__sub"><span class="hrl-badge">Decision email: <%= HrLite::HiringTemplate::PURPOSES[@template.purpose] %></span> Sent when you click <%= HrLite::HiringTemplate::PURPOSES[@template.purpose] %>. It can be edited, not deleted.</p><% end %>
|
|
5
|
+
</div>
|
|
3
6
|
|
|
4
7
|
<% if @preview %>
|
|
5
8
|
<div class="hrl-flash <%= @ready == @campaign_size ? "hrl-flash--ok" : "hrl-flash--warn" %>">
|
|
@@ -31,6 +34,9 @@
|
|
|
31
34
|
<span class="hrl-small hrl-muted"><%= HrLite.config.hiring&.sender&.dig(:email) || "The hiring mailbox" %> to <%= @preview_for.email %></span>
|
|
32
35
|
</div>
|
|
33
36
|
<div class="hrl-hiring-preview"><%= @preview.body %></div>
|
|
37
|
+
<% if (signature = HrLite.config.hiring.try(:signature)).present? %>
|
|
38
|
+
<div class="hrl-hiring-signature"><%= sanitize(signature) %></div>
|
|
39
|
+
<% end %>
|
|
34
40
|
</article>
|
|
35
41
|
<p class="hrl-small hrl-muted">Sent as plain text through Gmail, so it lands in the mailbox's Sent folder.</p>
|
|
36
42
|
<% else %>
|
|
@@ -3,9 +3,30 @@
|
|
|
3
3
|
<h1 class="hrl-page__title">Hiring email templates</h1>
|
|
4
4
|
<div class="hrl-page__actions"><a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_hiring_template_path %>">New template</a></div>
|
|
5
5
|
</div>
|
|
6
|
+
|
|
6
7
|
<section class="hrl-card">
|
|
8
|
+
<h2 class="hrl-card__title">Decision emails</h2>
|
|
9
|
+
<p class="hrl-small hrl-muted">Sent when you click Selected, Rejected or On hold on a candidate. You can edit them; they cannot be deleted.</p>
|
|
7
10
|
<ul class="hrl-feed">
|
|
8
|
-
<% @templates.
|
|
11
|
+
<% decisions = @templates.select(&:purpose).index_by(&:purpose) %>
|
|
12
|
+
<% HrLite::HiringTemplate::PURPOSES.each do |purpose, label| %>
|
|
13
|
+
<li class="hrl-feed__item"><div class="hrl-feed__body">
|
|
14
|
+
<% if (template = decisions[purpose]) %>
|
|
15
|
+
<a class="hrl-card__link" href="<%= hr_lite.edit_admin_hiring_template_path(template) %>"><%= label %>: <%= template.name %></a>
|
|
16
|
+
<span class="hrl-feed__meta"><%= template.subject %></span>
|
|
17
|
+
<% else %>
|
|
18
|
+
<strong><%= label %></strong> <span class="hrl-badge hrl-badge--warn">Not set</span>
|
|
19
|
+
<a class="hrl-btn" href="<%= hr_lite.new_admin_hiring_template_path(purpose: purpose) %>">Write the <%= label %> email</a>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div></li>
|
|
22
|
+
<% end %>
|
|
23
|
+
</ul>
|
|
24
|
+
</section>
|
|
25
|
+
|
|
26
|
+
<section class="hrl-card">
|
|
27
|
+
<h2 class="hrl-card__title">Other emails</h2>
|
|
28
|
+
<ul class="hrl-feed">
|
|
29
|
+
<% @templates.reject(&:purpose).each do |template| %>
|
|
9
30
|
<li class="hrl-feed__item"><div class="hrl-feed__body">
|
|
10
31
|
<a class="hrl-card__link" href="<%= hr_lite.edit_admin_hiring_template_path(template) %>"><%= template.name %></a>
|
|
11
32
|
<span class="hrl-feed__meta"><%= template.subject %></span>
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
<% content_for(:page_title) { "New template" } %>
|
|
2
|
-
<div class="hrl-page__head"
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= @template.purpose ? "The #{HrLite::HiringTemplate::PURPOSES[@template.purpose]} email" : "New hiring email" %></h1>
|
|
4
|
+
<% if @template.purpose %><p class="hrl-page__sub">Sent every time someone clicks <%= HrLite::HiringTemplate::PURPOSES[@template.purpose] %> on a candidate. Paste what you would write, then use Write with AI to tidy it and add the fields.</p><% end %>
|
|
5
|
+
</div>
|
|
3
6
|
<section class="hrl-card"><%= render "form", template: @template %></section>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<section class="hrl-card" id="penalties">
|
|
5
5
|
<h2 class="hrl-card__title">Penalty hours</h2>
|
|
6
6
|
<p><strong><%= hrl_duration(held * 60) %></strong> held · <%= pluralize(hrl_days(HrLite::AttendancePenalty.days_for(held)), "day") %><%= " of #{leave_type.name}" if leave_type %></p>
|
|
7
|
-
<p class="hrl-hint">Penalties add up until HR removes them.<% if leave_type %> Every 5 hours holds half a day of leave, which can't be applied for.<% end
|
|
7
|
+
<p class="hrl-hint">Penalties add up until HR removes them.<% if leave_type %> Every 5 hours holds half a day of leave, which can't be applied for.<% end %> To avoid more, check in by your slot and stay until the required checkout. If a punch was wrong, fix that day and ask HR to remove the penalties it caused.</p>
|
|
8
8
|
<% HrLite::PenaltyRemoval.where(user_id: user.id).includes(:removed_by).order(created_at: :desc).each do |removal| %>
|
|
9
9
|
<div class="hrl-feed__item hrl-small"><div class="hrl-feed__body">Removed <%= hrl_duration(removal.minutes * 60) %> on <%= removal.created_at.strftime("%-d %b %Y") %> by <%= hr_display_name(removal.removed_by) %>: <%= removal.reason %></div></div>
|
|
10
10
|
<% end %>
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<% end %>
|
|
17
17
|
<% end %>
|
|
18
18
|
</section>
|
|
19
|
-
<% penalties = HrLite::AttendancePenalty.where(user_id: user.id, date: month.all_month).order(:date, :id) %>
|
|
19
|
+
<% penalties = HrLite::AttendancePenalty.where(user_id: user.id, date: month.all_month).includes(:attendance_record).order(:date, :id) %>
|
|
20
20
|
<section class="hrl-card">
|
|
21
21
|
<h2 class="hrl-card__title">Penalties in <%= month.strftime("%B") %></h2>
|
|
22
22
|
<% if penalties.empty? %>
|
|
@@ -25,13 +25,19 @@
|
|
|
25
25
|
<% penalties.each do |penalty| %>
|
|
26
26
|
<div class="hrl-feed__item hrl-small">
|
|
27
27
|
<div class="hrl-feed__body">
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
<details class="hrl-why">
|
|
29
|
+
<summary>
|
|
30
|
+
<span><strong><%= penalty.date.strftime("%a %-d %b") %></strong> ·
|
|
31
|
+
<span class="<%= class_names("hrl-badge", penalty.removed_at ? "hrl-badge--muted" : "hrl-badge--warn") %>">+<%= penalty.minutes %> min</span>
|
|
32
|
+
<%= penalty.label %></span>
|
|
33
|
+
<%= hrl_icon("Info") %><span class="hrl-sr-only">Why?</span>
|
|
34
|
+
</summary>
|
|
35
|
+
<div class="hrl-muted"><%= penalty.cause %></div>
|
|
36
|
+
</details>
|
|
31
37
|
<% if penalty.removed_at %>
|
|
32
38
|
<div class="hrl-muted">Removed by HR: <%= penalty.removal_reason %></div>
|
|
33
39
|
<% elsif local_assigns[:removable] %>
|
|
34
|
-
<%= form_with url: hr_lite.remove_penalty_admin_attendance_path(user.id, penalty_id: penalty.id), method: :post, local: true, class: "hrl-row" do %>
|
|
40
|
+
<%= form_with url: hr_lite.remove_penalty_admin_attendance_path(user.id, penalty_id: penalty.id), method: :post, local: true, class: "hrl-row hrl-mt" do %>
|
|
35
41
|
<label class="hrl-sr-only" for="hrl-remove-<%= penalty.id %>">Reason to remove</label>
|
|
36
42
|
<input type="text" class="hrl-input" name="removal_reason" id="hrl-remove-<%= penalty.id %>" required placeholder="Reason to remove">
|
|
37
43
|
<button type="submit" class="hrl-btn">Remove</button>
|
|
@@ -0,0 +1,112 @@
|
|
|
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">This form is for <strong><%= @candidate.name %></strong>. 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
|
+
<section class="hrl-card">
|
|
17
|
+
<div class="hrl-field">
|
|
18
|
+
<label for="email">Your email (the one this link was sent to)</label>
|
|
19
|
+
<input type="email" name="email" id="email" autocomplete="email" required value="<%= params[:email] %>">
|
|
20
|
+
</div>
|
|
21
|
+
</section>
|
|
22
|
+
|
|
23
|
+
<% if round == 1 %>
|
|
24
|
+
<section class="hrl-card">
|
|
25
|
+
<fieldset class="hrl-field">
|
|
26
|
+
<legend><strong>Do you want to go ahead with the interview?</strong></legend>
|
|
27
|
+
<label class="hrl-field--check"><input type="radio" name="interested" value="yes" required <%= "checked" if params[:interested] == "yes" %>> Yes, I am interested</label>
|
|
28
|
+
<label class="hrl-field--check"><input type="radio" name="interested" value="no" <%= "checked" if params[:interested] == "no" %>> No, not now</label>
|
|
29
|
+
</fieldset>
|
|
30
|
+
</section>
|
|
31
|
+
<% end %>
|
|
32
|
+
|
|
33
|
+
<section class="hrl-card">
|
|
34
|
+
<h2 class="hrl-card__title"><%= round == 1 ? "If yes: pick" : "Pick" %> a day and one time slot for round <%= round %></h2>
|
|
35
|
+
<p class="hrl-small hrl-muted">Each slot is 2 hours. HR will call you to confirm.</p>
|
|
36
|
+
<fieldset class="hrl-field">
|
|
37
|
+
<legend>Day</legend>
|
|
38
|
+
<% HrLite::HiringReply.dates(allow_sunday: campaign.allow_sunday).each do |day| %>
|
|
39
|
+
<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>
|
|
40
|
+
<% end %>
|
|
41
|
+
</fieldset>
|
|
42
|
+
<fieldset class="hrl-field">
|
|
43
|
+
<legend>Time slot</legend>
|
|
44
|
+
<% HrLite::HiringReply::SLOTS.each do |key, label| %>
|
|
45
|
+
<label class="hrl-field--check"><input type="radio" name="slot" value="<%= key %>" <%= "checked" if params[:slot] == key %>> <%= label %></label>
|
|
46
|
+
<% end %>
|
|
47
|
+
</fieldset>
|
|
48
|
+
<div class="hrl-field">
|
|
49
|
+
<label for="alternate_phone">Alternate phone number (optional)</label>
|
|
50
|
+
<input type="tel" name="alternate_phone" id="alternate_phone" inputmode="tel" autocomplete="tel" value="<%= params[:alternate_phone] %>">
|
|
51
|
+
</div>
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
<% if round == 1 %>
|
|
55
|
+
<section class="hrl-card">
|
|
56
|
+
<fieldset class="hrl-field">
|
|
57
|
+
<legend>How soon can you join?</legend>
|
|
58
|
+
<% HrLite::HiringReply::JOINING.each do |key, label| %>
|
|
59
|
+
<label class="hrl-field--check"><input type="radio" name="joining" value="<%= key %>" <%= "checked" if params[:joining] == key %>> <%= label %></label>
|
|
60
|
+
<% end %>
|
|
61
|
+
</fieldset>
|
|
62
|
+
<% { "stipend_ok" => "Are you fine with the monthly stipend#{" of #{campaign.stipend}" if campaign.stipend.present?}?",
|
|
63
|
+
"location_ok" => "Can you work from our office#{" at #{campaign.location}" if campaign.location.present?}?",
|
|
64
|
+
"schedule_ok" => "Are you fine with the work schedule#{": #{campaign.schedule}" if campaign.schedule.present?}?",
|
|
65
|
+
"programme_ok" => "Can you commit to the full programme#{" (#{campaign.programme})" if campaign.programme.present?}?" }.each do |key, question| %>
|
|
66
|
+
<fieldset class="hrl-field">
|
|
67
|
+
<legend><%= question %></legend>
|
|
68
|
+
<label class="hrl-field--check"><input type="radio" name="<%= key %>" value="yes" <%= "checked" if params[key] == "yes" %>> Yes</label>
|
|
69
|
+
<label class="hrl-field--check"><input type="radio" name="<%= key %>" value="no" <%= "checked" if params[key] == "no" %>> No</label>
|
|
70
|
+
</fieldset>
|
|
71
|
+
<% end %>
|
|
72
|
+
</section>
|
|
73
|
+
<% end %>
|
|
74
|
+
|
|
75
|
+
<% if round == 3 %>
|
|
76
|
+
<section class="hrl-card">
|
|
77
|
+
<h2 class="hrl-card__title">Joining details</h2>
|
|
78
|
+
<p class="hrl-small hrl-muted">Stored encrypted. Only our HR team can see them.</p>
|
|
79
|
+
<div class="hrl-field">
|
|
80
|
+
<label for="aadhaar_number">Aadhaar number</label>
|
|
81
|
+
<input type="text" name="aadhaar_number" id="aadhaar_number" inputmode="numeric" autocomplete="off" maxlength="14" required>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="hrl-field">
|
|
84
|
+
<label for="pan_number">PAN</label>
|
|
85
|
+
<input type="text" name="pan_number" id="pan_number" autocomplete="off" maxlength="10" autocapitalize="characters">
|
|
86
|
+
</div>
|
|
87
|
+
<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>
|
|
88
|
+
<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>
|
|
89
|
+
</section>
|
|
90
|
+
<% end %>
|
|
91
|
+
|
|
92
|
+
<% kinds = HrLite::HiringReply::DOCUMENTS.fetch(round, {}) %>
|
|
93
|
+
<% if kinds.any? %>
|
|
94
|
+
<section class="hrl-card">
|
|
95
|
+
<h2 class="hrl-card__title">Your documents</h2>
|
|
96
|
+
<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>
|
|
97
|
+
<% kinds.each do |kind, label| %>
|
|
98
|
+
<div class="hrl-field">
|
|
99
|
+
<label for="doc_<%= kind %>"><%= label %><%= " (if you have it)" if kind.start_with?("salary_slip", "pan") %></label>
|
|
100
|
+
<input type="file" name="documents[<%= kind %>]" id="doc_<%= kind %>" accept=".pdf,.jpg,.jpeg,.png" <%= "required" unless kind.start_with?("salary_slip", "pan") %>>
|
|
101
|
+
</div>
|
|
102
|
+
<% end %>
|
|
103
|
+
</section>
|
|
104
|
+
<% end %>
|
|
105
|
+
|
|
106
|
+
<% if round == 3 %>
|
|
107
|
+
<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>
|
|
108
|
+
<% end %>
|
|
109
|
+
|
|
110
|
+
<div class="hrl-form-actions"><button class="hrl-btn hrl-btn--primary" type="submit">Send my answers</button></div>
|
|
111
|
+
<% end %>
|
|
112
|
+
<% 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
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddPurposeToHrLiteHiringTemplates < ActiveRecord::Migration[8.1]
|
|
2
|
+
# The email a decision button sends (selected / rejected / on_hold). One per
|
|
3
|
+
# purpose; nil for ordinary templates.
|
|
4
|
+
def change
|
|
5
|
+
add_column :hr_lite_hiring_templates, :purpose, :string
|
|
6
|
+
add_index :hr_lite_hiring_templates, :purpose, unique: true, where: "purpose IS NOT NULL"
|
|
7
|
+
end
|
|
8
|
+
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.64.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,9 @@ 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
|
|
550
|
+
- db/migrate/20261022120000_add_purpose_to_hr_lite_hiring_templates.rb
|
|
543
551
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
544
552
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
545
553
|
- lib/generators/hr_lite/install/templates/initializer.rb
|