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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +4 -0
  4. data/app/assets/javascripts/hr_lite/select_all.js +21 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +10 -0
  6. data/app/controllers/hr_lite/admin/hiring_campaigns_controller.rb +10 -2
  7. data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +39 -1
  8. data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +2 -2
  9. data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +33 -4
  10. data/app/controllers/hr_lite/hiring_replies_controller.rb +32 -0
  11. data/app/helpers/hr_lite/application_helper.rb +1 -0
  12. data/app/models/hr_lite/attendance_penalty.rb +13 -0
  13. data/app/models/hr_lite/hiring_campaign.rb +3 -1
  14. data/app/models/hr_lite/hiring_candidate.rb +6 -2
  15. data/app/models/hr_lite/hiring_reply.rb +146 -0
  16. data/app/models/hr_lite/hiring_template.rb +8 -0
  17. data/app/services/hr_lite/hiring_merge.rb +13 -4
  18. data/app/views/hr_lite/admin/hiring_campaigns/_form.html.erb +1 -0
  19. data/app/views/hr_lite/admin/hiring_campaigns/show.html.erb +10 -2
  20. data/app/views/hr_lite/admin/hiring_candidates/show.html.erb +45 -4
  21. data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +9 -1
  22. data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +7 -1
  23. data/app/views/hr_lite/admin/hiring_templates/index.html.erb +22 -1
  24. data/app/views/hr_lite/admin/hiring_templates/new.html.erb +4 -1
  25. data/app/views/hr_lite/attendance/_penalties.html.erb +12 -6
  26. data/app/views/hr_lite/hiring_replies/show.html.erb +112 -0
  27. data/app/views/layouts/hr_lite/application.html.erb +1 -0
  28. data/app/views/layouts/hr_lite/public.html.erb +21 -0
  29. data/config/routes.rb +5 -1
  30. data/db/migrate/20261022090000_create_hr_lite_hiring_replies.rb +26 -0
  31. data/db/migrate/20261022090100_add_allow_sunday_to_hr_lite_hiring_campaigns.rb +6 -0
  32. data/db/migrate/20261022120000_add_purpose_to_hr_lite_hiring_templates.rb +8 -0
  33. data/lib/hr_lite/version.rb +1 -1
  34. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d57f2981c5062477e62d5cf7518fb074dfedccd73de6338d3263ce57143dd824
4
- data.tar.gz: eb27ceb943445189d60db425d2fd7250d89fbb6e118f48b456d0a33eb22dfc4b
3
+ metadata.gz: 86b218a8a46bb4b5c3527df1fb637aee4b7617e8ef8abfd4e018274bc08e05af
4
+ data.tar.gz: 3e645c3511f89479289ac9169317183232d54c7443846208062d9b764bf692f0
5
5
  SHA512:
6
- metadata.gz: 128a548490e3eb5c3028c197eb352e14419d200bbff36c79c7237bb4f355b666dd525ad7a0e54a7d1c8f6814d3b8165e3311215ab8f17dd81ac20460ae47b926
7
- data.tar.gz: 84179555ec4cf9ea6623071e0f72da671ff3f17f69f9d4771fbe6a833e6ba77ca6edcab85bbeebbf835d6aee2d2a23ee1f61d1cba42f7f26186e6d61d246ca14
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
- redirect_to hr_lite.admin_hiring_candidate_path(@candidate), notice: "Moved to #{@candidate.stage_label}."
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] }.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
 
14
- def initialize(candidate, sender_name:)
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].to_s.strip
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
- <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>
@@ -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} selected? This is final." if to == "selected") } } %>
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
- <div class="hrl-field"><%= f.label :name %><%= f.text_field :name, required: true %></div>
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"><h1 class="hrl-page__title"><%= @template.name %></h1></div>
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.each do |template| %>
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"><h1 class="hrl-page__title">New hiring email</h1></div>
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 %></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 %> 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
- <strong><%= penalty.date.strftime("%a %-d %b") %></strong> ·
29
- <span class="<%= class_names("hrl-badge", penalty.removed_at ? "hrl-badge--muted" : "hrl-badge--warn") %>">+<%= penalty.minutes %> min</span>
30
- <%= penalty.label %>
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,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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.61.0"
2
+ VERSION = "0.64.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.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