hr_lite 0.62.0 → 0.64.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +4 -0
- data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +16 -1
- data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +7 -4
- data/app/controllers/hr_lite/hiring_replies_controller.rb +2 -1
- data/app/helpers/hr_lite/application_helper.rb +1 -0
- data/app/models/hr_lite/attendance_penalty.rb +13 -0
- data/app/models/hr_lite/hiring_reply.rb +22 -2
- data/app/models/hr_lite/hiring_template.rb +8 -0
- data/app/services/hr_lite/hiring_merge.rb +5 -2
- data/app/views/hr_lite/admin/hiring_candidates/show.html.erb +5 -5
- data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +4 -0
- data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +4 -1
- data/app/views/hr_lite/admin/hiring_templates/index.html.erb +22 -1
- data/app/views/hr_lite/admin/hiring_templates/new.html.erb +4 -1
- data/app/views/hr_lite/attendance/_penalties.html.erb +12 -6
- data/app/views/hr_lite/hiring_replies/show.html.erb +21 -15
- data/db/migrate/20261022120000_add_purpose_to_hr_lite_hiring_templates.rb +8 -0
- data/lib/hr_lite/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7771723b1709c1d61c4b26737d76980d3eb00d0896f68550380e8c8605e35f77
|
|
4
|
+
data.tar.gz: 072a2fd9cc465306163c5bf3005eb8d9d69609f5122f05cd4c93839fc5b15b5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac2806cb7d5c5d503b8f9136cc893166f3f1d6e7b46e6d49c25a1be7b0aba8c4b0c66eeaffd22229d96efbe89f14d5c3b510019da338697bda771460854b939b
|
|
7
|
+
data.tar.gz: d39889da25d341b8eb2d5353ade577229971f2c6eee5d8028f722af6115ec0bb3b76404761b683dc6c25dd5c04d67407ddd79fe5d905e076492d2aa010a84d06
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.64.1] - 2026-10-23
|
|
11
|
+
|
|
12
|
+
Interview slots hand out 30-minute turns. No migrations.
|
|
13
|
+
|
|
14
|
+
- **Up to 4 people per 2-hour slot, a 30-minute turn each.** Turns go in booking order: the 1st at the slot's start, the 2nd 30 minutes later, and so on. A full slot disappears from the form, and a day with no free slots says "Fully booked". A late fifth booking is refused with "Someone just booked that slot".
|
|
15
|
+
- **Everyone sees the turn.** The candidate's thank-you page shows their interview time. HR's candidate page shows it, and "Schedule round N" is prefilled with that start time, so HR never retypes it. Turns are worked out from booking order and never stored.
|
|
16
|
+
- **No email box on the reply page.** Each link already identifies its candidate, and the page still says "This form is for <name>".
|
|
17
|
+
|
|
18
|
+
## [0.64.0] - 2026-10-22
|
|
19
|
+
|
|
20
|
+
Decision emails. One migration: `purpose` on hiring templates.
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
|
|
25
|
+
## [0.63.1] - 2026-10-22
|
|
26
|
+
|
|
27
|
+
A reply link can only be answered by the candidate it belongs to. No migrations.
|
|
28
|
+
|
|
29
|
+
- **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.
|
|
30
|
+
- **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.
|
|
31
|
+
|
|
32
|
+
## [0.63.0] - 2026-10-22
|
|
33
|
+
|
|
34
|
+
Every penalty row now says why it exists. No migrations.
|
|
35
|
+
|
|
36
|
+
- **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.
|
|
37
|
+
- **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.
|
|
38
|
+
|
|
10
39
|
## [0.62.0] - 2026-10-22
|
|
11
40
|
|
|
12
41
|
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.
|
|
@@ -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 {
|
|
@@ -38,10 +38,25 @@ module HrLite
|
|
|
38
38
|
render :show
|
|
39
39
|
end
|
|
40
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.
|
|
41
43
|
def stage
|
|
42
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
|
+
|
|
43
54
|
@candidate.move_to!(to, reason: params[:reason], note: params[:note])
|
|
44
|
-
|
|
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}"
|
|
45
60
|
rescue ArgumentError => e
|
|
46
61
|
redirect_to hr_lite.admin_hiring_candidate_path(@candidate), alert: e.message
|
|
47
62
|
end
|
|
@@ -52,7 +52,7 @@ module HrLite
|
|
|
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
|
|
@@ -10,13 +10,16 @@ module HrLite
|
|
|
10
10
|
with: -> { redirect_to hr_lite.admin_hiring_templates_path, alert: "Twenty AI drafts an hour is the limit. Try again later." }
|
|
11
11
|
|
|
12
12
|
def index = @templates = HiringTemplate.alphabetical
|
|
13
|
-
def new = @template = HiringTemplate.new
|
|
13
|
+
def new = @template = HiringTemplate.new(purpose: params[:purpose].presence_in(HiringTemplate::PURPOSES.keys))
|
|
14
14
|
|
|
15
15
|
def create
|
|
16
|
-
@template = HiringTemplate.new(template_params)
|
|
16
|
+
@template = HiringTemplate.new(template_params.merge(purpose: params.dig(:hiring_template, :purpose).presence))
|
|
17
17
|
return draft if params[:ai].present?
|
|
18
18
|
return render(:new, status: :unprocessable_entity) unless @template.save
|
|
19
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
|
+
|
|
20
23
|
redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
|
|
21
24
|
end
|
|
22
25
|
|
|
@@ -58,10 +61,10 @@ module HrLite
|
|
|
58
61
|
return true unless @preview_for
|
|
59
62
|
|
|
60
63
|
sender_name = HrLite.config.hiring&.sender&.dig(:name)
|
|
61
|
-
@preview = HiringMerge.new(@preview_for, sender_name:).render(@template)
|
|
64
|
+
@preview = HiringMerge.new(@preview_for, sender_name:, preview: true).render(@template)
|
|
62
65
|
# Readiness across the previewed candidate's campaign: who a send would reach.
|
|
63
66
|
@campaign_size = @preview_for.campaign.candidates.count
|
|
64
|
-
@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? }
|
|
65
68
|
true
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -19,7 +19,8 @@ module HrLite
|
|
|
19
19
|
def show; end
|
|
20
20
|
|
|
21
21
|
def create
|
|
22
|
-
|
|
22
|
+
date, slot = params[:pick].to_s.split("|", 2) # one radio per free day + slot
|
|
23
|
+
@reply.submit!(interested: params[:interested].to_s, date: date, slot: slot, joining: params[:joining],
|
|
23
24
|
yes_no: params.permit(*HiringReply::YES_NO.keys).to_h, alternate_phone: params[:alternate_phone].to_s.strip,
|
|
24
25
|
files: params.fetch(:documents, {}).permit(*HiringReply::DOCUMENTS.fetch(@reply.round, {}).keys).to_h,
|
|
25
26
|
kyc: params.permit(:aadhaar_number, :pan_number, :pan_pending, :no_salary_slips, :consent).to_h.symbolize_keys)
|
|
@@ -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
|
|
@@ -5,7 +5,8 @@ module HrLite
|
|
|
5
5
|
class HiringReply < ApplicationRecord
|
|
6
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
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 #
|
|
8
|
+
SLOT_CAPACITY = 4 # people per 2-hour slot per day, then it is gone from the form
|
|
9
|
+
TURN = 30.minutes # each booking gets its own turn, in the order they booked
|
|
9
10
|
JOINING = { "immediately" => "Immediately", "two_weeks" => "Within 2 weeks", "one_month" => "Within a month" }.freeze
|
|
10
11
|
YES_NO = { "stipend_ok" => "Fine with the stipend", "location_ok" => "Fine with the office location",
|
|
11
12
|
"schedule_ok" => "Fine with the schedule", "programme_ok" => "Commits to the programme" }.freeze
|
|
@@ -36,6 +37,13 @@ module HrLite
|
|
|
36
37
|
|
|
37
38
|
def self.slot_full?(date, slot) = responded.where(preferred_date: date, preferred_slot: slot).count >= SLOT_CAPACITY
|
|
38
39
|
|
|
40
|
+
# The slots still free on each offered day, for the reply form.
|
|
41
|
+
def self.open_slots(allow_sunday:)
|
|
42
|
+
days = dates(allow_sunday: allow_sunday)
|
|
43
|
+
booked = responded.where(preferred_date: days).group(:preferred_date, :preferred_slot).count
|
|
44
|
+
days.to_h { |day| [ day, SLOTS.keys.reject { |slot| booked[[ day, slot ]].to_i >= SLOT_CAPACITY } ] }
|
|
45
|
+
end
|
|
46
|
+
|
|
39
47
|
# The next ten interview days. Sunday is off unless the campaign allows it.
|
|
40
48
|
def self.dates(allow_sunday:, from: Date.current) = (1..20).map { |i| from + i }.reject { |d| d.sunday? && !allow_sunday }.first(10)
|
|
41
49
|
|
|
@@ -45,6 +53,18 @@ module HrLite
|
|
|
45
53
|
end
|
|
46
54
|
|
|
47
55
|
# What this round asks for; round 3 skips PAN and slips the candidate says they lack.
|
|
56
|
+
# Their 30-minute turn inside the slot: 1st booking at the start, 2nd 30
|
|
57
|
+
# minutes later, and so on. Worked out from booking order, never stored.
|
|
58
|
+
def turn_starts_at
|
|
59
|
+
return unless responded_at && preferred_slot
|
|
60
|
+
|
|
61
|
+
earlier = self.class.responded.where(preferred_date:, preferred_slot:)
|
|
62
|
+
.where("responded_at < :at OR (responded_at = :at AND id < :id)", at: responded_at, id: id).count
|
|
63
|
+
Time.zone.parse("#{preferred_date} #{SLOT_START.fetch(preferred_slot)}") + (TURN * earlier)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def turn_label = (at = turn_starts_at) && "#{at.strftime('%-l:%M')}–#{(at + TURN).strftime('%-l:%M %p')}"
|
|
67
|
+
|
|
48
68
|
def documents_asked
|
|
49
69
|
asked = DOCUMENTS.fetch(round, {})
|
|
50
70
|
asked = asked.except("pan") if pan_pending
|
|
@@ -74,7 +94,7 @@ module HrLite
|
|
|
74
94
|
date = self.class.dates(allow_sunday: candidate.campaign.allow_sunday).find { |day| day.iso8601 == date.to_s }
|
|
75
95
|
raise ArgumentError, "Pick a day for the interview." unless date
|
|
76
96
|
raise ArgumentError, "Pick a time slot." unless SLOTS.key?(slot)
|
|
77
|
-
raise ArgumentError, "
|
|
97
|
+
raise ArgumentError, "Someone just booked that slot. Pick another one." if self.class.slot_full?(date, slot)
|
|
78
98
|
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
99
|
|
|
80
100
|
if round == 1
|
|
@@ -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
|
|
@@ -14,8 +14,11 @@ module HrLite
|
|
|
14
14
|
|
|
15
15
|
def self.tokens(text) = text.to_s.scan(TOKEN).flatten.uniq
|
|
16
16
|
|
|
17
|
-
|
|
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)
|
|
18
20
|
@extra = candidate.extra.to_h.transform_keys(&:to_s)
|
|
21
|
+
@preview = preview
|
|
19
22
|
@values = @extra.merge(reserved(candidate, sender_name))
|
|
20
23
|
end
|
|
21
24
|
|
|
@@ -48,7 +51,7 @@ module HrLite
|
|
|
48
51
|
"interview_date" => at&.strftime("%-d %B %Y"), "interview_time" => at&.strftime("%-l:%M %p"),
|
|
49
52
|
"meet_link" => interview&.meet_link, "sender_name" => sender_name,
|
|
50
53
|
# 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}") } ] } }
|
|
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}") } ] } }
|
|
52
55
|
end
|
|
53
56
|
end
|
|
54
57
|
end
|
|
@@ -34,9 +34,9 @@
|
|
|
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
36
|
<% picked = @next_reply %>
|
|
37
|
-
<% if picked %><p class="hrl-small">
|
|
37
|
+
<% if picked %><p class="hrl-small">Their turn: <strong><%= picked.preferred_date.strftime("%A, %-d %B") %>, <%= picked.turn_label %></strong> (in the <%= HrLite::HiringReply::SLOTS[picked.preferred_slot] %> slot). Call to confirm, then send the invite.</p><% end %>
|
|
38
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="<%=
|
|
39
|
+
<div class="hrl-field"><label for="time">Time</label><input type="time" name="time" id="time" required value="<%= picked&.turn_starts_at&.strftime("%H:%M") %>"></div>
|
|
40
40
|
<div class="hrl-field">
|
|
41
41
|
<label for="minutes">Length</label>
|
|
42
42
|
<select name="minutes" id="minutes"><% HrLite::Admin::HiringInterviewsController::LENGTHS.each do |m| %><option value="<%= m %>"><%= m %> minutes</option><% end %></select>
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
<div class="hrl-form-actions hrl-hiring-decide">
|
|
59
59
|
<% (manual - [ "rejected" ]).each do |to| %>
|
|
60
60
|
<%= button_to to.humanize, hr_lite.stage_admin_hiring_candidate_path(@candidate), method: :patch, params: { to: to }, class: "hrl-btn",
|
|
61
|
-
form: { data: { turbo_confirm: ("Mark #{@candidate.name}
|
|
61
|
+
form: { data: { turbo_confirm: ("Mark #{@candidate.name} #{to.humanize.downcase}?#{" This is final." if to == "selected"}#{" They get the #{to.humanize} email." if HrLite::HiringTemplate::PURPOSES.key?(to)}" if HrLite::HiringTemplate::PURPOSES.key?(to)) } } %>
|
|
62
62
|
<% end %>
|
|
63
63
|
</div>
|
|
64
64
|
<% if manual.include?("rejected") %>
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
</select>
|
|
75
75
|
</div>
|
|
76
76
|
<div class="hrl-field"><label for="note">Note (needed for “Other”)</label><textarea name="note" id="note" rows="2"></textarea></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.">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>
|
|
78
78
|
<% end %>
|
|
79
79
|
</details>
|
|
80
80
|
<% end %>
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
<h2 class="hrl-card__title">Round <%= reply.round %> reply</h2>
|
|
88
88
|
<dl class="hrl-deflist">
|
|
89
89
|
<% if reply.round == 1 %><dt>Interested</dt><dd><%= reply.answers["interested"] == "yes" ? "Yes" : "No" %></dd><% end %>
|
|
90
|
-
<% if reply.preferred_slot %><dt>
|
|
90
|
+
<% if reply.preferred_slot %><dt>Interview time</dt><dd><%= reply.preferred_date.strftime("%A, %-d %B") %>, <%= reply.turn_label %> (<%= HrLite::HiringReply::SLOTS[reply.preferred_slot] %> slot)</dd><% end %>
|
|
91
91
|
<% if reply.answers["joining"] %>
|
|
92
92
|
<dt>Can join</dt><dd><%= HrLite::HiringReply::JOINING[reply.answers["joining"]] %></dd>
|
|
93
93
|
<% HrLite::HiringReply::YES_NO.each do |key, label| %><dt><%= label %></dt><dd><%= reply.answers[key] == "yes" ? "Yes" : "No" %></dd><% end %>
|
|
@@ -1,5 +1,9 @@
|
|
|
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
|
+
<% 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 %>
|
|
3
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>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<% content_for(:page_title) { @template.name } %>
|
|
2
|
-
<div class="hrl-page__head"
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= @template.name %></h1>
|
|
4
|
+
<% if @template.purpose %><p class="hrl-page__sub"><span class="hrl-badge">Decision email: <%= HrLite::HiringTemplate::PURPOSES[@template.purpose] %></span> Sent when you click <%= HrLite::HiringTemplate::PURPOSES[@template.purpose] %>. It can be edited, not deleted.</p><% end %>
|
|
5
|
+
</div>
|
|
3
6
|
|
|
4
7
|
<% if @preview %>
|
|
5
8
|
<div class="hrl-flash <%= @ready == @campaign_size ? "hrl-flash--ok" : "hrl-flash--warn" %>">
|
|
@@ -3,9 +3,30 @@
|
|
|
3
3
|
<h1 class="hrl-page__title">Hiring email templates</h1>
|
|
4
4
|
<div class="hrl-page__actions"><a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_hiring_template_path %>">New template</a></div>
|
|
5
5
|
</div>
|
|
6
|
+
|
|
6
7
|
<section class="hrl-card">
|
|
8
|
+
<h2 class="hrl-card__title">Decision emails</h2>
|
|
9
|
+
<p class="hrl-small hrl-muted">Sent when you click Selected, Rejected or On hold on a candidate. You can edit them; they cannot be deleted.</p>
|
|
7
10
|
<ul class="hrl-feed">
|
|
8
|
-
<% @templates.
|
|
11
|
+
<% decisions = @templates.select(&:purpose).index_by(&:purpose) %>
|
|
12
|
+
<% HrLite::HiringTemplate::PURPOSES.each do |purpose, label| %>
|
|
13
|
+
<li class="hrl-feed__item"><div class="hrl-feed__body">
|
|
14
|
+
<% if (template = decisions[purpose]) %>
|
|
15
|
+
<a class="hrl-card__link" href="<%= hr_lite.edit_admin_hiring_template_path(template) %>"><%= label %>: <%= template.name %></a>
|
|
16
|
+
<span class="hrl-feed__meta"><%= template.subject %></span>
|
|
17
|
+
<% else %>
|
|
18
|
+
<strong><%= label %></strong> <span class="hrl-badge hrl-badge--warn">Not set</span>
|
|
19
|
+
<a class="hrl-btn" href="<%= hr_lite.new_admin_hiring_template_path(purpose: purpose) %>">Write the <%= label %> email</a>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div></li>
|
|
22
|
+
<% end %>
|
|
23
|
+
</ul>
|
|
24
|
+
</section>
|
|
25
|
+
|
|
26
|
+
<section class="hrl-card">
|
|
27
|
+
<h2 class="hrl-card__title">Other emails</h2>
|
|
28
|
+
<ul class="hrl-feed">
|
|
29
|
+
<% @templates.reject(&:purpose).each do |template| %>
|
|
9
30
|
<li class="hrl-feed__item"><div class="hrl-feed__body">
|
|
10
31
|
<a class="hrl-card__link" href="<%= hr_lite.edit_admin_hiring_template_path(template) %>"><%= template.name %></a>
|
|
11
32
|
<span class="hrl-feed__meta"><%= template.subject %></span>
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
<% content_for(:page_title) { "New template" } %>
|
|
2
|
-
<div class="hrl-page__head"
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= @template.purpose ? "The #{HrLite::HiringTemplate::PURPOSES[@template.purpose]} email" : "New hiring email" %></h1>
|
|
4
|
+
<% if @template.purpose %><p class="hrl-page__sub">Sent every time someone clicks <%= HrLite::HiringTemplate::PURPOSES[@template.purpose] %> on a candidate. Paste what you would write, then use Write with AI to tidy it and add the fields.</p><% end %>
|
|
5
|
+
</div>
|
|
3
6
|
<section class="hrl-card"><%= render "form", template: @template %></section>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<section class="hrl-card" id="penalties">
|
|
5
5
|
<h2 class="hrl-card__title">Penalty hours</h2>
|
|
6
6
|
<p><strong><%= hrl_duration(held * 60) %></strong> held · <%= pluralize(hrl_days(HrLite::AttendancePenalty.days_for(held)), "day") %><%= " of #{leave_type.name}" if leave_type %></p>
|
|
7
|
-
<p class="hrl-hint">Penalties add up until HR removes them.<% if leave_type %> Every 5 hours holds half a day of leave, which can't be applied for.<% end
|
|
7
|
+
<p class="hrl-hint">Penalties add up until HR removes them.<% if leave_type %> Every 5 hours holds half a day of leave, which can't be applied for.<% end %> To avoid more, check in by your slot and stay until the required checkout. If a punch was wrong, fix that day and ask HR to remove the penalties it caused.</p>
|
|
8
8
|
<% HrLite::PenaltyRemoval.where(user_id: user.id).includes(:removed_by).order(created_at: :desc).each do |removal| %>
|
|
9
9
|
<div class="hrl-feed__item hrl-small"><div class="hrl-feed__body">Removed <%= hrl_duration(removal.minutes * 60) %> on <%= removal.created_at.strftime("%-d %b %Y") %> by <%= hr_display_name(removal.removed_by) %>: <%= removal.reason %></div></div>
|
|
10
10
|
<% end %>
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<% end %>
|
|
17
17
|
<% end %>
|
|
18
18
|
</section>
|
|
19
|
-
<% penalties = HrLite::AttendancePenalty.where(user_id: user.id, date: month.all_month).order(:date, :id) %>
|
|
19
|
+
<% penalties = HrLite::AttendancePenalty.where(user_id: user.id, date: month.all_month).includes(:attendance_record).order(:date, :id) %>
|
|
20
20
|
<section class="hrl-card">
|
|
21
21
|
<h2 class="hrl-card__title">Penalties in <%= month.strftime("%B") %></h2>
|
|
22
22
|
<% if penalties.empty? %>
|
|
@@ -25,13 +25,19 @@
|
|
|
25
25
|
<% penalties.each do |penalty| %>
|
|
26
26
|
<div class="hrl-feed__item hrl-small">
|
|
27
27
|
<div class="hrl-feed__body">
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
<details class="hrl-why">
|
|
29
|
+
<summary>
|
|
30
|
+
<span><strong><%= penalty.date.strftime("%a %-d %b") %></strong> ·
|
|
31
|
+
<span class="<%= class_names("hrl-badge", penalty.removed_at ? "hrl-badge--muted" : "hrl-badge--warn") %>">+<%= penalty.minutes %> min</span>
|
|
32
|
+
<%= penalty.label %></span>
|
|
33
|
+
<%= hrl_icon("Info") %><span class="hrl-sr-only">Why?</span>
|
|
34
|
+
</summary>
|
|
35
|
+
<div class="hrl-muted"><%= penalty.cause %></div>
|
|
36
|
+
</details>
|
|
31
37
|
<% if penalty.removed_at %>
|
|
32
38
|
<div class="hrl-muted">Removed by HR: <%= penalty.removal_reason %></div>
|
|
33
39
|
<% elsif local_assigns[:removable] %>
|
|
34
|
-
<%= form_with url: hr_lite.remove_penalty_admin_attendance_path(user.id, penalty_id: penalty.id), method: :post, local: true, class: "hrl-row" do %>
|
|
40
|
+
<%= form_with url: hr_lite.remove_penalty_admin_attendance_path(user.id, penalty_id: penalty.id), method: :post, local: true, class: "hrl-row hrl-mt" do %>
|
|
35
41
|
<label class="hrl-sr-only" for="hrl-remove-<%= penalty.id %>">Reason to remove</label>
|
|
36
42
|
<input type="text" class="hrl-input" name="removal_reason" id="hrl-remove-<%= penalty.id %>" required placeholder="Reason to remove">
|
|
37
43
|
<button type="submit" class="hrl-btn">Remove</button>
|
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
<% content_for(:page_title) { campaign.position } %>
|
|
4
4
|
<div class="hrl-page__head">
|
|
5
5
|
<h1 class="hrl-page__title"><%= campaign.position %></h1>
|
|
6
|
-
<p class="hrl-page__sub">Hi <%= @candidate.first_name %>, <%= round == 1 ? "thank you for your interest." : "congratulations on reaching round #{round}." %></p>
|
|
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
7
|
</div>
|
|
8
8
|
|
|
9
9
|
<% if @reply.responded_at %>
|
|
10
10
|
<section class="hrl-card">
|
|
11
11
|
<h2 class="hrl-card__title">Thanks, we have your answers</h2>
|
|
12
|
-
|
|
12
|
+
<% if @reply.answers["interested"] == "no" %>
|
|
13
|
+
<p>We have noted that you do not want to go ahead. All the best.</p>
|
|
14
|
+
<% else %>
|
|
15
|
+
<% if (turn = @reply.turn_label) %><p>Your round <%= round %> interview time: <strong><%= @reply.preferred_date.strftime("%A, %-d %B") %>, <%= turn %></strong>.</p><% end %>
|
|
16
|
+
<p>Our HR team will call you to confirm it.</p>
|
|
17
|
+
<% end %>
|
|
13
18
|
</section>
|
|
14
19
|
<% else %>
|
|
15
20
|
<%= form_with url: hr_lite.hiring_reply_path(@reply.token), method: :post, local: true, multipart: true do %>
|
|
@@ -25,19 +30,20 @@
|
|
|
25
30
|
|
|
26
31
|
<section class="hrl-card">
|
|
27
32
|
<h2 class="hrl-card__title"><%= round == 1 ? "If yes: pick" : "Pick" %> a day and one time slot for round <%= round %></h2>
|
|
28
|
-
<p class="hrl-small hrl-muted">Each slot
|
|
29
|
-
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
<p class="hrl-small hrl-muted">Each 2-hour slot takes up to 4 people. You get your own 30-minute interview time inside it, in the order you book. HR will call you to confirm.</p>
|
|
34
|
+
<% HrLite::HiringReply.open_slots(allow_sunday: campaign.allow_sunday).each do |day, slots| %>
|
|
35
|
+
<fieldset class="hrl-field">
|
|
36
|
+
<legend><%= day.strftime("%A, %-d %B") %></legend>
|
|
37
|
+
<% if slots.empty? %>
|
|
38
|
+
<p class="hrl-small hrl-muted">Fully booked</p>
|
|
39
|
+
<% else %>
|
|
40
|
+
<% slots.each do |slot| %>
|
|
41
|
+
<% value = "#{day.iso8601}|#{slot}" %>
|
|
42
|
+
<label class="hrl-field--check"><input type="radio" name="pick" value="<%= value %>" <%= "checked" if params[:pick] == value %>> <%= HrLite::HiringReply::SLOTS[slot] %></label>
|
|
43
|
+
<% end %>
|
|
44
|
+
<% end %>
|
|
45
|
+
</fieldset>
|
|
46
|
+
<% end %>
|
|
41
47
|
<div class="hrl-field">
|
|
42
48
|
<label for="alternate_phone">Alternate phone number (optional)</label>
|
|
43
49
|
<input type="tel" name="alternate_phone" id="alternate_phone" inputmode="tel" autocomplete="tel" value="<%= params[:alternate_phone] %>">
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddPurposeToHrLiteHiringTemplates < ActiveRecord::Migration[8.1]
|
|
2
|
+
# The email a decision button sends (selected / rejected / on_hold). One per
|
|
3
|
+
# purpose; nil for ordinary templates.
|
|
4
|
+
def change
|
|
5
|
+
add_column :hr_lite_hiring_templates, :purpose, :string
|
|
6
|
+
add_index :hr_lite_hiring_templates, :purpose, unique: true, where: "purpose IS NOT NULL"
|
|
7
|
+
end
|
|
8
|
+
end
|
data/lib/hr_lite/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hr_lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.64.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -547,6 +547,7 @@ files:
|
|
|
547
547
|
- db/migrate/20261021100500_seed_hr_lite_hiring.rb
|
|
548
548
|
- db/migrate/20261022090000_create_hr_lite_hiring_replies.rb
|
|
549
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
|
|
550
551
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
551
552
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
552
553
|
- lib/generators/hr_lite/install/templates/initializer.rb
|