hr_lite 0.60.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +18 -0
  4. data/app/assets/javascripts/hr_lite/select_all.js +21 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +26 -0
  6. data/app/controllers/hr_lite/admin/hiring_campaigns_controller.rb +96 -0
  7. data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +50 -0
  8. data/app/controllers/hr_lite/admin/hiring_interviews_controller.rb +77 -0
  9. data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +62 -0
  10. data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +71 -0
  11. data/app/controllers/hr_lite/hiring_replies_controller.rb +32 -0
  12. data/app/helpers/hr_lite/application_helper.rb +38 -0
  13. data/app/jobs/hr_lite/hiring_send_job.rb +42 -0
  14. data/app/models/hr_lite/hiring_campaign.rb +23 -0
  15. data/app/models/hr_lite/hiring_candidate.rb +56 -0
  16. data/app/models/hr_lite/hiring_interview.rb +13 -0
  17. data/app/models/hr_lite/hiring_message.rb +14 -0
  18. data/app/models/hr_lite/hiring_reply.rb +144 -0
  19. data/app/models/hr_lite/hiring_template.rb +11 -0
  20. data/app/services/hr_lite/hiring_import.rb +106 -0
  21. data/app/services/hr_lite/hiring_merge.rb +54 -0
  22. data/app/views/hr_lite/admin/hiring_campaigns/_form.html.erb +19 -0
  23. data/app/views/hr_lite/admin/hiring_campaigns/edit.html.erb +3 -0
  24. data/app/views/hr_lite/admin/hiring_campaigns/import.html.erb +18 -0
  25. data/app/views/hr_lite/admin/hiring_campaigns/index.html.erb +21 -0
  26. data/app/views/hr_lite/admin/hiring_campaigns/new.html.erb +3 -0
  27. data/app/views/hr_lite/admin/hiring_campaigns/preview.html.erb +62 -0
  28. data/app/views/hr_lite/admin/hiring_campaigns/show.html.erb +106 -0
  29. data/app/views/hr_lite/admin/hiring_candidates/show.html.erb +186 -0
  30. data/app/views/hr_lite/admin/hiring_sends/new.html.erb +34 -0
  31. data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +20 -0
  32. data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +43 -0
  33. data/app/views/hr_lite/admin/hiring_templates/index.html.erb +15 -0
  34. data/app/views/hr_lite/admin/hiring_templates/new.html.erb +3 -0
  35. data/app/views/hr_lite/hiring_replies/show.html.erb +105 -0
  36. data/app/views/layouts/hr_lite/application.html.erb +1 -0
  37. data/app/views/layouts/hr_lite/public.html.erb +21 -0
  38. data/config/routes.rb +14 -0
  39. data/db/migrate/20261021100000_create_hr_lite_hiring_campaigns.rb +15 -0
  40. data/db/migrate/20261021100100_create_hr_lite_hiring_candidates.rb +22 -0
  41. data/db/migrate/20261021100200_create_hr_lite_hiring_templates.rb +10 -0
  42. data/db/migrate/20261021100300_create_hr_lite_hiring_messages.rb +19 -0
  43. data/db/migrate/20261021100400_create_hr_lite_hiring_interviews.rb +16 -0
  44. data/db/migrate/20261021100500_seed_hr_lite_hiring.rb +43 -0
  45. data/db/migrate/20261022090000_create_hr_lite_hiring_replies.rb +26 -0
  46. data/db/migrate/20261022090100_add_allow_sunday_to_hr_lite_hiring_campaigns.rb +6 -0
  47. data/lib/hr_lite/configuration.rb +14 -1
  48. data/lib/hr_lite/permissions.rb +2 -1
  49. data/lib/hr_lite/role_seeds.rb +2 -2
  50. data/lib/hr_lite/version.rb +1 -1
  51. data/lib/hr_lite.rb +6 -0
  52. metadata +40 -1
