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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d57f2981c5062477e62d5cf7518fb074dfedccd73de6338d3263ce57143dd824
4
- data.tar.gz: eb27ceb943445189d60db425d2fd7250d89fbb6e118f48b456d0a33eb22dfc4b
3
+ metadata.gz: 7f779cdbd416cb664617888bbb71b2c6866742b52e3ad5525ac2c5819a9b9908
4
+ data.tar.gz: 346563b5b820eabc5aec4d83076cb1a3dd37f6309c159ad15c7a3f70698032a3
5
5
  SHA512:
6
- metadata.gz: 128a548490e3eb5c3028c197eb352e14419d200bbff36c79c7237bb4f355b666dd525ad7a0e54a7d1c8f6814d3b8165e3311215ab8f17dd81ac20460ae47b926
7
- data.tar.gz: 84179555ec4cf9ea6623071e0f72da671ff3f17f69f9d4771fbe6a833e6ba77ca6edcab85bbeebbf835d6aee2d2a23ee1f61d1cba42f7f26186e6d61d246ca14
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] }.freeze
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].to_s.strip
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
- <div class="hrl-field"><label for="date">Date</label><input type="date" name="date" id="date" required></div>
37
- <div class="hrl-field"><label for="time">Time</label><input type="time" name="time" id="time" required></div>
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
@@ -0,0 +1,6 @@
1
+ class AddAllowSundayToHrLiteHiringCampaigns < ActiveRecord::Migration[8.1]
2
+ # Sunday is off; HR can open it for one campaign's interview slots.
3
+ def change
4
+ add_column :hr_lite_hiring_campaigns, :allow_sunday, :boolean, null: false, default: false
5
+ end
6
+ end
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.61.0"
2
+ VERSION = "0.62.0"
3
3
  end
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.61.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