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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f779cdbd416cb664617888bbb71b2c6866742b52e3ad5525ac2c5819a9b9908
4
- data.tar.gz: 346563b5b820eabc5aec4d83076cb1a3dd37f6309c159ad15c7a3f70698032a3
3
+ metadata.gz: 7771723b1709c1d61c4b26737d76980d3eb00d0896f68550380e8c8605e35f77
4
+ data.tar.gz: 072a2fd9cc465306163c5bf3005eb8d9d69609f5122f05cd4c93839fc5b15b5f
5
5
  SHA512:
6
- metadata.gz: 682e1b50a140c01d357a77666d721629e713dc8c1f97465ea8943c92b8d0cee5a5e47933c98f9451d5e80019bdc548879a27f1c72002279a19ab051500873ce6
7
- data.tar.gz: 57fa8aa33593a9f4ac0d88abf82dcc47dd32f666872924038ffb1de4754cb981312fec76e23fcd9706bc31b2aa75a3dc40a42bd25b8d4c947d26705b2f356f2c
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
- 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}"
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
- @reply.submit!(interested: params[:interested].to_s, date: params[:date], slot: params[:slot], joining: params[:joining],
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 # candidates per slot per day; HR calls each to confirm
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, "That slot just filled up. Pick another one." if self.class.slot_full?(date, slot)
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
- def initialize(candidate, sender_name:)
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">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 %>
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="<%= HrLite::HiringReply::SLOT_START[picked&.preferred_slot] %>"></div>
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} 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)) } } %>
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>Slot</dt><dd><%= reply.preferred_date.strftime("%A, %-d %B") %>, <%= HrLite::HiringReply::SLOTS[reply.preferred_slot] %></dd><% end %>
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"><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" %>">
@@ -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>
@@ -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
- <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>
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 is 2 hours. HR will call you to confirm.</p>
29
- <fieldset class="hrl-field">
30
- <legend>Day</legend>
31
- <% HrLite::HiringReply.dates(allow_sunday: campaign.allow_sunday).each do |day| %>
32
- <label class="hrl-field--check"><input type="radio" name="date" value="<%= day.iso8601 %>" <%= "checked" if params[:date] == day.iso8601 %>> <%= day.strftime("%A, %-d %B") %></label>
33
- <% end %>
34
- </fieldset>
35
- <fieldset class="hrl-field">
36
- <legend>Time slot</legend>
37
- <% HrLite::HiringReply::SLOTS.each do |key, label| %>
38
- <label class="hrl-field--check"><input type="radio" name="slot" value="<%= key %>" <%= "checked" if params[:slot] == key %>> <%= label %></label>
39
- <% end %>
40
- </fieldset>
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
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.62.0"
2
+ VERSION = "0.64.1"
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.62.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