hr_lite 0.58.0 → 0.61.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +14 -0
  4. data/app/assets/javascripts/hr_lite/crm_picker.js +4 -2
  5. data/app/assets/javascripts/hr_lite/mention.js +2 -2
  6. data/app/assets/javascripts/hr_lite/photo_crop.js +4 -0
  7. data/app/assets/javascripts/hr_lite/search.js +3 -2
  8. data/app/assets/javascripts/hr_lite/tax_preview.js +3 -2
  9. data/app/assets/stylesheets/hr_lite/hr_lite.css +20 -0
  10. data/app/controllers/hr_lite/admin/assets_controller.rb +4 -4
  11. data/app/controllers/hr_lite/admin/attendances_controller.rb +1 -1
  12. data/app/controllers/hr_lite/admin/ca_reviews_controller.rb +2 -2
  13. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +4 -4
  14. data/app/controllers/hr_lite/admin/designation_changes_controller.rb +4 -1
  15. data/app/controllers/hr_lite/admin/employees_controller.rb +4 -2
  16. data/app/controllers/hr_lite/admin/expenses_controller.rb +6 -6
  17. data/app/controllers/hr_lite/admin/hiring_campaigns_controller.rb +88 -0
  18. data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +27 -0
  19. data/app/controllers/hr_lite/admin/hiring_interviews_controller.rb +77 -0
  20. data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +62 -0
  21. data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +45 -0
  22. data/app/controllers/hr_lite/admin/hr_requests_controller.rb +4 -4
  23. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +4 -4
  24. data/app/controllers/hr_lite/admin/overview_controller.rb +5 -0
  25. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +4 -4
  26. data/app/controllers/hr_lite/admin/resignations_controller.rb +4 -0
  27. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +2 -2
  28. data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +4 -4
  29. data/app/controllers/hr_lite/admin/work_locations_controller.rb +3 -3
  30. data/app/controllers/hr_lite/application_controller.rb +5 -0
  31. data/app/controllers/hr_lite/comp_off_requests_controller.rb +2 -2
  32. data/app/controllers/hr_lite/hr_requests_controller.rb +2 -2
  33. data/app/controllers/hr_lite/regularization_requests_controller.rb +2 -2
  34. data/app/controllers/hr_lite/tax_declarations_controller.rb +2 -2
  35. data/app/controllers/hr_lite/wfh_requests_controller.rb +2 -2
  36. data/app/controllers/hr_lite/work_locations_controller.rb +1 -1
  37. data/app/helpers/hr_lite/application_helper.rb +38 -0
  38. data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +1 -1
  39. data/app/jobs/hr_lite/hiring_send_job.rb +42 -0
  40. data/app/models/concerns/hr_lite/audited.rb +2 -2
  41. data/app/models/hr_lite/designation_change.rb +4 -0
  42. data/app/models/hr_lite/employee_profile.rb +1 -1
  43. data/app/models/hr_lite/hiring_campaign.rb +21 -0
  44. data/app/models/hr_lite/hiring_candidate.rb +52 -0
  45. data/app/models/hr_lite/hiring_interview.rb +13 -0
  46. data/app/models/hr_lite/hiring_message.rb +14 -0
  47. data/app/models/hr_lite/hiring_template.rb +11 -0
  48. data/app/models/hr_lite/resignation.rb +4 -0
  49. data/app/models/hr_lite/wfh_request.rb +1 -1
  50. data/app/services/hr_lite/hiring_import.rb +106 -0
  51. data/app/services/hr_lite/hiring_merge.rb +48 -0
  52. data/app/views/hr_lite/admin/hiring_campaigns/_form.html.erb +18 -0
  53. data/app/views/hr_lite/admin/hiring_campaigns/edit.html.erb +3 -0
  54. data/app/views/hr_lite/admin/hiring_campaigns/import.html.erb +18 -0
  55. data/app/views/hr_lite/admin/hiring_campaigns/index.html.erb +21 -0
  56. data/app/views/hr_lite/admin/hiring_campaigns/new.html.erb +3 -0
  57. data/app/views/hr_lite/admin/hiring_campaigns/preview.html.erb +62 -0
  58. data/app/views/hr_lite/admin/hiring_campaigns/show.html.erb +98 -0
  59. data/app/views/hr_lite/admin/hiring_candidates/show.html.erb +145 -0
  60. data/app/views/hr_lite/admin/hiring_sends/new.html.erb +34 -0
  61. data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +16 -0
  62. data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +40 -0
  63. data/app/views/hr_lite/admin/hiring_templates/index.html.erb +15 -0
  64. data/app/views/hr_lite/admin/hiring_templates/new.html.erb +3 -0
  65. data/app/views/hr_lite/admin/overview/index.html.erb +17 -0
  66. data/config/routes.rb +10 -0
  67. data/db/migrate/20261021100000_create_hr_lite_hiring_campaigns.rb +15 -0
  68. data/db/migrate/20261021100100_create_hr_lite_hiring_candidates.rb +22 -0
  69. data/db/migrate/20261021100200_create_hr_lite_hiring_templates.rb +10 -0
  70. data/db/migrate/20261021100300_create_hr_lite_hiring_messages.rb +19 -0
  71. data/db/migrate/20261021100400_create_hr_lite_hiring_interviews.rb +16 -0
  72. data/db/migrate/20261021100500_seed_hr_lite_hiring.rb +43 -0
  73. data/lib/hr_lite/configuration.rb +14 -1
  74. data/lib/hr_lite/notifications.rb +5 -5
  75. data/lib/hr_lite/permissions.rb +2 -1
  76. data/lib/hr_lite/role_seeds.rb +2 -2
  77. data/lib/hr_lite/version.rb +1 -1
  78. data/lib/hr_lite.rb +17 -1
  79. metadata +33 -1
@@ -0,0 +1,48 @@
1
+ module HrLite
2
+ # Fills a hiring email's @placeholders for one candidate. Reserved names
3
+ # (campaign, candidate, interview, sender) win over a sheet's extra columns.
4
+ # An unknown or blank placeholder is reported, never sent as "".
5
+ class HiringMerge
6
+ TOKEN = /(?<![\w.@])@([a-z][a-z0-9_]*)/
7
+ GROUPS = { "Candidate" => %w[first_name name course branch campus],
8
+ "Offer" => %w[position programme stipend ctc location schedule],
9
+ "Interview" => %w[interview_date interview_time meet_link sender_name] }.freeze
10
+ RESERVED = GROUPS.values.flatten.freeze
11
+
12
+ Result = Data.define(:subject, :body, :missing)
13
+
14
+ def initialize(candidate, sender_name:)
15
+ @extra = candidate.extra.to_h.transform_keys(&:to_s)
16
+ @values = @extra.merge(reserved(candidate, sender_name))
17
+ end
18
+
19
+ def render(template)
20
+ missing = []
21
+ fill = ->(text) {
22
+ text.to_s.gsub(TOKEN) do
23
+ key = Regexp.last_match(1)
24
+ value = @values[key].to_s.strip
25
+ next value unless value.empty?
26
+
27
+ missing << "@#{key} (#{RESERVED.include?(key) || @extra.key?(key) ? 'empty' : 'unknown'})"
28
+ Regexp.last_match(0)
29
+ end
30
+ }
31
+ Result.new(subject: fill.(template.subject), body: fill.(template.body), missing: missing.uniq)
32
+ end
33
+
34
+ private
35
+
36
+ def reserved(candidate, sender_name)
37
+ campaign = candidate.campaign
38
+ interview = candidate.interviews.where(status: "scheduled").order(:round).last
39
+ at = interview&.starts_at&.in_time_zone(HrLite.config.time_zone)
40
+ { "name" => candidate.name, "first_name" => candidate.first_name, "course" => candidate.course,
41
+ "branch" => candidate.branch, "campus" => candidate.campus, "position" => campaign.position,
42
+ "programme" => campaign.programme, "stipend" => campaign.stipend, "ctc" => campaign.ctc,
43
+ "location" => campaign.location, "schedule" => campaign.schedule,
44
+ "interview_date" => at&.strftime("%-d %B %Y"), "interview_time" => at&.strftime("%-l:%M %p"),
45
+ "meet_link" => interview&.meet_link, "sender_name" => sender_name }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,18 @@
1
+ <%= form_with model: campaign, url: (campaign.persisted? ? hr_lite.admin_hiring_campaign_path(campaign) : hr_lite.admin_hiring_campaigns_path), local: true do |f| %>
2
+ <%= render "hr_lite/shared/form_errors", record: campaign %>
3
+ <p class="hrl-small hrl-muted">These fill @position, @programme, @stipend, @ctc, @location and @schedule in every email.</p>
4
+ <div class="hrl-field"><%= f.label :title, "Campaign name" %><%= f.text_field :title, required: true, placeholder: "e.g. Campus drive, October" %></div>
5
+ <div class="hrl-field"><%= f.label :position %><%= f.text_field :position, required: true %></div>
6
+ <div class="hrl-field"><%= f.label :programme %><%= f.text_field :programme %></div>
7
+ <div class="hrl-field"><%= f.label :stipend, "Monthly stipend" %><%= f.text_field :stipend %></div>
8
+ <div class="hrl-field"><%= f.label :ctc, "Full-time CTC" %><%= f.text_field :ctc %></div>
9
+ <div class="hrl-field"><%= f.label :location, "Work location" %><%= f.text_area :location, rows: 2 %></div>
10
+ <div class="hrl-field"><%= f.label :schedule, "Work schedule" %><%= f.text_field :schedule %></div>
11
+ <% if campaign.persisted? %>
12
+ <div class="hrl-field"><%= f.label :status %><%= f.select :status, [ %w[Open open], %w[Closed closed] ] %></div>
13
+ <% end %>
14
+ <div class="hrl-form-actions">
15
+ <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
16
+ <a class="hrl-btn" href="<%= campaign.persisted? ? hr_lite.admin_hiring_campaign_path(campaign) : hr_lite.admin_hiring_campaigns_path %>">Cancel</a>
17
+ </div>
18
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "Edit campaign" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Edit <%= @campaign.title %></h1></div>
3
+ <section class="hrl-card"><%= render "form", campaign: @campaign %></section>
@@ -0,0 +1,18 @@
1
+ <% content_for(:page_title) { "Upload candidates" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Upload candidate sheet</h1>
4
+ <p class="hrl-page__sub"><%= @campaign.title %>. Nothing is saved until you confirm the preview.</p>
5
+ </div>
6
+ <section class="hrl-card">
7
+ <%= form_with url: hr_lite.import_admin_hiring_campaign_path(@campaign), method: :post, local: true, multipart: true do %>
8
+ <div class="hrl-field">
9
+ <label for="sheet">Sheet (.xlsx or .csv, headers in the first row)</label>
10
+ <input type="file" name="sheet" id="sheet" accept=".xlsx,.xls,.csv" required>
11
+ </div>
12
+ <p class="hrl-small hrl-muted">Name and email are required. Columns we do not recognise are kept, and can be used as @placeholders.</p>
13
+ <div class="hrl-form-actions">
14
+ <button class="hrl-btn hrl-btn--primary" type="submit">Preview</button>
15
+ <a class="hrl-btn" href="<%= hr_lite.admin_hiring_campaign_path(@campaign) %>">Cancel</a>
16
+ </div>
17
+ <% end %>
18
+ </section>
@@ -0,0 +1,21 @@
1
+ <% content_for(:page_title) { "Hiring" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Hiring</h1>
4
+ <div class="hrl-page__actions"><a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_hiring_campaign_path %>">New campaign</a></div>
5
+ </div>
6
+
7
+ <% if @campaigns.empty? %>
8
+ <section class="hrl-card hrl-card--empty"><p class="hrl-muted">No campaigns yet. Create one, then upload the university's candidate sheet.</p></section>
9
+ <% end %>
10
+ <% @campaigns.each do |campaign| %>
11
+ <section class="hrl-card">
12
+ <h2 class="hrl-card__title"><a class="hrl-card__link" href="<%= hr_lite.admin_hiring_campaign_path(campaign) %>"><%= campaign.title %></a></h2>
13
+ <p class="hrl-small hrl-muted"><%= [ campaign.position, campaign.stipend, (campaign.status == "closed" ? "Closed" : nil) ].compact_blank.join(" · ") %></p>
14
+ <div class="hrl-chiprow">
15
+ <% HrLite::HiringCandidate::STAGES.each do |stage| %>
16
+ <% if (n = @counts[[ campaign.id, stage ]]) %><span class="hrl-badge hrl-badge--muted"><%= n %> <%= stage.humanize.downcase %></span><% end %>
17
+ <% end %>
18
+ </div>
19
+ </section>
20
+ <% end %>
21
+ <%= hrl_pagination %>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "New campaign" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">New hiring campaign</h1></div>
3
+ <section class="hrl-card"><%= render "form", campaign: @campaign %></section>
@@ -0,0 +1,62 @@
1
+ <% content_for(:page_title) { "Check the import" } %>
2
+ <% counts = @import.counts %>
3
+ <div class="hrl-page__head">
4
+ <h1 class="hrl-page__title">Check the import</h1>
5
+ <p class="hrl-page__sub"><%= @campaign.title %></p>
6
+ </div>
7
+ <div class="hrl-kpis">
8
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= counts["create"] %></div><div class="hrl-kpi__label">New</div></div>
9
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= counts["update"] %></div><div class="hrl-kpi__label">Update</div></div>
10
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= counts["skip"] %></div><div class="hrl-kpi__label">Skip</div></div>
11
+ </div>
12
+
13
+ <%= form_with url: hr_lite.import_admin_hiring_campaign_path(@campaign), method: :post, local: true do %>
14
+ <input type="hidden" name="rows" value="<%= @sealed %>">
15
+ <% if @import.conflicts.any? %>
16
+ <section class="hrl-card hrl-card--warn">
17
+ <h2 class="hrl-card__title">Pick one column for each field</h2>
18
+ <% @import.conflicts.each do |field, columns| %>
19
+ <fieldset class="hrl-field">
20
+ <legend><%= field.humanize %></legend>
21
+ <% columns.each do |column| %>
22
+ <label class="hrl-field--check"><input type="radio" name="picks[<%= field %>]" value="<%= column.index %>" required> “<%= column.header %>”</label>
23
+ <% end %>
24
+ </fieldset>
25
+ <% end %>
26
+ <p class="hrl-small hrl-muted">The column you do not pick is kept as extra data.</p>
27
+ <button class="hrl-btn" type="submit">Update preview</button>
28
+ </section>
29
+ <% end %>
30
+
31
+ <section class="hrl-card">
32
+ <h2 class="hrl-card__title">Columns</h2>
33
+ <div class="hrl-table-wrap">
34
+ <table class="hrl-table hrl-table--stack hrl-table--tight">
35
+ <thead><tr><th>In the sheet</th><th>Saved as</th></tr></thead>
36
+ <tbody>
37
+ <% @import.columns.each do |column| %>
38
+ <tr>
39
+ <td data-label="In the sheet"><%= column.header.presence || "(no header)" %></td>
40
+ <td data-label="Saved as"><%= column.field ? (column.field == "cv_url" ? "CV link" : column.field.humanize) : "Extra: @#{column.extra_key}" %></td>
41
+ </tr>
42
+ <% end %>
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ </section>
47
+
48
+ <% skipped = @import.rows.select { |row| row.outcome == "skip" } %>
49
+ <% if skipped.any? %>
50
+ <section class="hrl-card">
51
+ <h2 class="hrl-card__title">Rows that will be skipped</h2>
52
+ <ul class="hrl-feed"><% skipped.each do |row| %><li class="hrl-feed__item">Row <%= row.number %>: <%= row.reason %></li><% end %></ul>
53
+ </section>
54
+ <% end %>
55
+
56
+ <div class="hrl-form-actions">
57
+ <% if @import.conflicts.empty? %>
58
+ <button class="hrl-btn hrl-btn--primary" type="submit" name="confirm" value="1">Import <%= counts["create"] + counts["update"] %> candidates</button>
59
+ <% end %>
60
+ <a class="hrl-btn" href="<%= hr_lite.admin_hiring_campaign_path(@campaign) %>">Cancel</a>
61
+ </div>
62
+ <% end %>
@@ -0,0 +1,98 @@
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
+ <% if @attention.any? %>
21
+ <section class="hrl-card hrl-card--warn">
22
+ <h2 class="hrl-card__title">Emails that need a look</h2>
23
+ <ul class="hrl-feed">
24
+ <% @attention.each do |message| %>
25
+ <li class="hrl-feed__item"><div class="hrl-feed__body">
26
+ <a href="<%= hr_lite.admin_hiring_candidate_path(message.candidate) %>"><%= message.candidate.name %></a> · <%= message.template.name %> ·
27
+ <% if message.status == "failed" %>
28
+ Failed (<%= message.error %>)
29
+ <%= button_to "Send again", hr_lite.admin_retry_hiring_message_path(message), class: "hrl-btn" %>
30
+ <% else %>
31
+ Not confirmed: check Gmail Sent before sending again
32
+ <% end %>
33
+ </div></li>
34
+ <% end %>
35
+ </ul>
36
+ </section>
37
+ <% end %>
38
+
39
+ <section class="hrl-card">
40
+ <form method="get" action="<%= hr_lite.admin_hiring_campaign_path(@campaign) %>" class="hrl-row">
41
+ <div class="hrl-field">
42
+ <label for="stage">Stage</label>
43
+ <select name="stage" id="stage">
44
+ <option value="">All (<%= @stage_counts.values.sum %>)</option>
45
+ <% HrLite::HiringCandidate::STAGES.each do |stage| %>
46
+ <option value="<%= stage %>" <%= "selected" if @stage == stage %>><%= stage.humanize %> (<%= @stage_counts[stage].to_i %>)</option>
47
+ <% end %>
48
+ </select>
49
+ </div>
50
+ <% if @campuses.any? %>
51
+ <div class="hrl-field">
52
+ <label for="campus">Campus</label>
53
+ <select name="campus" id="campus" <%= "data-hrl-filter=\"Type a campus\"".html_safe if @campuses.size > 5 %>>
54
+ <option value="">All campuses</option>
55
+ <% @campuses.each do |campus| %><option <%= "selected" if @campus == campus %>><%= campus %></option><% end %>
56
+ </select>
57
+ </div>
58
+ <% end %>
59
+ <div class="hrl-form-actions"><button class="hrl-btn" type="submit">Filter</button></div>
60
+ </form>
61
+ </section>
62
+
63
+ <section class="hrl-card">
64
+ <% if @candidates.empty? %>
65
+ <p class="hrl-muted"><%= @stage_counts.empty? ? "No candidates yet. Upload the university's sheet." : "Nobody at this stage." %></p>
66
+ <% else %>
67
+ <form method="get" action="<%= hr_lite.new_admin_hiring_campaign_hiring_send_path(@campaign) %>">
68
+ <div class="hrl-row">
69
+ <div class="hrl-field">
70
+ <label for="template_id">Email</label>
71
+ <select name="template_id" id="template_id" required>
72
+ <% @templates.each do |template| %><option value="<%= template.id %>"><%= template.name %></option><% end %>
73
+ </select>
74
+ </div>
75
+ <label class="hrl-field--check"><input type="checkbox" name="all" value="1"> All <%= @total_count %> in this filter</label>
76
+ <input type="hidden" name="stage" value="<%= @stage %>"><input type="hidden" name="campus" value="<%= @campus %>">
77
+ <div class="hrl-form-actions"><button class="hrl-btn hrl-btn--primary" type="submit">Review email</button></div>
78
+ </div>
79
+ <div class="hrl-table-wrap">
80
+ <table class="hrl-table hrl-table--stack">
81
+ <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>
82
+ <tbody>
83
+ <% @candidates.each do |candidate| %>
84
+ <tr>
85
+ <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>
86
+ <td data-label="Name"><a class="hrl-card__link" href="<%= hr_lite.admin_hiring_candidate_path(candidate) %>"><%= candidate.name %></a></td>
87
+ <td data-label="Course"><%= [ candidate.course, candidate.branch ].compact_blank.join(" · ") %></td>
88
+ <td data-label="Campus"><%= candidate.campus %></td>
89
+ <td data-label="Stage"><%= hrl_hiring_badge(candidate) %></td>
90
+ </tr>
91
+ <% end %>
92
+ </tbody>
93
+ </table>
94
+ </div>
95
+ </form>
96
+ <%= hrl_pagination %>
97
+ <% end %>
98
+ </section>
@@ -0,0 +1,145 @@
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
+ <div class="hrl-field"><label for="date">Date</label><input type="date" name="date" id="date" required></div>
37
+ <div class="hrl-field"><label for="time">Time</label><input type="time" name="time" id="time" required></div>
38
+ <div class="hrl-field">
39
+ <label for="minutes">Length</label>
40
+ <select name="minutes" id="minutes"><% HrLite::Admin::HiringInterviewsController::LENGTHS.each do |m| %><option value="<%= m %>"><%= m %> minutes</option><% end %></select>
41
+ </div>
42
+ <div class="hrl-form-actions"><button class="hrl-btn hrl-btn--primary" type="submit">Send invite with Meet link</button></div>
43
+ <p class="hrl-small">From the hiring mailbox's Google Calendar. Google emails the candidate.</p>
44
+ <% end %>
45
+ </section>
46
+ <% elsif @candidate.stage.in?(%w[imported invited]) %>
47
+ <section class="hrl-card hrl-card--next">
48
+ <p class="hrl-small hrl-muted">Next step</p>
49
+ <h2 class="hrl-card__title">Wait for their reply, then mark them interested</h2>
50
+ </section>
51
+ <% end %>
52
+
53
+ <% if manual.any? %>
54
+ <section class="hrl-card">
55
+ <h2 class="hrl-card__title">Decision</h2>
56
+ <div class="hrl-form-actions hrl-hiring-decide">
57
+ <% (manual - [ "rejected" ]).each do |to| %>
58
+ <%= button_to to.humanize, hr_lite.stage_admin_hiring_candidate_path(@candidate), method: :patch, params: { to: to }, class: "hrl-btn",
59
+ form: { data: { turbo_confirm: ("Mark #{@candidate.name} selected? This is final." if to == "selected") } } %>
60
+ <% end %>
61
+ </div>
62
+ <% if manual.include?("rejected") %>
63
+ <details>
64
+ <summary class="hrl-btn hrl-btn--danger">Reject…</summary>
65
+ <%= form_with url: hr_lite.stage_admin_hiring_candidate_path(@candidate), method: :patch, local: true do %>
66
+ <input type="hidden" name="to" value="rejected">
67
+ <div class="hrl-field">
68
+ <label for="reason">Reason</label>
69
+ <select name="reason" id="reason" required>
70
+ <option value="">Pick a reason</option>
71
+ <% HrLite::HiringCandidate::REJECTION_REASONS.each do |reason| %><option><%= reason %></option><% end %>
72
+ </select>
73
+ </div>
74
+ <div class="hrl-field"><label for="note">Note (needed for “Other”)</label><textarea name="note" id="note" rows="2"></textarea></div>
75
+ <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>
76
+ <% end %>
77
+ </details>
78
+ <% end %>
79
+ </section>
80
+ <% end %>
81
+ </div>
82
+
83
+ <section class="hrl-card">
84
+ <h2 class="hrl-card__title">Profile from the sheet</h2>
85
+ <dl class="hrl-deflist">
86
+ <dt>Email</dt><dd><%= @candidate.email %></dd>
87
+ <dt>Phone</dt><dd><%= @candidate.phone.presence || "—" %></dd>
88
+ <dt>Date of birth</dt><dd><%= @candidate.date_of_birth.presence || "—" %></dd>
89
+ <% @candidate.extra.each do |key, value| %><dt><%= key.delete_prefix("sheet_").humanize %></dt><dd><%= value %></dd><% end %>
90
+ <% if @candidate.rejection_reason.present? %><dt>Rejected because</dt><dd><%= @candidate.rejection_reason %></dd><% end %>
91
+ </dl>
92
+ </section>
93
+
94
+ <% if @interviews.any? %>
95
+ <section class="hrl-card">
96
+ <h2 class="hrl-card__title">Interviews</h2>
97
+ <% @interviews.each do |interview| %>
98
+ <div class="hrl-card">
99
+ <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>
100
+ <% if interview.status == "scheduled" %>
101
+ <details>
102
+ <summary class="hrl-btn">Reschedule</summary>
103
+ <%= form_with url: hr_lite.admin_hiring_candidate_hiring_interview_path(@candidate, interview), method: :patch, local: true do %>
104
+ <div class="hrl-field"><label for="date_<%= interview.id %>">New date</label><input type="date" name="date" id="date_<%= interview.id %>" required></div>
105
+ <div class="hrl-field"><label for="time_<%= interview.id %>">New time</label><input type="time" name="time" id="time_<%= interview.id %>" required></div>
106
+ <input type="hidden" name="minutes" value="<%= ((interview.ends_at - interview.starts_at) / 60).round %>">
107
+ <div class="hrl-form-actions"><button class="hrl-btn hrl-btn--primary" type="submit">Move round <%= interview.round %></button></div>
108
+ <% end %>
109
+ </details>
110
+ <%= button_to "Cancel round #{interview.round}", hr_lite.admin_hiring_candidate_hiring_interview_path(@candidate, interview), method: :delete,
111
+ class: "hrl-btn hrl-btn--danger", form: { data: { turbo_confirm: "Cancel this interview? Google tells the candidate." } } %>
112
+ <% end %>
113
+ </div>
114
+ <% end %>
115
+ </section>
116
+ <% end %>
117
+
118
+ <section class="hrl-card">
119
+ <h2 class="hrl-card__title">Emails</h2>
120
+ <% if @messages.empty? %>
121
+ <p class="hrl-muted">Nothing sent yet.</p>
122
+ <% else %>
123
+ <ul class="hrl-feed">
124
+ <% @messages.each do |message| %>
125
+ <li class="hrl-feed__item"><div class="hrl-feed__body">
126
+ <strong><%= message.template.name %></strong>
127
+ <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>
128
+ </div></li>
129
+ <% end %>
130
+ </ul>
131
+ <% end %>
132
+ </section>
133
+
134
+ <section class="hrl-card">
135
+ <h2 class="hrl-card__title">Activity</h2>
136
+ <ol class="hrl-timeline">
137
+ <% @timeline.each do |log| %>
138
+ <li class="hrl-timeline__item">
139
+ <p class="hrl-timeline__title"><%= hrl_hiring_event(log) %></p>
140
+ <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>
141
+ </li>
142
+ <% end %>
143
+ </ol>
144
+ </section>
145
+ </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,16 @@
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 %><%= 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
+ <h3 class="hrl-small"><strong>Fields, filled per candidate</strong></h3>
7
+ <% HrLite::HiringMerge::GROUPS.each do |group, names| %>
8
+ <p class="hrl-small hrl-muted"><%= group %></p>
9
+ <div class="hrl-chiprow"><% names.each do |name| %><code class="hrl-badge">@<%= name %></code><% end %></div>
10
+ <% end %>
11
+ <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>
12
+ <div class="hrl-form-actions">
13
+ <%= f.submit "Save template", class: "hrl-btn hrl-btn--primary" %>
14
+ <a class="hrl-btn" href="<%= hr_lite.admin_hiring_templates_path %>">Back</a>
15
+ </div>
16
+ <% end %>
@@ -0,0 +1,40 @@
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
+ </article>
35
+ <p class="hrl-small hrl-muted">Sent as plain text through Gmail, so it lands in the mailbox's Sent folder.</p>
36
+ <% else %>
37
+ <p class="hrl-muted">Upload a candidate sheet to preview this email for a real person.</p>
38
+ <% end %>
39
+ </section>
40
+ </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>
@@ -12,6 +12,23 @@
12
12
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:missing_checkout] %></div><div class="hrl-kpi__label">Missing checkouts</div></div>
13
13
  </div>
14
14
 
15
+ <% if @failures.present? %>
16
+ <section class="hrl-card">
17
+ <h2 class="hrl-card__title">Failed in the background</h2>
18
+ <% @failures.each do |log| %>
19
+ <div class="hrl-feed__item">
20
+ <div class="hrl-feed__body">
21
+ <strong><%= log.action.delete_prefix("failure.").tr("._", " ").humanize %></strong>
22
+ — <%= log.subject.is_a?(HrLite.user_klass) ? hr_display_name(log.subject) : "#{log.subject_type.demodulize.underscore.humanize} ##{log.subject_id}" %>,
23
+ <%= log.created_at.strftime("%d %b, %H:%M") %>
24
+ <div class="hrl-small hrl-muted"><%= log.audited_changes["error"] %>: <%= log.audited_changes["message"] %></div>
25
+ </div>
26
+ </div>
27
+ <% end %>
28
+ <a class="hrl-card__link" href="<%= hr_lite.admin_audit_logs_path %>">Audit trail</a>
29
+ </section>
30
+ <% end %>
31
+
15
32
  <% if pending.zero? && @kpis.except(:pending).values.all?(&:zero?) %>
16
33
  <p class="hrl-card hrl-card--empty">All present and accounted for.</p>
17
34
  <% else %>
data/config/routes.rb CHANGED
@@ -71,6 +71,16 @@ HrLite::Engine.routes.draw do
71
71
 
72
72
  namespace :admin do
73
73
  resources :policies, only: %i[index new create]
74
+ resources :hiring_campaigns, except: %i[destroy] do
75
+ member { get :import; post :import }
76
+ resources :hiring_sends, path: "send", only: %i[new create]
77
+ end
78
+ resources :hiring_candidates, only: %i[show] do
79
+ member { patch :stage }
80
+ resources :hiring_interviews, path: "interviews", only: %i[create update destroy]
81
+ end
82
+ resources :hiring_templates, except: %i[show destroy]
83
+ post "hiring_messages/:id/retry", to: "hiring_sends#retry", as: :retry_hiring_message
74
84
  get "overview", to: "overview#index"
75
85
  resources :attendances, only: %i[index show update], param: :user_id do
76
86
  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