hr_lite 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86b218a8a46bb4b5c3527df1fb637aee4b7617e8ef8abfd4e018274bc08e05af
4
- data.tar.gz: 3e645c3511f89479289ac9169317183232d54c7443846208062d9b764bf692f0
3
+ metadata.gz: e8bac47ad9084b5f1b56da93a9a4658b0657ad90078ad5a51cda3de73d46e93a
4
+ data.tar.gz: e0a6f0dadbda02b024ece4ff00292586077c815022f71cea914a342ccc4a14df
5
5
  SHA512:
6
- metadata.gz: 8ac6f7b8261f2dbaa74acb697effd3deb137d6f3d84647df9260c602ea457d7cb7067b631bdcbef4c09d56555f5688884bcac7607b48ae52002b0946291e4597
7
- data.tar.gz: dfd0bb1099a90b72051f91569303e321d1d3a4cd216baf3cd9ba11f4dae720e5738be651fb584863b2fbe080702f0b8b517c70f49c9223f3ae6563cf72c0b6e6
6
+ metadata.gz: 36a34342b03fe802f84fe9cde43196dc22beeac2fffeb17572d79e8e5cf3b15a4efca9a59616c598f91e7d37efce0fffc8bb7830d35997015982f009477cbeb8
7
+ data.tar.gz: 45a7023d60f36f461dce9fb26482622fb69ef72464b5ff58043284dd785edf2d5ee714c40bec8ffd20f4f5ec07cd905b9f18553c5f1e54eee39bccd31885d385
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.65.0] - 2026-10-28
11
+
12
+ Expense claims have categories to pick from. No migrations.
13
+
14
+ - **`hr_lite:seed` adds 15 travel-company expense categories** (local conveyance, flights and trains, hotel stay, meals, client meetings, site inspections, visa fees, fuel and tolls, airport transfers, phone, courier, printing, trade fairs, tips, Other) when the table is empty. The "What kind of expense" picker was blank on installs nobody had configured. Every category needs a receipt except tips; "Other" uses the required "What it was for" note. An install that already has categories is left alone.
15
+
16
+ ## [0.64.2] - 2026-10-24
17
+
18
+ Leadership's leave-decision emails open the leave. No migrations.
19
+
20
+ - **"Leave approved/rejected" emails to leadership linked to the employee's own page** (`/leave_requests/:id`), which 404s for anyone else. Leadership's copy now names the employee and links to `/admin/leave_requests/:id`. The employee's email is unchanged.
21
+
22
+ ## [0.64.1] - 2026-10-23
23
+
24
+ Interview slots hand out 30-minute turns. No migrations.
25
+
26
+ - **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".
27
+ - **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.
28
+ - **No email box on the reply page.** Each link already identifies its candidate, and the page still says "This form is for <name>".
29
+
10
30
  ## [0.64.0] - 2026-10-22
11
31
 
12
32
  Decision emails. One migration: `purpose` on hiring templates.
@@ -19,7 +19,8 @@ module HrLite
19
19
  def show; end
20
20
 
21
21
  def create
22
- @reply.submit!(email: params[:email], 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)
@@ -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
@@ -61,10 +81,8 @@ module HrLite
61
81
 
62
82
  # Saves the reply, moves round 1's candidate to interested / not interested,
63
83
  # puts it on their timeline and rings the HR person who emailed them.
64
- def submit!(email: nil, interested: nil, date: nil, slot: nil, joining: nil, yes_no: {}, alternate_phone: nil, files: {}, kyc: {})
84
+ def submit!(interested: nil, date: nil, slot: nil, joining: nil, yes_no: {}, alternate_phone: nil, files: {}, kyc: {})
65
85
  raise ArgumentError, "You have already replied. Thank you." if responded_at
66
- # The link is personal: the email it was sent to proves the right person opened it.
67
- raise ArgumentError, "That email does not match this link. Use the link from your own email." unless email.to_s.strip.casecmp?(candidate.email)
68
86
 
69
87
  assign_kyc!(kyc) if round == 3
70
88
 
@@ -76,7 +94,7 @@ module HrLite
76
94
  date = self.class.dates(allow_sunday: candidate.campaign.allow_sunday).find { |day| day.iso8601 == date.to_s }
77
95
  raise ArgumentError, "Pick a day for the interview." unless date
78
96
  raise ArgumentError, "Pick a time slot." unless SLOTS.key?(slot)
79
- raise ArgumentError, "That slot just filled up. Pick another one." if self.class.slot_full?(date, slot)
97
+ raise ArgumentError, "Someone just booked that slot. Pick another one." if self.class.slot_full?(date, slot)
80
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/)
81
99
 
