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,106 @@
1
+ <% content_for(:page_title) { @campaign.title } %>
2
+ <% content_for(:back) { "Campaigns" } %>
3
+ <% sender = HrLite.config.hiring&.sender %>
4
+ <div class="hrl-page__head">
5
+ <h1 class="hrl-page__title"><%= @campaign.title %></h1>
6
+ <p class="hrl-page__sub"><%= [ @campaign.position, @campaign.stipend, @campaign.location ].compact_blank.join(" · ") %></p>
7
+ <div class="hrl-page__actions">
8
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.import_admin_hiring_campaign_path(@campaign) %>">Upload sheet</a>
9
+ <a class="hrl-btn" href="<%= hr_lite.edit_admin_hiring_campaign_path(@campaign) %>">Edit offer</a>
10
+ </div>
11
+ </div>
12
+
13
+ <% unless sender&.dig(:ready) %>
14
+ <section class="hrl-card hrl-card--warn">
15
+ <p><strong>No hiring mailbox is connected.</strong> Emails and invites cannot go out yet.</p>
16
+ <% if sender&.dig(:connect_url) %><p><a class="hrl-btn" href="<%= sender[:connect_url] %>">Connect <%= sender[:email] %></a></p><% end %>
17
+ </section>
18
+ <% end %>
19
+
20
+ <div class="hrl-kpis">
21
+ <% @kpis.each do |label, (count, filter)| %>
22
+ <a class="hrl-kpi hrl-kpi--link" href="<%= hr_lite.admin_hiring_campaign_path(@campaign, filter) %>"><div class="hrl-kpi__value"><%= count %></div><div class="hrl-kpi__label"><%= label %></div></a>
23
+ <% end %>
24
+ </div>
25
+
26
+ <% if @attention.any? %>
27
+ <section class="hrl-card hrl-card--warn">
28
+ <h2 class="hrl-card__title">Emails that need a look</h2>
29
+ <ul class="hrl-feed">
30
+ <% @attention.each do |message| %>
31
+ <li class="hrl-feed__item"><div class="hrl-feed__body">
32
+ <a href="<%= hr_lite.admin_hiring_candidate_path(message.candidate) %>"><%= message.candidate.name %></a> · <%= message.template.name %> ·
33
+ <% if message.status == "failed" %>
34
+ Failed (<%= message.error %>)
35
+ <%= button_to "Send again", hr_lite.admin_retry_hiring_message_path(message), class: "hrl-btn" %>
36
+ <% else %>
37
+ Not confirmed: check Gmail Sent before sending again
38
+ <% end %>
39
+ </div></li>
40
+ <% end %>
41
+ </ul>
42
+ </section>
43
+ <% end %>
44
+
45
+ <section class="hrl-card">
46
+ <form method="get" action="<%= hr_lite.admin_hiring_campaign_path(@campaign) %>" class="hrl-row">
47
+ <div class="hrl-field">
48
+ <label for="stage">Stage</label>
49
+ <select name="stage" id="stage">
50
+ <option value="">All (<%= @stage_counts.values.sum %>)</option>
51
+ <% HrLite::HiringCandidate::STAGES.each do |stage| %>
52
+ <option value="<%= stage %>" <%= "selected" if @stage == stage %>><%= stage.humanize %> (<%= @stage_counts[stage].to_i %>)</option>
53
+ <% end %>
54
+ </select>
55
+ </div>
56
+ <% if @campuses.any? %>
57
+ <div class="hrl-field">
58
+ <label for="campus">Campus</label>
59
+ <select name="campus" id="campus" <%= "data-hrl-filter=\"Type a campus\"".html_safe if @campuses.size > 5 %>>
60
+ <option value="">All campuses</option>
61
+ <% @campuses.each do |campus| %><option <%= "selected" if @campus == campus %>><%= campus %></option><% end %>
62
+ </select>
63
+ </div>
64
+ <% end %>
65
+ <label class="hrl-field--check"><input type="checkbox" name="replied" value="1" <%= "checked" if @replied %>> Replied via link</label>
66
+ <input type="hidden" name="round" value="<%= @round %>">
67
+ <div class="hrl-form-actions"><button class="hrl-btn" type="submit">Filter</button></div>
68
+ </form>
69
+ </section>
70
+
71
+ <section class="hrl-card">
72
+ <% if @candidates.empty? %>
73
+ <p class="hrl-muted"><%= @stage_counts.empty? ? "No candidates yet. Upload the university's sheet." : "Nobody at this stage." %></p>
74
+ <% else %>
75
+ <form method="get" action="<%= hr_lite.new_admin_hiring_campaign_hiring_send_path(@campaign) %>">
76
+ <div class="hrl-row">
77
+ <div class="hrl-field">
78
+ <label for="template_id">Email</label>
79
+ <select name="template_id" id="template_id" required>
80
+ <% @templates.each do |template| %><option value="<%= template.id %>"><%= template.name %></option><% end %>
81
+ </select>
82
+ </div>
83
+ <label class="hrl-field--check"><input type="checkbox" name="all" value="1" data-hrl-select-all="candidate_ids[]"> All <%= @total_count %> in this filter</label>
84
+ <input type="hidden" name="stage" value="<%= @stage %>"><input type="hidden" name="campus" value="<%= @campus %>"><input type="hidden" name="replied" value="<%= @replied %>"><input type="hidden" name="round" value="<%= @round %>">
85
+ <div class="hrl-form-actions"><button class="hrl-btn hrl-btn--primary" type="submit">Review email</button></div>
86
+ </div>
87
+ <div class="hrl-table-wrap">
88
+ <table class="hrl-table hrl-table--stack">
89
+ <thead><tr><th><span class="hrl-sr-only">Pick</span></th><th>Name</th><th>Course</th><th>Campus</th><th>Stage</th></tr></thead>
90
+ <tbody>
91
+ <% @candidates.each do |candidate| %>
92
+ <tr>
93
+ <td data-label="Pick"><label class="hrl-field--check"><input type="checkbox" name="candidate_ids[]" value="<%= candidate.id %>"> Select<span class="hrl-sr-only"> <%= candidate.name %></span></label></td>
94
+ <td data-label="Name"><a class="hrl-card__link" href="<%= hr_lite.admin_hiring_candidate_path(candidate) %>"><%= candidate.name %></a></td>
95
+ <td data-label="Course"><%= [ candidate.course, candidate.branch ].compact_blank.join(" · ") %></td>
96
+ <td data-label="Campus"><%= candidate.campus %></td>
97
+ <td data-label="Stage"><%= hrl_hiring_badge(candidate) %></td>
98
+ </tr>
99
+ <% end %>
100
+ </tbody>
101
+ </table>
102
+ </div>
103
+ </form>
104
+ <%= hrl_pagination %>
105
+ <% end %>
106
+ </section>
@@ -0,0 +1,186 @@
1
+ <% content_for(:page_title) { @candidate.name } %>
2
+ <% campaign = @candidate.campaign %>
3
+ <% moves = HrLite::HiringCandidate::MOVES.fetch(@candidate.stage) %>
4
+ <% manual = moves & HrLite::Admin::HiringCandidatesController::MANUAL %>
5
+ <div class="hrl-page__head">
6
+ <a class="hrl-btn" href="<%= hr_lite.admin_hiring_campaign_path(campaign) %>">‹ <%= campaign.title %></a>
7
+ <div class="hrl-hiring-person">
8
+ <span class="hrl-hiring-avatar" aria-hidden="true"><%= @candidate.name.split.first(2).map { |w| w[0] }.join.upcase %></span>
9
+ <div>
10
+ <h1 class="hrl-page__title"><%= @candidate.name %></h1>
11
+ <p class="hrl-page__sub"><%= [ @candidate.course, @candidate.branch, @candidate.campus ].compact_blank.join(" · ") %></p>
12
+ </div>
13
+ </div>
14
+ <div class="hrl-page__actions">
15
+ <a class="hrl-btn" href="mailto:<%= @candidate.email %>">Email</a>
16
+ <% if @candidate.phone.present? %><a class="hrl-btn" href="tel:<%= @candidate.phone.gsub(/[^\d+]/, "") %>">Call</a><% end %>
17
+ <% if @candidate.cv_url.to_s.match?(%r{\Ahttps?://}) %><a class="hrl-btn" href="<%= @candidate.cv_url %>" rel="noopener noreferrer nofollow" target="_blank">CV</a><% end %>
18
+ </div>
19
+ </div>
20
+
21
+ <section class="hrl-card" aria-label="Pipeline">
22
+ <ol class="hrl-steps">
23
+ <% hrl_hiring_steps(@candidate).each_with_index do |(label, state), i| %>
24
+ <li class="is-<%= state %>" <%= 'aria-current="step"'.html_safe if state == :now %>><span class="hrl-steps__dot"><%= state == :done ? "✓" : i + 1 %></span><%= label %></li>
25
+ <% end %>
26
+ </ol>
27
+ </section>
28
+
29
+ <div class="hrl-grid hrl-grid--side">
30
+ <div class="hrl-grid__aside">
31
+ <% if moves.include?("interview") %>
32
+ <section class="hrl-card hrl-card--next">
33
+ <p class="hrl-small hrl-muted">Next step</p>
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
+ <%= form_with url: hr_lite.admin_hiring_candidate_hiring_interviews_path(@candidate), method: :post, local: true do %>
36
+ <% picked = @next_reply %>
37
+ <% if picked %><p class="hrl-small">They picked <strong><%= picked.preferred_date.strftime("%A, %-d %B") %>, <%= HrLite::HiringReply::SLOTS[picked.preferred_slot] %></strong>. Call to confirm, then send the invite.</p><% end %>
38
+ <div class="hrl-field"><label for="date">Date</label><input type="date" name="date" id="date" required value="<%= picked&.preferred_date&.iso8601 %>"></div>
39
+ <div class="hrl-field"><label for="time">Time</label><input type="time" name="time" id="time" required value="<%= HrLite::HiringReply::SLOT_START[picked&.preferred_slot] %>"></div>
40
+ <div class="hrl-field">
41
+ <label for="minutes">Length</label>
42
+ <select name="minutes" id="minutes"><% HrLite::Admin::HiringInterviewsController::LENGTHS.each do |m| %><option value="<%= m %>"><%= m %> minutes</option><% end %></select>
43
+ </div>
44
+ <div class="hrl-form-actions"><button class="hrl-btn hrl-btn--primary" type="submit">Send invite with Meet link</button></div>
45
+ <p class="hrl-small">From the hiring mailbox's Google Calendar. Google emails the candidate.</p>
46
+ <% end %>
47
+ </section>
48
+ <% elsif @candidate.stage.in?(%w[imported invited]) %>
49
+ <section class="hrl-card hrl-card--next">
50
+ <p class="hrl-small hrl-muted">Next step</p>
51
+ <h2 class="hrl-card__title">Wait for their reply, then mark them interested</h2>
52
+ </section>
53
+ <% end %>
54
+
55
+ <% if manual.any? %>
56
+ <section class="hrl-card">
57
+ <h2 class="hrl-card__title">Decision</h2>
58
+ <div class="hrl-form-actions hrl-hiring-decide">
59
+ <% (manual - [ "rejected" ]).each do |to| %>
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") } } %>
62
+ <% end %>
63
+ </div>
64
+ <% if manual.include?("rejected") %>
65
+ <details>
66
+ <summary class="hrl-btn hrl-btn--danger">Reject…</summary>
67
+ <%= form_with url: hr_lite.stage_admin_hiring_candidate_path(@candidate), method: :patch, local: true do %>
68
+ <input type="hidden" name="to" value="rejected">
69
+ <div class="hrl-field">
70
+ <label for="reason">Reason</label>
71
+ <select name="reason" id="reason" required>
72
+ <option value="">Pick a reason</option>
73
+ <% HrLite::HiringCandidate::REJECTION_REASONS.each do |reason| %><option><%= reason %></option><% end %>
74
+ </select>
75
+ </div>
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>
78
+ <% end %>
79
+ </details>
80
+ <% end %>
81
+ </section>
82
+ <% end %>
83
+ </div>
84
+
85
+ <% @replies.each do |reply| %>
86
+ <section class="hrl-card">
87
+ <h2 class="hrl-card__title">Round <%= reply.round %> reply</h2>
88
+ <dl class="hrl-deflist">
89
+ <% if reply.round == 1 %><dt>Interested</dt><dd><%= reply.answers["interested"] == "yes" ? "Yes" : "No" %></dd><% end %>
90
+ <% if reply.preferred_slot %><dt>Slot</dt><dd><%= reply.preferred_date.strftime("%A, %-d %B") %>, <%= HrLite::HiringReply::SLOTS[reply.preferred_slot] %></dd><% end %>
91
+ <% if reply.answers["joining"] %>
92
+ <dt>Can join</dt><dd><%= HrLite::HiringReply::JOINING[reply.answers["joining"]] %></dd>
93
+ <% HrLite::HiringReply::YES_NO.each do |key, label| %><dt><%= label %></dt><dd><%= reply.answers[key] == "yes" ? "Yes" : "No" %></dd><% end %>
94
+ <% end %>
95
+ <% if reply.round == 3 %>
96
+ <% { "aadhaar" => [ "Aadhaar", reply.masked_aadhaar ], "pan" => [ "PAN", reply.masked_pan ] }.each do |field, (label, masked)| %>
97
+ <dt><%= label %></dt>
98
+ <dd>
99
+ <% if (full = @revealed&.dig(reply.id, field)) %>
100
+ <strong><%= full %></strong>
101
+ <% elsif masked %>
102
+ <%= masked %>
103
+ <%= button_to "Show full #{label}", hr_lite.reveal_admin_hiring_candidate_path(@candidate), params: { reply_id: reply.id, field: field }, class: "hrl-btn" %>
104
+ <% else %>
105
+ <span class="hrl-badge hrl-badge--warn">PAN pending: collect at joining</span>
106
+ <% end %>
107
+ </dd>
108
+ <% end %>
109
+ <% if reply.no_salary_slips %><dt>Salary slips</dt><dd>None (fresher)</dd><% end %>
110
+ <% end %>
111
+ <% if reply.alternate_phone.present? %><dt>Alternate phone</dt><dd><a class="hrl-btn" href="tel:<%= reply.alternate_phone.gsub(/[^\d+]/, "") %>">Call <%= reply.alternate_phone %></a></dd><% end %>
112
+ <dt>Replied</dt><dd><%= reply.responded_at.strftime("%-d %b %Y, %-l:%M %p") %></dd>
113
+ </dl>
114
+ <% if reply.documents.attached? %>
115
+ <div class="hrl-form-actions">
116
+ <% reply.documents.each do |doc| %>
117
+ <a class="hrl-btn" href="<%= hr_lite.document_admin_hiring_candidate_path(@candidate, attachment_id: doc.id) %>"><%= reply.document_label(doc) %></a>
118
+ <% end %>
119
+ </div>
120
+ <% end %>
121
+ </section>
122
+ <% end %>
123
+
124
+ <section class="hrl-card">
125
+ <h2 class="hrl-card__title">Profile from the sheet</h2>
126
+ <dl class="hrl-deflist">
127
+ <dt>Email</dt><dd><%= @candidate.email %></dd>
128
+ <dt>Phone</dt><dd><%= @candidate.phone.presence || "—" %></dd>
129
+ <dt>Date of birth</dt><dd><%= @candidate.date_of_birth.presence || "—" %></dd>
130
+ <% @candidate.extra.each do |key, value| %><dt><%= key.delete_prefix("sheet_").humanize %></dt><dd><%= value %></dd><% end %>
131
+ <% if @candidate.rejection_reason.present? %><dt>Rejected because</dt><dd><%= @candidate.rejection_reason %></dd><% end %>
132
+ </dl>
133
+ </section>
134
+
135
+ <% if @interviews.any? %>
136
+ <section class="hrl-card">
137
+ <h2 class="hrl-card__title">Interviews</h2>
138
+ <% @interviews.each do |interview| %>
139
+ <div class="hrl-card">
140
+ <p><strong>Round <%= interview.round %></strong> · <%= interview.starts_at.strftime("%-d %b %Y, %-l:%M %p") %> · <%= interview.status == "unknown" ? "not confirmed by Google: check the calendar" : interview.status.humanize %></p>
141
+ <% if interview.status == "scheduled" %>
142
+ <details>
143
+ <summary class="hrl-btn">Reschedule</summary>
144
+ <%= form_with url: hr_lite.admin_hiring_candidate_hiring_interview_path(@candidate, interview), method: :patch, local: true do %>
145
+ <div class="hrl-field"><label for="date_<%= interview.id %>">New date</label><input type="date" name="date" id="date_<%= interview.id %>" required></div>
146
+ <div class="hrl-field"><label for="time_<%= interview.id %>">New time</label><input type="time" name="time" id="time_<%= interview.id %>" required></div>
147
+ <input type="hidden" name="minutes" value="<%= ((interview.ends_at - interview.starts_at) / 60).round %>">
148
+ <div class="hrl-form-actions"><button class="hrl-btn hrl-btn--primary" type="submit">Move round <%= interview.round %></button></div>
149
+ <% end %>
150
+ </details>
151
+ <%= button_to "Cancel round #{interview.round}", hr_lite.admin_hiring_candidate_hiring_interview_path(@candidate, interview), method: :delete,
152
+ class: "hrl-btn hrl-btn--danger", form: { data: { turbo_confirm: "Cancel this interview? Google tells the candidate." } } %>
153
+ <% end %>
154
+ </div>
155
+ <% end %>
156
+ </section>
157
+ <% end %>
158
+
159
+ <section class="hrl-card">
160
+ <h2 class="hrl-card__title">Emails</h2>
161
+ <% if @messages.empty? %>
162
+ <p class="hrl-muted">Nothing sent yet.</p>
163
+ <% else %>
164
+ <ul class="hrl-feed">
165
+ <% @messages.each do |message| %>
166
+ <li class="hrl-feed__item"><div class="hrl-feed__body">
167
+ <strong><%= message.template.name %></strong>
168
+ <span class="hrl-feed__meta"><%= { "sent" => "Sent #{message.sent_at&.strftime('%-d %b, %-l:%M %p')}, in Gmail Sent", "failed" => "Failed: #{message.error}", "queued" => "Waiting to send" }.fetch(message.status, "Not confirmed: check Gmail Sent") %></span>
169
+ </div></li>
170
+ <% end %>
171
+ </ul>
172
+ <% end %>
173
+ </section>
174
+
175
+ <section class="hrl-card">
176
+ <h2 class="hrl-card__title">Activity</h2>
177
+ <ol class="hrl-timeline">
178
+ <% @timeline.each do |log| %>
179
+ <li class="hrl-timeline__item">
180
+ <p class="hrl-timeline__title"><%= hrl_hiring_event(log) %></p>
181
+ <p class="hrl-small hrl-muted"><%= log.actor ? hr_display_name(log.actor) : "System" %> · <%= log.created_at.strftime("%-d %b %Y, %-l:%M %p") %></p>
182
+ </li>
183
+ <% end %>
184
+ </ol>
185
+ </section>
186
+ </div>
@@ -0,0 +1,34 @@
1
+ <% content_for(:page_title) { "Send email" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Send “<%= @template.name %>” to <%= pluralize(@recipients.size, "candidate") %></h1>
4
+ <p class="hrl-page__sub"><%= @campaign.title %></p>
5
+ </div>
6
+
7
+ <section class="hrl-card">
8
+ <dl class="hrl-deflist">
9
+ <dt>From</dt><dd><%= @sender&.dig(:ready) ? "#{@sender[:email]}, so it lands in that Gmail Sent folder" : "No hiring mailbox connected" %></dd>
10
+ <dt>Will get it</dt><dd><%= @recipients.size %></dd>
11
+ <dt>Left out</dt><dd><%= @left_out.size %></dd>
12
+ </dl>
13
+ </section>
14
+
15
+ <% if @left_out.any? %>
16
+ <section class="hrl-card">
17
+ <h2 class="hrl-card__title">Left out, and why</h2>
18
+ <ul class="hrl-feed">
19
+ <% @left_out.each do |candidate, reason| %><li class="hrl-feed__item"><div class="hrl-feed__body"><strong><%= candidate.name %></strong>: <%= reason %></div></li><% end %>
20
+ </ul>
21
+ </section>
22
+ <% end %>
23
+
24
+ <%= form_with url: hr_lite.admin_hiring_campaign_hiring_sends_path(@campaign), method: :post, local: true do %>
25
+ <input type="hidden" name="template_id" value="<%= @template.id %>">
26
+ <% @recipients.each do |candidate| %><input type="hidden" name="candidate_ids[]" value="<%= candidate.id %>"><% end %>
27
+ <p class="hrl-small hrl-muted">Emails go out one by one in the background. The campaign page lists any that fail.</p>
28
+ <div class="hrl-form-actions">
29
+ <% if @recipients.any? && @sender&.dig(:ready) %>
30
+ <button class="hrl-btn hrl-btn--primary" type="submit" data-turbo-confirm="Send <%= pluralize(@recipients.size, 'email') %> from <%= @sender[:email] %>?">Send <%= pluralize(@recipients.size, "email") %></button>
31
+ <% end %>
32
+ <a class="hrl-btn" href="<%= hr_lite.admin_hiring_campaign_path(@campaign) %>">Back</a>
33
+ </div>
34
+ <% end %>
@@ -0,0 +1,20 @@
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
+ <%= render "hr_lite/shared/form_errors", record: template %>
3
+ <div class="hrl-field"><%= f.label :name, "Template name" %><%= f.text_field :name, required: true %></div>
4
+ <div class="hrl-field"><%= f.label :subject %><%= f.text_field :subject, required: true %></div>
5
+ <div class="hrl-field"><%= f.label :body, "Message" %><%= f.text_area :body, rows: 16, required: true %></div>
6
+ <% if HrLite.config.hiring.respond_to?(:draft_email) %>
7
+ <button class="hrl-btn" type="submit" name="ai" value="1" formnovalidate>Write with AI</button>
8
+ <p class="hrl-small hrl-muted">Paste or type the email the way you would write it to one person. AI fixes the format, names the template if it has no name yet, writes the subject, and swaps names and offer details for the fields below. Nothing is saved until you click Save.</p>
9
+ <% end %>
10
+ <h3 class="hrl-small"><strong>Fields, filled per candidate</strong></h3>
11
+ <% HrLite::HiringMerge::GROUPS.each do |group, names| %>
12
+ <p class="hrl-small hrl-muted"><%= group %></p>
13
+ <div class="hrl-chiprow"><% names.each do |name| %><code class="hrl-badge">@<%= name %></code><% end %></div>
14
+ <% end %>
15
+ <p class="hrl-small hrl-muted">Plus any extra column from the sheet, shown on a candidate's page. A candidate missing one is left out of a send, never sent a blank.</p>
16
+ <div class="hrl-form-actions">
17
+ <%= f.submit "Save template", class: "hrl-btn hrl-btn--primary" %>
18
+ <a class="hrl-btn" href="<%= hr_lite.admin_hiring_templates_path %>">Back</a>
19
+ </div>
20
+ <% end %>
@@ -0,0 +1,43 @@
1
+ <% content_for(:page_title) { @template.name } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title"><%= @template.name %></h1></div>
3
+
4
+ <% if @preview %>
5
+ <div class="hrl-flash <%= @ready == @campaign_size ? "hrl-flash--ok" : "hrl-flash--warn" %>">
6
+ Ready for <%= @ready %> of <%= @campaign_size %> candidates in <%= @preview_for.campaign.title %>.<%= " The rest are left out of a send until their fields are filled." if @ready < @campaign_size %>
7
+ </div>
8
+ <% end %>
9
+
10
+ <div class="hrl-grid hrl-grid--2">
11
+ <section class="hrl-card"><%= render "form", template: @template %></section>
12
+
13
+ <section class="hrl-card">
14
+ <h2 class="hrl-card__title">Preview</h2>
15
+ <% if @preview %>
16
+ <form method="get" action="<%= hr_lite.edit_admin_hiring_template_path(@template) %>" class="hrl-row">
17
+ <div class="hrl-field">
18
+ <label for="candidate_id">Preview for</label>
19
+ <select name="candidate_id" id="candidate_id" <%= "data-hrl-filter=\"Type a name\"".html_safe if @sample.size > 5 %>>
20
+ <% @sample.each do |candidate| %><option value="<%= candidate.id %>" <%= "selected" if candidate == @preview_for %>><%= candidate.name %> · <%= candidate.campaign.title %></option><% end %>
21
+ </select>
22
+ </div>
23
+ <div class="hrl-form-actions"><button class="hrl-btn" type="submit">Show</button></div>
24
+ </form>
25
+ <% if @preview.missing.any? %>
26
+ <div class="hrl-flash hrl-flash--warn">This candidate would be left out: <%= @preview.missing.join(", ") %>.</div>
27
+ <% end %>
28
+ <article>
29
+ <div class="hrl-mail__head">
30
+ <strong><%= @preview.subject %></strong>
31
+ <span class="hrl-small hrl-muted"><%= HrLite.config.hiring&.sender&.dig(:email) || "The hiring mailbox" %> to <%= @preview_for.email %></span>
32
+ </div>
33
+ <div class="hrl-hiring-preview"><%= @preview.body %></div>
34
+ <% if (signature = HrLite.config.hiring.try(:signature)).present? %>
35
+ <div class="hrl-hiring-signature"><%= sanitize(signature) %></div>
36
+ <% end %>
37
+ </article>
38
+ <p class="hrl-small hrl-muted">Sent as plain text through Gmail, so it lands in the mailbox's Sent folder.</p>
39
+ <% else %>
40
+ <p class="hrl-muted">Upload a candidate sheet to preview this email for a real person.</p>
41
+ <% end %>
42
+ </section>
43
+ </div>
@@ -0,0 +1,15 @@
1
+ <% content_for(:page_title) { "Hiring emails" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Hiring email templates</h1>
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
+ </div>
6
+ <section class="hrl-card">
7
+ <ul class="hrl-feed">
8
+ <% @templates.each do |template| %>
9
+ <li class="hrl-feed__item"><div class="hrl-feed__body">
10
+ <a class="hrl-card__link" href="<%= hr_lite.edit_admin_hiring_template_path(template) %>"><%= template.name %></a>
11
+ <span class="hrl-feed__meta"><%= template.subject %></span>
12
+ </div></li>
13
+ <% end %>
14
+ </ul>
15
+ </section>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "New template" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">New hiring email</h1></div>
3
+ <section class="hrl-card"><%= render "form", template: @template %></section>
@@ -0,0 +1,105 @@
1
+ <% campaign = @candidate.campaign %>
2
+ <% round = @reply.round %>
3
+ <% content_for(:page_title) { campaign.position } %>
4
+ <div class="hrl-page__head">
5
+ <h1 class="hrl-page__title"><%= campaign.position %></h1>
6
+ <p class="hrl-page__sub">Hi <%= @candidate.first_name %>, <%= round == 1 ? "thank you for your interest." : "congratulations on reaching round #{round}." %></p>
7
+ </div>
8
+
9
+ <% if @reply.responded_at %>
10
+ <section class="hrl-card">
11
+ <h2 class="hrl-card__title">Thanks, we have your answers</h2>
12
+ <p><%= @reply.answers["interested"] == "no" ? "We have noted that you do not want to go ahead. All the best." : "Our HR team will call you to confirm your round #{round} interview slot." %></p>
13
+ </section>
14
+ <% else %>
15
+ <%= form_with url: hr_lite.hiring_reply_path(@reply.token), method: :post, local: true, multipart: true do %>
16
+ <% if round == 1 %>
17
+ <section class="hrl-card">
18
+ <fieldset class="hrl-field">
19
+ <legend><strong>Do you want to go ahead with the interview?</strong></legend>
20
+ <label class="hrl-field--check"><input type="radio" name="interested" value="yes" required <%= "checked" if params[:interested] == "yes" %>> Yes, I am interested</label>
21
+ <label class="hrl-field--check"><input type="radio" name="interested" value="no" <%= "checked" if params[:interested] == "no" %>> No, not now</label>
22
+ </fieldset>
23
+ </section>
24
+ <% end %>
25
+
26
+ <section class="hrl-card">
27
+ <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>
41
+ <div class="hrl-field">
42
+ <label for="alternate_phone">Alternate phone number (optional)</label>
43
+ <input type="tel" name="alternate_phone" id="alternate_phone" inputmode="tel" autocomplete="tel" value="<%= params[:alternate_phone] %>">
44
+ </div>
45
+ </section>
46
+
47
+ <% if round == 1 %>
48
+ <section class="hrl-card">
49
+ <fieldset class="hrl-field">
50
+ <legend>How soon can you join?</legend>
51
+ <% HrLite::HiringReply::JOINING.each do |key, label| %>
52
+ <label class="hrl-field--check"><input type="radio" name="joining" value="<%= key %>" <%= "checked" if params[:joining] == key %>> <%= label %></label>
53
+ <% end %>
54
+ </fieldset>
55
+ <% { "stipend_ok" => "Are you fine with the monthly stipend#{" of #{campaign.stipend}" if campaign.stipend.present?}?",
56
+ "location_ok" => "Can you work from our office#{" at #{campaign.location}" if campaign.location.present?}?",
57
+ "schedule_ok" => "Are you fine with the work schedule#{": #{campaign.schedule}" if campaign.schedule.present?}?",
58
+ "programme_ok" => "Can you commit to the full programme#{" (#{campaign.programme})" if campaign.programme.present?}?" }.each do |key, question| %>
59
+ <fieldset class="hrl-field">
60
+ <legend><%= question %></legend>
61
+ <label class="hrl-field--check"><input type="radio" name="<%= key %>" value="yes" <%= "checked" if params[key] == "yes" %>> Yes</label>
62
+ <label class="hrl-field--check"><input type="radio" name="<%= key %>" value="no" <%= "checked" if params[key] == "no" %>> No</label>
63
+ </fieldset>
64
+ <% end %>
65
+ </section>
66
+ <% end %>
67
+
68
+ <% if round == 3 %>
69
+ <section class="hrl-card">
70
+ <h2 class="hrl-card__title">Joining details</h2>
71
+ <p class="hrl-small hrl-muted">Stored encrypted. Only our HR team can see them.</p>
72
+ <div class="hrl-field">
73
+ <label for="aadhaar_number">Aadhaar number</label>
74
+ <input type="text" name="aadhaar_number" id="aadhaar_number" inputmode="numeric" autocomplete="off" maxlength="14" required>
75
+ </div>
76
+ <div class="hrl-field">
77
+ <label for="pan_number">PAN</label>
78
+ <input type="text" name="pan_number" id="pan_number" autocomplete="off" maxlength="10" autocapitalize="characters">
79
+ </div>
80
+ <label class="hrl-field--check"><input type="checkbox" name="pan_pending" value="1" <%= "checked" if params[:pan_pending] == "1" %>> I don't have a PAN yet (HR will ask for it when you join)</label>
81
+ <label class="hrl-field--check"><input type="checkbox" name="no_salary_slips" value="1" <%= "checked" if params[:no_salary_slips] == "1" %>> I have no salary slips (fresher)</label>
82
+ </section>
83
+ <% end %>
84
+
85
+ <% kinds = HrLite::HiringReply::DOCUMENTS.fetch(round, {}) %>
86
+ <% if kinds.any? %>
87
+ <section class="hrl-card">
88
+ <h2 class="hrl-card__title">Your documents</h2>
89
+ <p class="hrl-small hrl-muted">PDF, JPG or PNG, up to 5 MB each. Stored encrypted.<%= " Skip PAN or salary slips if you ticked the box above." if round == 3 %></p>
90
+ <% kinds.each do |kind, label| %>
91
+ <div class="hrl-field">
92
+ <label for="doc_<%= kind %>"><%= label %><%= " (if you have it)" if kind.start_with?("salary_slip", "pan") %></label>
93
+ <input type="file" name="documents[<%= kind %>]" id="doc_<%= kind %>" accept=".pdf,.jpg,.jpeg,.png" <%= "required" unless kind.start_with?("salary_slip", "pan") %>>
94
+ </div>
95
+ <% end %>
96
+ </section>
97
+ <% end %>
98
+
99
+ <% if round == 3 %>
100
+ <label class="hrl-field--check"><input type="checkbox" name="consent" value="1" required> I agree to share these details and documents with <%= HrLite.config.company.call[:brand].presence || "the company" %> for hiring and joining.</label>
101
+ <% end %>
102
+
103
+ <div class="hrl-form-actions"><button class="hrl-btn hrl-btn--primary" type="submit">Send my answers</button></div>
104
+ <% end %>
105
+ <% end %>
@@ -121,6 +121,7 @@
121
121
  <%= javascript_include_tag "hr_lite/mention", defer: true %>
122
122
  <%= javascript_include_tag "hr_lite/nav", defer: true %>
123
123
  <%= javascript_include_tag "hr_lite/select_filter", defer: true %>
124
+ <%= javascript_include_tag "hr_lite/select_all", defer: true %>
124
125
  <%= javascript_include_tag "hr_lite/search", defer: true %>
125
126
  <dialog class="hrl-search" data-hrl-search aria-label="Search">
126
127
  <form method="dialog" class="hrl-search__close"><button class="hrl-btn" type="submit">Close</button></form>
@@ -0,0 +1,21 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <% company = HrLite.config.company.call %>
5
+ <title><%= [ content_for(:page_title), company[:brand].presence || company[:name] ].compact.join(" · ") %></title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
7
+ <meta name="robots" content="noindex, nofollow">
8
+ <%= csrf_meta_tags %>
9
+ <%= csp_meta_tag %>
10
+ <%= stylesheet_link_tag "hr_lite/hr_lite", media: "all" %>
11
+ <% HrLite.config.extra_stylesheets.each do |sheet| %><%= stylesheet_link_tag sheet, media: "all" %><% end %>
12
+ </head>
13
+ <body class="hrl-body">
14
+ <main id="hrl-main" class="hrl-public">
15
+ <% if company[:logo_url].present? %><img class="hrl-public__logo" src="<%= company[:logo_url] %>" alt="<%= company[:brand].presence || company[:name] %>"><% end %>
16
+ <% if flash[:alert].present? %><div class="hrl-flash hrl-flash--warn" role="alert"><%= flash[:alert] %></div><% end %>
17
+ <%= yield %>
18
+ </main>
19
+ <%= javascript_include_tag "hr_lite/confirm", defer: true %>
20
+ </body>
21
+ </html>
data/config/routes.rb CHANGED
@@ -69,8 +69,22 @@ HrLite::Engine.routes.draw do
69
69
  get "career", to: "career#show"
70
70
  resources :appraisals, only: %i[index show]
71
71
 
72
+ # A candidate's reply page for one round, from @round1_link … @round3_link (no login: the token is the key).
73
+ get "apply/:token", to: "hiring_replies#show", as: :hiring_reply
74
+ post "apply/:token", to: "hiring_replies#create"
75
+
72
76
  namespace :admin do
73
77
  resources :policies, only: %i[index new create]
78
+ resources :hiring_campaigns, except: %i[destroy] do
79
+ member { get :import; post :import }
80
+ resources :hiring_sends, path: "send", only: %i[new create]
81
+ end
82
+ resources :hiring_candidates, only: %i[show] do
83
+ member { patch :stage; get :document; post :reveal }
84
+ resources :hiring_interviews, path: "interviews", only: %i[create update destroy]
85
+ end
86
+ resources :hiring_templates, except: %i[show destroy]
87
+ post "hiring_messages/:id/retry", to: "hiring_sends#retry", as: :retry_hiring_message
74
88
  get "overview", to: "overview#index"
75
89
  resources :attendances, only: %i[index show update], param: :user_id do
76
90
  collection { get :week }
@@ -0,0 +1,15 @@
1
+ class CreateHrLiteHiringCampaigns < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :hr_lite_hiring_campaigns do |t|
4
+ t.string :title, null: false
5
+ t.string :position, null: false
6
+ t.string :programme
7
+ t.string :stipend
8
+ t.string :ctc
9
+ t.string :location
10
+ t.string :schedule
11
+ t.string :status, null: false, default: "open"
12
+ t.timestamps
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,22 @@
1
+ class CreateHrLiteHiringCandidates < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :hr_lite_hiring_candidates do |t|
4
+ t.references :campaign, null: false, foreign_key: { to_table: :hr_lite_hiring_campaigns }
5
+ t.string :name, null: false
6
+ t.string :email, null: false
7
+ t.text :phone
8
+ t.text :date_of_birth
9
+ t.string :campus
10
+ t.string :course
11
+ t.string :branch
12
+ t.string :cv_url
13
+ t.json :extra, null: false, default: {}
14
+ t.string :stage, null: false, default: "imported"
15
+ t.integer :round, null: false, default: 0
16
+ t.string :rejection_reason
17
+ t.timestamps
18
+ end
19
+ add_index :hr_lite_hiring_candidates, %i[campaign_id email], unique: true
20
+ add_index :hr_lite_hiring_candidates, %i[campaign_id stage]
21
+ end
22
+ end