@@ -0,0 +1,56 @@
1
+ module HrLite
2
+ # A person from a university's sheet, moving through one campaign. Every
3
+ # change goes on their timeline (AuditLog rows whose subject is them).
4
+ class HiringCandidate < ApplicationRecord
5
+ STAGES = %w[imported invited interested not_interested interview selected rejected on_hold].freeze
6
+ # Where a stage may go next. Sending moves imported → invited; scheduling moves to interview.
7
+ MOVES = {
8
+ "imported" => %w[invited interested not_interested],
9
+ "invited" => %w[interested not_interested],
10
+ "interested" => %w[interview not_interested on_hold rejected],
11
+ "not_interested" => %w[interested],
12
+ "interview" => %w[interview selected rejected on_hold],
13
+ "on_hold" => %w[interview selected rejected],
14
+ "selected" => [], "rejected" => []
15
+ }.freeze
16
+ REJECTION_REASONS = [ "Communication skills", "Not available for 6 months", "Location / relocation",
17
+ "Stipend not acceptable", "Did not attend interview", "Other" ].freeze
18
+
19
+ belongs_to :campaign, class_name: "HrLite::HiringCampaign", inverse_of: :candidates
20
+ has_many :messages, class_name: "HrLite::HiringMessage", foreign_key: :candidate_id, dependent: :restrict_with_error
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
23
+
24
+ encrypts :phone, :date_of_birth
25
+ normalizes :email, with: ->(email) { email.to_s.strip.downcase }
26
+
27
+ validates :name, presence: true
28
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: { scope: :campaign_id }
29
+ validates :stage, inclusion: { in: STAGES }
30
+
31
+ def first_name = name.to_s.split.first
32
+ def stage_label = stage == "interview" ? "Interview · round #{round}" : stage.humanize
33
+ def timeline = AuditLog.where(subject_type: self.class.name, subject_id: id).includes(:actor).order(created_at: :desc, id: :desc)
34
+
35
+ # The one door for a stage change: refuses a move the pipeline does not
36
+ # allow, needs a listed reason to reject, and writes the timeline row.
37
+ def move_to!(to, reason: nil, note: nil, round: self.round, actor: HrLite::Current.actor)
38
+ raise ArgumentError, "#{stage_label} cannot move to #{to.humanize}." unless MOVES.fetch(stage).include?(to)
39
+ raise ArgumentError, "Pick a rejection reason." if to == "rejected" && !REJECTION_REASONS.include?(reason)
40
+ raise ArgumentError, "Write a note for “Other”." if reason == "Other" && note.blank?
41
+
42
+ transaction do
43
+ from = stage
44
+ update!(stage: to, round: round, rejection_reason: (to == "rejected" ? [ reason, note.presence ].compact.join(": ") : rejection_reason))
45
+ log!("stage", actor: actor, from: from, to: to, reason: (reason if to == "rejected"))
46
+ end
47
+ end
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
+
52
+ def log!(action, actor: HrLite::Current.actor, **changes)
53
+ AuditLog.record!(action: "hiring.#{action}", subject: self, actor: actor, changes: changes.compact)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,13 @@
1
+ module HrLite
2
+ # One interview round on the sender's Google Calendar. A new round is a new
3
+ # row, so earlier rounds keep their own history.
4
+ class HiringInterview < ApplicationRecord
5
+ STATUSES = %w[unknown scheduled failed cancelled].freeze
6
+
7
+ belongs_to :candidate, class_name: "HrLite::HiringCandidate"
8
+
9
+ validates :status, inclusion: { in: STATUSES }
10
+ validates :round, numericality: { greater_than: 0 }
11
+ validates :starts_at, :ends_at, presence: true
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ module HrLite
2
+ # One template sent (or being sent) to one candidate. The unique index on
3
+ # candidate + template is what makes a repeated click or job a no-op.
4
+ # "sending" left behind by a crash, and "unknown", both mean: look in Gmail.
5
+ class HiringMessage < ApplicationRecord
6
+ STATUSES = %w[queued sending sent failed unknown].freeze
7
+
8
+ belongs_to :candidate, class_name: "HrLite::HiringCandidate"
9
+ belongs_to :template, class_name: "HrLite::HiringTemplate"
10
+ belongs_to :sent_by, class_name: HrLite.config.user_class, optional: true
11
+
12
+ validates :status, inclusion: { in: STATUSES }
13
+ end
14
+ 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
@@ -0,0 +1,11 @@
1
+ module HrLite
2
+ # An email HR writes once and sends to many. @placeholders are filled per
3
+ # candidate by HiringMerge at send time.
4
+ class HiringTemplate < ApplicationRecord
5
+ has_many :messages, class_name: "HrLite::HiringMessage", foreign_key: :template_id, dependent: :restrict_with_error
6
+
7
+ validates :name, :subject, :body, presence: true
8
+
9
+ scope :alphabetical, -> { order(:name) }
10
+ end
11
+ end
@@ -0,0 +1,106 @@
1
+ module HrLite
2
+ # Reads a university's candidate sheet into a preview, then imports it.
3
+ # Headers match an alias list after case-folding and dropping punctuation
4
+ # and spaces. A column nobody recognises is kept in `extra`, never dropped;
5
+ # two columns claiming one field wait for HR to pick one.
6
+ class HiringImport
7
+ ALIASES = {
8
+ "name" => %w[studentname name candidatename fullname],
9
+ "email" => %w[emailid email emailaddress mailid],
10
+ "phone" => %w[contactno contactnumber phone phoneno mobile mobileno],
11
+ "date_of_birth" => %w[dob dobddmmyyyy dateofbirth],
12
+ "campus" => %w[campus college],
13
+ "course" => %w[coursepursuing course programme program],
14
+ "branch" => %w[branchspecialization branch specialization],
15
+ "cv_url" => %w[linkforcv cv cvlink resume resumelink]
16
+ }.freeze
17
+
18
+ Column = Data.define(:index, :header, :field, :extra_key)
19
+ Row = Data.define(:number, :attributes, :extra, :outcome, :reason)
20
+
21
+ def self.normalize(header) = header.to_s.downcase.gsub(/[^a-z0-9]/, "")
22
+
23
+ # Cell values arrive as the sheet stored them: never evaluated, never fetched.
24
+ def self.text(value)
25
+ case value
26
+ when nil then ""
27
+ when Float then value == value.floor ? value.to_i.to_s : value.to_s
28
+ when Date, Time then value.strftime("%d/%m/%Y")
29
+ else value.to_s.strip
30
+ end
31
+ end
32
+
33
+ attr_reader :columns, :conflicts, :rows
34
+
35
+ # rows: arrays of cell values, headers first. picks: { "phone" => "3" }
36
+ # names the column that wins a field two columns claim.
37
+ def initialize(campaign, rows, picks: {})
38
+ @campaign = campaign
39
+ header, *body = rows
40
+ map_columns(Array(header), picks.to_h.transform_values(&:to_i))
41
+ @rows = classify(body)
42
+ end
43
+
44
+ def counts = %w[create update skip].index_with { |outcome| rows.count { |row| row.outcome == outcome } }
45
+
46
+ def import!
47
+ return false if conflicts.any?
48
+
49
+ HiringCandidate.transaction do
50
+ rows.reject { |row| row.outcome == "skip" }.each do |row|
51
+ candidate = @campaign.candidates.find_or_initialize_by(email: row.attributes["email"])
52
+ created = candidate.new_record?
53
+ candidate.assign_attributes(row.attributes.merge("extra" => candidate.extra.to_h.merge(row.extra)))
54
+ candidate.save!
55
+ candidate.log!(created ? "imported" : "reimported") if created || candidate.saved_changes.any?
56
+ end
57
+ end
58
+ true
59
+ end
60
+
61
+ private
62
+
63
+ def map_columns(header, picks)
64
+ @columns = header.each_with_index.map do |cell, index|
65
+ field = ALIASES.find { |_, names| names.include?(self.class.normalize(cell)) }&.first
66
+ Column.new(index:, header: cell.to_s.strip, field:, extra_key: nil)
67
+ end
68
+ claimed = @columns.select(&:field).group_by(&:field)
69
+ @conflicts = claimed.select { |field, cols| cols.size > 1 && cols.none? { |c| c.index == picks[field] } }
70
+ # A column that lost its field (or is unknown) keeps its data in `extra`.
71
+ taken = HiringMerge::RESERVED + ALIASES.keys
72
+ @columns = @columns.map do |col|
73
+ loses = col.field && claimed[col.field].size > 1 && picks[col.field] != col.index
74
+ next col if col.field && !loses
75
+
76
+ key = col.header.parameterize(separator: "_").presence || "column_#{col.index + 1}"
77
+ key = "sheet_#{key}" if taken.include?(key) || !key.match?(/\A[a-z]/) # @placeholders start with a letter
78
+ taken << key
79
+ col.with(field: nil, extra_key: key)
80
+ end
81
+ end
82
+
83
+ def classify(body)
84
+ seen = {}
85
+ existing = @campaign.candidates.pluck(:email).to_set
86
+ body.each_with_index.filter_map do |cells, i|
87
+ number = i + 2 # sheet row, counting the header
88
+ values = @columns.to_h { |col| [ col, self.class.text(Array(cells)[col.index]) ] }
89
+ next if values.values.all?(&:blank?) # an empty row in the sheet is not a candidate
90
+
91
+ attributes = values.select { |col, _| col.field }.to_h { |col, v| [ col.field, v.presence ] }
92
+ attributes["email"] = attributes["email"].to_s.strip.downcase.presence
93
+ extra = values.select { |col, v| col.extra_key && v.present? }.to_h { |col, v| [ col.extra_key, v ] }
94
+ outcome, reason =
95
+ if attributes["name"].blank? then [ "skip", "no name" ]
96
+ elsif attributes["email"].blank? then [ "skip", "no email address" ]
97
+ elsif !attributes["email"].match?(URI::MailTo::EMAIL_REGEXP) then [ "skip", "email address looks wrong" ]
98
+ elsif seen[attributes["email"]] then [ "skip", "same email as row #{seen[attributes['email']]}" ]
99
+ else [ existing.include?(attributes["email"]) ? "update" : "create", nil ]
100
+ end
101
+ seen[attributes["email"]] ||= number if attributes["email"]
102
+ Row.new(number:, attributes:, extra:, outcome:, reason:)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,54 @@
1
+ module HrLite
2
+ # Fills a hiring email's @placeholders for one candidate. Reserved names
3
+ # (campaign, candidate, interview, sender) win over a sheet's extra columns.
4
+ # An unknown or blank placeholder is reported, never sent as "".
5
+ class HiringMerge
6
+ TOKEN = /(?<![\w.@])@([a-z][a-z0-9_]*)/
7
+ GROUPS = { "Candidate" => %w[first_name name course branch campus],
8
+ "Offer" => %w[position programme stipend ctc location schedule],
9
+ "Interview" => %w[interview_date interview_time meet_link sender_name],
10
+ "Reply links" => %w[round1_link round2_link round3_link] }.freeze
11
+ RESERVED = GROUPS.values.flatten.freeze
12
+
13
+ Result = Data.define(:subject, :body, :missing)
14
+
15
+ def self.tokens(text) = text.to_s.scan(TOKEN).flatten.uniq
16
+
17
+ def initialize(candidate, sender_name:)
18
+ @extra = candidate.extra.to_h.transform_keys(&:to_s)
19
+ @values = @extra.merge(reserved(candidate, sender_name))
20
+ end
21
+
22
+ def render(template)
23
+ missing = []
24
+ fill = ->(text) {
25
+ text.to_s.gsub(TOKEN) do
26
+ key = Regexp.last_match(1)
27
+ value = @values[key]
28
+ value = (value.respond_to?(:call) ? value.call : value).to_s.strip
29
+ next value unless value.empty?
30
+
31
+ missing << "@#{key} (#{RESERVED.include?(key) || @extra.key?(key) ? 'empty' : 'unknown'})"
32
+ Regexp.last_match(0)
33
+ end
34
+ }
35
+ Result.new(subject: fill.(template.subject), body: fill.(template.body), missing: missing.uniq)
36
+ end
37
+
38
+ private
39
+
40
+ def reserved(candidate, sender_name)
41
+ campaign = candidate.campaign
42
+ interview = candidate.interviews.where(status: "scheduled").order(:round).last
43
+ at = interview&.starts_at&.in_time_zone(HrLite.config.time_zone)
44
+ { "name" => candidate.name, "first_name" => candidate.first_name, "course" => candidate.course,
45
+ "branch" => candidate.branch, "campus" => candidate.campus, "position" => campaign.position,
46
+ "programme" => campaign.programme, "stipend" => campaign.stipend, "ctc" => campaign.ctc,
47
+ "location" => campaign.location, "schedule" => campaign.schedule,
48
+ "interview_date" => at&.strftime("%-d %B %Y"), "interview_time" => at&.strftime("%-l:%M %p"),
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}") } ] } }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,19 @@
1
+ <%= form_with model: campaign, url: (campaign.persisted? ? hr_lite.admin_hiring_campaign_path(campaign) : hr_lite.admin_hiring_campaigns_path), local: true do |f| %>
2
+ <%= render "hr_lite/shared/form_errors", record: campaign %>
3
+ <p class="hrl-small hrl-muted">These fill @position, @programme, @stipend, @ctc, @location and @schedule in every email.</p>
4
+ <div class="hrl-field"><%= f.label :title, "Campaign name" %><%= f.text_field :title, required: true, placeholder: "e.g. Campus drive, October" %></div>
5
+ <div class="hrl-field"><%= f.label :position %><%= f.text_field :position, required: true %></div>
6
+ <div class="hrl-field"><%= f.label :programme %><%= f.text_field :programme %></div>
7
+ <div class="hrl-field"><%= f.label :stipend, "Monthly stipend" %><%= f.text_field :stipend %></div>
8
+ <div class="hrl-field"><%= f.label :ctc, "Full-time CTC" %><%= f.text_field :ctc %></div>
9
+ <div class="hrl-field"><%= f.label :location, "Work location" %><%= f.text_area :location, rows: 2 %></div>
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>
12
+ <% if campaign.persisted? %>
13
+ <div class="hrl-field"><%= f.label :status %><%= f.select :status, [ %w[Open open], %w[Closed closed] ] %></div>
14
+ <% end %>
15
+ <div class="hrl-form-actions">
16
+ <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
17
+ <a class="hrl-btn" href="<%= campaign.persisted? ? hr_lite.admin_hiring_campaign_path(campaign) : hr_lite.admin_hiring_campaigns_path %>">Cancel</a>
18
+ </div>
19
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "Edit campaign" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Edit <%= @campaign.title %></h1></div>
3
+ <section class="hrl-card"><%= render "form", campaign: @campaign %></section>
@@ -0,0 +1,18 @@
1
+ <% content_for(:page_title) { "Upload candidates" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Upload candidate sheet</h1>
4
+ <p class="hrl-page__sub"><%= @campaign.title %>. Nothing is saved until you confirm the preview.</p>
5
+ </div>
6
+ <section class="hrl-card">
7
+ <%= form_with url: hr_lite.import_admin_hiring_campaign_path(@campaign), method: :post, local: true, multipart: true do %>
8
+ <div class="hrl-field">
9
+ <label for="sheet">Sheet (.xlsx or .csv, headers in the first row)</label>
10
+ <input type="file" name="sheet" id="sheet" accept=".xlsx,.xls,.csv" required>
11
+ </div>
12
+ <p class="hrl-small hrl-muted">Name and email are required. Columns we do not recognise are kept, and can be used as @placeholders.</p>
13
+ <div class="hrl-form-actions">
14
+ <button class="hrl-btn hrl-btn--primary" type="submit">Preview</button>
15
+ <a class="hrl-btn" href="<%= hr_lite.admin_hiring_campaign_path(@campaign) %>">Cancel</a>
16
+ </div>
17
+ <% end %>
18
+ </section>
@@ -0,0 +1,21 @@
1
+ <% content_for(:page_title) { "Hiring" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Hiring</h1>
4
+ <div class="hrl-page__actions"><a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_hiring_campaign_path %>">New campaign</a></div>
5
+ </div>
6
+
7
+ <% if @campaigns.empty? %>
8
+ <section class="hrl-card hrl-card--empty"><p class="hrl-muted">No campaigns yet. Create one, then upload the university's candidate sheet.</p></section>
9
+ <% end %>
10
+ <% @campaigns.each do |campaign| %>
11
+ <section class="hrl-card">
12
+ <h2 class="hrl-card__title"><a class="hrl-card__link" href="<%= hr_lite.admin_hiring_campaign_path(campaign) %>"><%= campaign.title %></a></h2>
13
+ <p class="hrl-small hrl-muted"><%= [ campaign.position, campaign.stipend, (campaign.status == "closed" ? "Closed" : nil) ].compact_blank.join(" · ") %></p>
14
+ <div class="hrl-chiprow">
15
+ <% HrLite::HiringCandidate::STAGES.each do |stage| %>
16
+ <% if (n = @counts[[ campaign.id, stage ]]) %><span class="hrl-badge hrl-badge--muted"><%= n %> <%= stage.humanize.downcase %></span><% end %>
17
+ <% end %>
18
+ </div>
19
+ </section>
20
+ <% end %>
21
+ <%= hrl_pagination %>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "New campaign" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">New hiring campaign</h1></div>
3
+ <section class="hrl-card"><%= render "form", campaign: @campaign %></section>
@@ -0,0 +1,62 @@
1
+ <% content_for(:page_title) { "Check the import" } %>
2
+ <% counts = @import.counts %>
3
+ <div class="hrl-page__head">
4
+ <h1 class="hrl-page__title">Check the import</h1>
5
+ <p class="hrl-page__sub"><%= @campaign.title %></p>
6
+ </div>
7
+ <div class="hrl-kpis">
8
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= counts["create"] %></div><div class="hrl-kpi__label">New</div></div>
9
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= counts["update"] %></div><div class="hrl-kpi__label">Update</div></div>
10
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= counts["skip"] %></div><div class="hrl-kpi__label">Skip</div></div>
11
+ </div>
12
+
13
+ <%= form_with url: hr_lite.import_admin_hiring_campaign_path(@campaign), method: :post, local: true do %>
14
+ <input type="hidden" name="rows" value="<%= @sealed %>">
15
+ <% if @import.conflicts.any? %>
16
+ <section class="hrl-card hrl-card--warn">
17
+ <h2 class="hrl-card__title">Pick one column for each field</h2>
18
+ <% @import.conflicts.each do |field, columns| %>
19
+ <fieldset class="hrl-field">
20
+ <legend><%= field.humanize %></legend>
21
+ <% columns.each do |column| %>
22
+ <label class="hrl-field--check"><input type="radio" name="picks[<%= field %>]" value="<%= column.index %>" required> “<%= column.header %>”</label>
23
+ <% end %>
24
+ </fieldset>
25
+ <% end %>
26
+ <p class="hrl-small hrl-muted">The column you do not pick is kept as extra data.</p>
27
+ <button class="hrl-btn" type="submit">Update preview</button>
28
+ </section>
29
+ <% end %>
30
+
31
+ <section class="hrl-card">
32
+ <h2 class="hrl-card__title">Columns</h2>
33
+ <div class="hrl-table-wrap">
34
+ <table class="hrl-table hrl-table--stack hrl-table--tight">
35
+ <thead><tr><th>In the sheet</th><th>Saved as</th></tr></thead>
36
+ <tbody>
37
+ <% @import.columns.each do |column| %>
38
+ <tr>
39
+ <td data-label="In the sheet"><%= column.header.presence || "(no header)" %></td>
40
+ <td data-label="Saved as"><%= column.field ? (column.field == "cv_url" ? "CV link" : column.field.humanize) : "Extra: @#{column.extra_key}" %></td>
41
+ </tr>
42
+ <% end %>
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ </section>
47
+
48
+ <% skipped = @import.rows.select { |row| row.outcome == "skip" } %>
49
+ <% if skipped.any? %>
50
+ <section class="hrl-card">
51
+ <h2 class="hrl-card__title">Rows that will be skipped</h2>
52
+ <ul class="hrl-feed"><% skipped.each do |row| %><li class="hrl-feed__item">Row <%= row.number %>: <%= row.reason %></li><% end %></ul>
53
+ </section>
54
+ <% end %>
55
+
56
+ <div class="hrl-form-actions">
57
+ <% if @import.conflicts.empty? %>
58
+ <button class="hrl-btn hrl-btn--primary" type="submit" name="confirm" value="1">Import <%= counts["create"] + counts["update"] %> candidates</button>
59
+ <% end %>
60
+ <a class="hrl-btn" href="<%= hr_lite.admin_hiring_campaign_path(@campaign) %>">Cancel</a>
61
+ </div>
62
+ <% end %>