82
100
  if round == 1
@@ -310,7 +310,10 @@ module HrLite
310
310
  body: decision_note.presence,
311
311
  path: "/leave_requests/#{id}",
312
312
  bell_to: [ user ],
313
- email_to: [ user ]
313
+ email_to: [ user ],
314
+ # The employee's path 404s for anyone else; leadership opens the admin record.
315
+ leadership: { title: "#{title} — #{HrLite.display_name(user)}, #{leave_type.name} (#{date_range_label})",
316
+ path: "/admin/leave_requests/#{id}" }
314
317
  )
315
318
  end
316
319
 
@@ -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>
@@ -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 %>
@@ -9,17 +9,15 @@
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 %>
16
- <section class="hrl-card">
17
- <div class="hrl-field">
18
- <label for="email">Your email (the one this link was sent to)</label>
19
- <input type="email" name="email" id="email" autocomplete="email" required value="<%= params[:email] %>">
20
- </div>
21
- </section>
22
-
23
21
  <% if round == 1 %>
24
22
  <section class="hrl-card">
25
23
  <fieldset class="hrl-field">
@@ -32,19 +30,20 @@
32
30
 
33
31
  <section class="hrl-card">
34
32
  <h2 class="hrl-card__title"><%= round == 1 ? "If yes: pick" : "Pick" %> a day and one time slot for round <%= round %></h2>
35
- <p class="hrl-small hrl-muted">Each slot is 2 hours. HR will call you to confirm.</p>
36
- <fieldset class="hrl-field">
37
- <legend>Day</legend>
38
- <% HrLite::HiringReply.dates(allow_sunday: campaign.allow_sunday).each do |day| %>
39
- <label class="hrl-field--check"><input type="radio" name="date" value="<%= day.iso8601 %>" <%= "checked" if params[:date] == day.iso8601 %>> <%= day.strftime("%A, %-d %B") %></label>
40
- <% end %>
41
- </fieldset>
42
- <fieldset class="hrl-field">
43
- <legend>Time slot</legend>
44
- <% HrLite::HiringReply::SLOTS.each do |key, label| %>
45
- <label class="hrl-field--check"><input type="radio" name="slot" value="<%= key %>" <%= "checked" if params[:slot] == key %>> <%= label %></label>
46
- <% end %>
47
- </fieldset>
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 %>
48
47
  <div class="hrl-field">
49
48
  <label for="alternate_phone">Alternate phone number (optional)</label>
50
49
  <input type="tel" name="alternate_phone" id="alternate_phone" inputmode="tel" autocomplete="tel" value="<%= params[:alternate_phone] %>">
data/lib/hr_lite/seeds.rb CHANGED
@@ -10,8 +10,28 @@ module HrLite
10
10
  { code: "CO", name: "Comp off", color: "#8b5cf6", paid: true, annual_quota: 0, accrual: "yearly_upfront", carry_forward_cap: 0, position: 5, comp_off: true }
11
11
  ].freeze
12
12
 
13
+ # A travel company's staff claims. "Other" takes anything else; the
14
+ # required "What it was for" field carries the note.
15
+ DEFAULT_EXPENSE_CATEGORIES = [
16
+ "Local conveyance (cab, auto, metro)", "Flights and trains", "Hotel stay",
17
+ "Meals on duty", "Client meeting and entertainment", "Site inspection and FAM trip",
18
+ "Visa, permit and passport fees", "Fuel, tolls and parking", "Airport transfers",
19
+ "Phone and internet", "Courier and documents", "Printing and stationery",
20
+ "Trade fair and events", "Tips and guide payments", "Other"
21
+ ].freeze
22
+
13
23
  def self.run!
14
- seed_leave_types! + seed_holidays!
24
+ seed_leave_types! + seed_holidays! + seed_expense_categories!
25
+ end
26
+
27
+ # Only into an empty table, so renamed or removed categories stay that way.
28
+ def self.seed_expense_categories!
29
+ return [] if ExpenseCategory.exists?
30
+
31
+ DEFAULT_EXPENSE_CATEGORIES.map do |name|
32
+ ExpenseCategory.create!(name: name, receipt_required: name != "Tips and guide payments")
33
+ "expense_category #{name}"
34
+ end
15
35
  end
16
36
 
17
37
  # Deliberately NOT auto-run from run!: an operator unticking every
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.64.0"
2
+ VERSION = "0.65.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.64.0
4
+ version: 0.65.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha