hr_lite 0.11.0 → 0.13.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 +4 -4
- data/CHANGELOG.md +71 -1
- data/app/assets/stylesheets/hr_lite/hr_lite.css +6 -0
- data/app/controllers/hr_lite/admin/reports_controller.rb +151 -0
- data/app/controllers/hr_lite/benefits_controller.rb +17 -0
- data/app/controllers/hr_lite/expenses_controller.rb +50 -0
- data/app/controllers/hr_lite/hr_requests_controller.rb +43 -0
- data/app/controllers/hr_lite/policies_controller.rb +26 -0
- data/app/helpers/hr_lite/application_helper.rb +6 -1
- data/app/models/hr_lite/approval_flow.rb +1 -1
- data/app/models/hr_lite/benefit.rb +37 -0
- data/app/models/hr_lite/benefit_enrolment.rb +30 -0
- data/app/models/hr_lite/expense.rb +142 -0
- data/app/models/hr_lite/expense_category.rb +33 -0
- data/app/models/hr_lite/hr_request.rb +75 -0
- data/app/models/hr_lite/policy.rb +59 -0
- data/app/models/hr_lite/policy_acknowledgement.rb +13 -0
- data/app/views/hr_lite/admin/reports/index.html.erb +40 -0
- data/app/views/hr_lite/admin/reports/show.html.erb +32 -0
- data/app/views/hr_lite/benefits/index.html.erb +33 -0
- data/app/views/hr_lite/expenses/index.html.erb +68 -0
- data/app/views/hr_lite/expenses/new.html.erb +38 -0
- data/app/views/hr_lite/hr_requests/index.html.erb +39 -0
- data/app/views/hr_lite/hr_requests/new.html.erb +28 -0
- data/app/views/hr_lite/hr_requests/show.html.erb +25 -0
- data/app/views/hr_lite/policies/index.html.erb +34 -0
- data/app/views/hr_lite/policies/show.html.erb +23 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260818004707_create_hr_lite_employee_services.rb +108 -0
- data/lib/hr_lite/notifications.rb +8 -0
- data/lib/hr_lite/permissions.rb +9 -0
- data/lib/hr_lite/role_seeds.rb +10 -3
- data/lib/hr_lite/version.rb +1 -1
- metadata +24 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "Can I have a salary certificate?" — the questions that were going to
|
|
3
|
+
# somebody's inbox and getting lost there.
|
|
4
|
+
class HrRequest < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[open in_progress resolved closed cancelled].freeze
|
|
8
|
+
|
|
9
|
+
CATEGORIES = %w[
|
|
10
|
+
salary_certificate employment_certificate address_change bank_change
|
|
11
|
+
document_request payroll_query tax_query insurance_query policy_query other
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
15
|
+
belongs_to :assigned_to, class_name: HrLite.config.user_class, optional: true
|
|
16
|
+
|
|
17
|
+
validates :subject, presence: true
|
|
18
|
+
validates :category, inclusion: { in: CATEGORIES }
|
|
19
|
+
validates :status, inclusion: { in: STATUSES }
|
|
20
|
+
|
|
21
|
+
scope :open_requests, -> { where(status: %w[open in_progress]) }
|
|
22
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
23
|
+
|
|
24
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
25
|
+
|
|
26
|
+
after_create_commit :notify_desk
|
|
27
|
+
|
|
28
|
+
def category_label = category.humanize
|
|
29
|
+
|
|
30
|
+
def assign!(actor:, assignee:)
|
|
31
|
+
update!(assigned_to_id: assignee.id, status: "in_progress")
|
|
32
|
+
Notifications.publish(
|
|
33
|
+
"hr_request.assigned",
|
|
34
|
+
title: "#{category_label}: #{subject}",
|
|
35
|
+
body: "Assigned to you by #{HrLite.display_name(actor)}.",
|
|
36
|
+
path: "/admin/hr_requests/#{id}", bell_to: [ assignee ]
|
|
37
|
+
)
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resolve!(actor:, resolution:)
|
|
42
|
+
raise ArgumentError, "a resolution needs an answer" if resolution.blank?
|
|
43
|
+
|
|
44
|
+
update!(status: "resolved", resolution: resolution, resolved_at: Time.current,
|
|
45
|
+
assigned_to_id: assigned_to_id || actor.id)
|
|
46
|
+
Notifications.publish(
|
|
47
|
+
"hr_request.resolved",
|
|
48
|
+
title: "Your request was answered — #{subject}",
|
|
49
|
+
body: resolution, path: "/hr_requests/#{id}",
|
|
50
|
+
bell_to: [ user ], email_to: [ user ]
|
|
51
|
+
)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cancel!(actor:)
|
|
56
|
+
raise ActiveRecord::RecordInvalid.new(self), "already settled" unless open? || in_progress?
|
|
57
|
+
|
|
58
|
+
update!(status: "cancelled")
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def notify_desk
|
|
65
|
+
desk = HrLite.users_holding("hr_request.manage").to_a
|
|
66
|
+
return if desk.empty?
|
|
67
|
+
|
|
68
|
+
Notifications.publish(
|
|
69
|
+
"hr_request.raised",
|
|
70
|
+
title: "#{HrLite.display_name(user)} asked: #{subject}",
|
|
71
|
+
body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A written policy people are held to. Versioned, because "you agreed to
|
|
3
|
+
# this" is only true of the version somebody actually read.
|
|
4
|
+
class Policy < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
has_many :policy_acknowledgements, class_name: "HrLite::PolicyAcknowledgement",
|
|
8
|
+
dependent: :destroy
|
|
9
|
+
|
|
10
|
+
validates :title, presence: true, uniqueness: { scope: :version }
|
|
11
|
+
validates :body, presence: true
|
|
12
|
+
validates :effective_from, presence: true
|
|
13
|
+
validates :version, numericality: { greater_than: 0 }
|
|
14
|
+
|
|
15
|
+
scope :published, -> { where(published: true) }
|
|
16
|
+
scope :live_on, ->(date) { published.where(effective_from: ..date) }
|
|
17
|
+
scope :newest_first, -> { order(effective_from: :desc, version: :desc) }
|
|
18
|
+
|
|
19
|
+
# The version in force today for each title — an older one stays for the
|
|
20
|
+
# acknowledgements attached to it, but nobody should be reading it.
|
|
21
|
+
def self.current
|
|
22
|
+
live_on(Date.current).group_by(&:title).values.map do |versions|
|
|
23
|
+
versions.max_by(&:version)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Re-issuing asks everybody again, which is the whole point of versioning
|
|
28
|
+
# it: an acknowledgement of v1 says nothing about v2.
|
|
29
|
+
def supersede!(body:, effective_from: Date.current, acknowledgement_required: nil)
|
|
30
|
+
self.class.create!(
|
|
31
|
+
title: title, body: body, version: version + 1, effective_from: effective_from,
|
|
32
|
+
acknowledgement_required: acknowledgement_required.nil? ? self.acknowledgement_required : acknowledgement_required,
|
|
33
|
+
published: true
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def acknowledged_by?(user)
|
|
38
|
+
policy_acknowledgements.exists?(user_id: user.id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def outstanding_for
|
|
42
|
+
return HrLite.user_klass.none unless acknowledgement_required && published
|
|
43
|
+
|
|
44
|
+
HrLite.user_klass.where(id: HrLite.active_employees.map(&:id))
|
|
45
|
+
.where.not(id: policy_acknowledgements.select(:user_id))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Clicking twice is not an error and must not read as one. The
|
|
49
|
+
# find_or_create handles the second click; the rescue handles two
|
|
50
|
+
# requests arriving at once, which the unique index refuses.
|
|
51
|
+
def acknowledge!(user)
|
|
52
|
+
policy_acknowledgements.find_or_create_by!(user_id: user.id) do |ack|
|
|
53
|
+
ack.acknowledged_at = Time.current
|
|
54
|
+
end
|
|
55
|
+
rescue ActiveRecord::RecordNotUnique
|
|
56
|
+
policy_acknowledgements.find_by!(user_id: user.id)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "I have read this." Kept per VERSION, and never edited — it is the
|
|
3
|
+
# evidence, and evidence that can be changed afterwards is not evidence.
|
|
4
|
+
class PolicyAcknowledgement < ApplicationRecord
|
|
5
|
+
belongs_to :policy, class_name: "HrLite::Policy"
|
|
6
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
7
|
+
|
|
8
|
+
validates :acknowledged_at, presence: true
|
|
9
|
+
validates :user_id, uniqueness: { scope: :policy_id }
|
|
10
|
+
|
|
11
|
+
def readonly? = persisted?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Reports" } %>
|
|
2
|
+
<div class="hrl-page__head"><h1 class="hrl-page__title">Reports</h1></div>
|
|
3
|
+
|
|
4
|
+
<section class="hrl-card">
|
|
5
|
+
<p class="hrl-muted hrl-small">
|
|
6
|
+
Each report reaches exactly as far as the screen it summarises — a manager
|
|
7
|
+
sees their own team, HR sees everybody.
|
|
8
|
+
</p>
|
|
9
|
+
<%= form_with url: hr_lite.admin_reports_path, method: :get, local: true do %>
|
|
10
|
+
<div class="hrl-field">
|
|
11
|
+
<%= label_tag :month, "Month" %>
|
|
12
|
+
<%= text_field_tag :month, @month.strftime("%Y-%m"), placeholder: "YYYY-MM" %>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="hrl-form-actions">
|
|
15
|
+
<%= submit_tag "Change month", class: "hrl-btn" %>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
</section>
|
|
19
|
+
|
|
20
|
+
<section class="hrl-card">
|
|
21
|
+
<div class="hrl-table-wrap">
|
|
22
|
+
<table class="hrl-table hrl-table--stack">
|
|
23
|
+
<thead><tr><th>Report</th><th></th></tr></thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
<% @reports.each do |name, label| %>
|
|
26
|
+
<tr>
|
|
27
|
+
<td data-label="Report"><%= label %></td>
|
|
28
|
+
<td data-label="">
|
|
29
|
+
<a class="hrl-small"
|
|
30
|
+
href="<%= hr_lite.admin_report_path(name, month: @month.strftime("%Y-%m")) %>">Open</a>
|
|
31
|
+
|
|
32
|
+
<a class="hrl-small"
|
|
33
|
+
href="<%= hr_lite.admin_report_path(name, month: @month.strftime("%Y-%m"), format: :csv) %>">CSV</a>
|
|
34
|
+
</td>
|
|
35
|
+
</tr>
|
|
36
|
+
<% end %>
|
|
37
|
+
</tbody>
|
|
38
|
+
</table>
|
|
39
|
+
</div>
|
|
40
|
+
</section>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<% content_for(:page_title) { @reports_title = HrLite::Admin::ReportsController::REPORTS[@name] } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= HrLite::Admin::ReportsController::REPORTS[@name] %></h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn"
|
|
6
|
+
href="<%= hr_lite.admin_report_path(@name, month: @month.strftime("%Y-%m"), format: :csv) %>">Download CSV</a>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<section class="hrl-card">
|
|
11
|
+
<p class="hrl-small hrl-muted"><%= @month.strftime("%B %Y") %></p>
|
|
12
|
+
<% if @rows.empty? %>
|
|
13
|
+
<p class="hrl-muted">Nothing to report for this month.</p>
|
|
14
|
+
<% else %>
|
|
15
|
+
<div class="hrl-table-wrap">
|
|
16
|
+
<table class="hrl-table hrl-table--stack">
|
|
17
|
+
<thead>
|
|
18
|
+
<tr><% @rows.first.keys.each do |header| %><th><%= header %></th><% end %></tr>
|
|
19
|
+
</thead>
|
|
20
|
+
<tbody>
|
|
21
|
+
<% @rows.each do |row| %>
|
|
22
|
+
<tr>
|
|
23
|
+
<% row.each do |header, value| %>
|
|
24
|
+
<td data-label="<%= header %>"><%= value %></td>
|
|
25
|
+
<% end %>
|
|
26
|
+
</tr>
|
|
27
|
+
<% end %>
|
|
28
|
+
</tbody>
|
|
29
|
+
</table>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
32
|
+
</section>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Benefits" } %>
|
|
2
|
+
<div class="hrl-page__head"><h1 class="hrl-page__title">Your benefits</h1></div>
|
|
3
|
+
|
|
4
|
+
<section class="hrl-card">
|
|
5
|
+
<% if @enrolments.empty? %>
|
|
6
|
+
<p class="hrl-muted">You are not enrolled in any benefit yet.</p>
|
|
7
|
+
<% else %>
|
|
8
|
+
<div class="hrl-table-wrap">
|
|
9
|
+
<table class="hrl-table hrl-table--stack">
|
|
10
|
+
<thead><tr><th>Benefit</th><th>Cover</th><th>Since</th><th>Dependants</th></tr></thead>
|
|
11
|
+
<tbody>
|
|
12
|
+
<% @enrolments.each do |enrolment| %>
|
|
13
|
+
<% benefit = enrolment.benefit %>
|
|
14
|
+
<tr>
|
|
15
|
+
<td data-label="Benefit">
|
|
16
|
+
<strong><%= benefit.name %></strong>
|
|
17
|
+
<div class="hrl-small hrl-muted">
|
|
18
|
+
<%= benefit.kind.humanize %><%= " · #{benefit.provider}" if benefit.provider.present? %>
|
|
19
|
+
<%= " · policy #{benefit.policy_number}" if benefit.policy_number.present? %>
|
|
20
|
+
</div>
|
|
21
|
+
</td>
|
|
22
|
+
<td data-label="Cover" class="hrl-num">
|
|
23
|
+
<%= benefit.coverage ? hrl_money(benefit.coverage) : "—" %>
|
|
24
|
+
</td>
|
|
25
|
+
<td data-label="Since"><%= enrolment.enrolled_on.strftime("%d %b %Y") %></td>
|
|
26
|
+
<td data-label="Dependants" class="hrl-num"><%= enrolment.dependants %></td>
|
|
27
|
+
</tr>
|
|
28
|
+
<% end %>
|
|
29
|
+
</tbody>
|
|
30
|
+
</table>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
</section>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Expenses" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Expenses</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_expense_path %>">New claim</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<% if @categories.any? %>
|
|
10
|
+
<section class="hrl-card">
|
|
11
|
+
<h2 class="hrl-card__title">What is left this month</h2>
|
|
12
|
+
<ul>
|
|
13
|
+
<% @categories.each do |category| %>
|
|
14
|
+
<% remaining = category.remaining_for(hr_current_user) %>
|
|
15
|
+
<li class="hrl-small">
|
|
16
|
+
<strong><%= category.name %></strong> —
|
|
17
|
+
<%= remaining.nil? ? "no cap" : "#{hrl_money(remaining)} left" %>
|
|
18
|
+
<% if category.receipt_required %>
|
|
19
|
+
<span class="hrl-badge hrl-badge--muted">Receipt needed</span>
|
|
20
|
+
<% end %>
|
|
21
|
+
</li>
|
|
22
|
+
<% end %>
|
|
23
|
+
</ul>
|
|
24
|
+
</section>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<section class="hrl-card">
|
|
28
|
+
<% if @expenses.empty? %>
|
|
29
|
+
<p class="hrl-muted">No claims yet.</p>
|
|
30
|
+
<% else %>
|
|
31
|
+
<div class="hrl-table-wrap">
|
|
32
|
+
<table class="hrl-table hrl-table--stack">
|
|
33
|
+
<thead><tr><th>Spent</th><th>Category</th><th>Amount</th><th>Status</th><th></th></tr></thead>
|
|
34
|
+
<tbody>
|
|
35
|
+
<% @expenses.each do |expense| %>
|
|
36
|
+
<tr>
|
|
37
|
+
<td data-label="Spent"><%= expense.spent_on.strftime("%d %b %Y") %></td>
|
|
38
|
+
<td data-label="Category">
|
|
39
|
+
<%= expense.category.name %>
|
|
40
|
+
<div class="hrl-small hrl-muted"><%= expense.description %></div>
|
|
41
|
+
</td>
|
|
42
|
+
<td data-label="Amount" class="hrl-num"><%= hrl_money(expense.amount) %></td>
|
|
43
|
+
<td data-label="Status">
|
|
44
|
+
<%= hrl_request_status_badge(expense.status) %>
|
|
45
|
+
<% if expense.reimbursed_in %>
|
|
46
|
+
<div class="hrl-small hrl-muted">
|
|
47
|
+
with <%= expense.reimbursed_in.strftime("%B %Y") %> payroll
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
50
|
+
<% if expense.decision_note.present? %>
|
|
51
|
+
<div class="hrl-small hrl-muted"><%= expense.decision_note %></div>
|
|
52
|
+
<% end %>
|
|
53
|
+
</td>
|
|
54
|
+
<td data-label="">
|
|
55
|
+
<% if expense.draft? || expense.submitted? %>
|
|
56
|
+
<%= button_to "Withdraw", hr_lite.cancel_expense_path(expense),
|
|
57
|
+
class: "hrl-btn",
|
|
58
|
+
form: { data: { turbo_confirm: "Withdraw this claim?" } } %>
|
|
59
|
+
<% end %>
|
|
60
|
+
</td>
|
|
61
|
+
</tr>
|
|
62
|
+
<% end %>
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
<%= hrl_pagination %>
|
|
67
|
+
<% end %>
|
|
68
|
+
</section>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<% content_for(:page_title) { "New claim" } %>
|
|
2
|
+
<div class="hrl-page__head"><h1 class="hrl-page__title">New claim</h1></div>
|
|
3
|
+
|
|
4
|
+
<section class="hrl-card">
|
|
5
|
+
<%= form_with model: @expense, url: hr_lite.expenses_path, method: :post, local: true do |f| %>
|
|
6
|
+
<%= render "hr_lite/shared/form_errors", record: @expense %>
|
|
7
|
+
|
|
8
|
+
<div class="hrl-field">
|
|
9
|
+
<%= f.label :category_id, "What kind of expense" %>
|
|
10
|
+
<%= f.select :category_id, @categories.map { |c| [ c.name, c.id ] },
|
|
11
|
+
{ include_blank: "Choose one" } %>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="hrl-field">
|
|
14
|
+
<%= f.label :amount, "Amount (₹)" %>
|
|
15
|
+
<%= f.text_field :amount, inputmode: "decimal" %>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="hrl-field">
|
|
18
|
+
<%= f.label :spent_on, "When you spent it" %>
|
|
19
|
+
<%= f.date_field :spent_on, max: Date.current.to_s %>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="hrl-field">
|
|
22
|
+
<%= f.label :description, "What it was for" %>
|
|
23
|
+
<%= f.text_field :description %>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="hrl-field">
|
|
26
|
+
<%= f.label :receipt, "Receipt (PDF or photo)" %>
|
|
27
|
+
<%= f.file_field :receipt, accept: "application/pdf,image/*" %>
|
|
28
|
+
<span class="hrl-small hrl-muted">
|
|
29
|
+
Some categories need one — the form will say so if yours does.
|
|
30
|
+
</span>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div class="hrl-form-actions">
|
|
34
|
+
<%= f.submit "Submit claim", class: "hrl-btn hrl-btn--primary" %>
|
|
35
|
+
<a class="hrl-btn" href="<%= hr_lite.expenses_path %>">Cancel</a>
|
|
36
|
+
</div>
|
|
37
|
+
<% end %>
|
|
38
|
+
</section>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Ask HR" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Ask HR</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_hr_request_path %>">New request</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<section class="hrl-card">
|
|
10
|
+
<% if @requests.empty? %>
|
|
11
|
+
<p class="hrl-muted">Nothing raised yet.</p>
|
|
12
|
+
<% else %>
|
|
13
|
+
<div class="hrl-table-wrap">
|
|
14
|
+
<table class="hrl-table hrl-table--stack">
|
|
15
|
+
<thead><tr><th>Request</th><th>Raised</th><th>Status</th><th></th></tr></thead>
|
|
16
|
+
<tbody>
|
|
17
|
+
<% @requests.each do |request| %>
|
|
18
|
+
<tr>
|
|
19
|
+
<td data-label="Request">
|
|
20
|
+
<a href="<%= hr_lite.hr_request_path(request) %>"><%= request.subject %></a>
|
|
21
|
+
<div class="hrl-small hrl-muted"><%= request.category_label %></div>
|
|
22
|
+
</td>
|
|
23
|
+
<td data-label="Raised"><%= request.created_at.to_date.strftime("%d %b %Y") %></td>
|
|
24
|
+
<td data-label="Status"><%= hrl_request_status_badge(request.status) %></td>
|
|
25
|
+
<td data-label="">
|
|
26
|
+
<% if request.open? || request.in_progress? %>
|
|
27
|
+
<%= button_to "Withdraw", hr_lite.cancel_hr_request_path(request),
|
|
28
|
+
class: "hrl-btn",
|
|
29
|
+
form: { data: { turbo_confirm: "Withdraw this request?" } } %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</td>
|
|
32
|
+
</tr>
|
|
33
|
+
<% end %>
|
|
34
|
+
</tbody>
|
|
35
|
+
</table>
|
|
36
|
+
</div>
|
|
37
|
+
<%= hrl_pagination %>
|
|
38
|
+
<% end %>
|
|
39
|
+
</section>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<% content_for(:page_title) { "New request" } %>
|
|
2
|
+
<div class="hrl-page__head"><h1 class="hrl-page__title">Ask HR</h1></div>
|
|
3
|
+
|
|
4
|
+
<section class="hrl-card">
|
|
5
|
+
<%= form_with model: @request, url: hr_lite.hr_requests_path, method: :post, local: true do |f| %>
|
|
6
|
+
<%= render "hr_lite/shared/form_errors", record: @request %>
|
|
7
|
+
|
|
8
|
+
<div class="hrl-field">
|
|
9
|
+
<%= f.label :category, "What is this about" %>
|
|
10
|
+
<%= f.select :category,
|
|
11
|
+
HrLite::HrRequest::CATEGORIES.map { |c| [ c.humanize, c ] },
|
|
12
|
+
{ include_blank: "Choose one" } %>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="hrl-field">
|
|
15
|
+
<%= f.label :subject, "In one line" %>
|
|
16
|
+
<%= f.text_field :subject %>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="hrl-field">
|
|
19
|
+
<%= f.label :body, "Anything else HR should know" %>
|
|
20
|
+
<%= f.text_area :body, rows: 5 %>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="hrl-form-actions">
|
|
24
|
+
<%= f.submit "Send to HR", class: "hrl-btn hrl-btn--primary" %>
|
|
25
|
+
<a class="hrl-btn" href="<%= hr_lite.hr_requests_path %>">Cancel</a>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
28
|
+
</section>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<% content_for(:page_title) { @request.subject } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= @request.subject %></h1>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<section class="hrl-card">
|
|
7
|
+
<p class="hrl-small hrl-muted">
|
|
8
|
+
<%= @request.category_label %> · raised
|
|
9
|
+
<%= @request.created_at.to_date.strftime("%d %b %Y") %> ·
|
|
10
|
+
<%= hrl_request_status_badge(@request.status) %>
|
|
11
|
+
</p>
|
|
12
|
+
<% if @request.body.present? %>
|
|
13
|
+
<div class="hrl-prose"><%= simple_format(@request.body) %></div>
|
|
14
|
+
<% end %>
|
|
15
|
+
</section>
|
|
16
|
+
|
|
17
|
+
<% if @request.resolution.present? %>
|
|
18
|
+
<section class="hrl-card">
|
|
19
|
+
<h2 class="hrl-card__title">HR's answer</h2>
|
|
20
|
+
<div class="hrl-prose"><%= simple_format(@request.resolution) %></div>
|
|
21
|
+
<p class="hrl-small hrl-muted">
|
|
22
|
+
<%= @request.resolved_at&.to_date&.strftime("%d %b %Y") %>
|
|
23
|
+
</p>
|
|
24
|
+
</section>
|
|
25
|
+
<% end %>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Policies" } %>
|
|
2
|
+
<div class="hrl-page__head"><h1 class="hrl-page__title">Policies</h1></div>
|
|
3
|
+
|
|
4
|
+
<section class="hrl-card">
|
|
5
|
+
<% if @policies.empty? %>
|
|
6
|
+
<p class="hrl-muted">No policies are published yet.</p>
|
|
7
|
+
<% else %>
|
|
8
|
+
<div class="hrl-table-wrap">
|
|
9
|
+
<table class="hrl-table hrl-table--stack">
|
|
10
|
+
<thead><tr><th>Policy</th><th>In force</th><th></th></tr></thead>
|
|
11
|
+
<tbody>
|
|
12
|
+
<% @policies.each do |policy| %>
|
|
13
|
+
<tr>
|
|
14
|
+
<td data-label="Policy">
|
|
15
|
+
<a href="<%= hr_lite.policy_path(policy) %>"><%= policy.title %></a>
|
|
16
|
+
<span class="hrl-badge hrl-badge--muted">v<%= policy.version %></span>
|
|
17
|
+
</td>
|
|
18
|
+
<td data-label="In force"><%= policy.effective_from.strftime("%d %b %Y") %></td>
|
|
19
|
+
<td data-label="">
|
|
20
|
+
<% if policy.acknowledgement_required %>
|
|
21
|
+
<% if @acknowledged.include?(policy.id) %>
|
|
22
|
+
<span class="hrl-badge hrl-badge--ok">Read</span>
|
|
23
|
+
<% else %>
|
|
24
|
+
<span class="hrl-badge hrl-badge--warn">Needs your acknowledgement</span>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% end %>
|
|
27
|
+
</td>
|
|
28
|
+
</tr>
|
|
29
|
+
<% end %>
|
|
30
|
+
</tbody>
|
|
31
|
+
</table>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
</section>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<% content_for(:page_title) { @policy.title } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= @policy.title %></h1>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<section class="hrl-card">
|
|
7
|
+
<p class="hrl-small hrl-muted">
|
|
8
|
+
Version <%= @policy.version %>, in force from
|
|
9
|
+
<%= @policy.effective_from.strftime("%d %b %Y") %>.
|
|
10
|
+
</p>
|
|
11
|
+
<div class="hrl-prose"><%= simple_format(@policy.body) %></div>
|
|
12
|
+
</section>
|
|
13
|
+
|
|
14
|
+
<% if @policy.acknowledgement_required %>
|
|
15
|
+
<section class="hrl-card">
|
|
16
|
+
<% if @policy.acknowledged_by?(hr_current_user) %>
|
|
17
|
+
<p class="hrl-muted">You acknowledged this version.</p>
|
|
18
|
+
<% else %>
|
|
19
|
+
<%= button_to "I have read this", hr_lite.acknowledge_policy_path(@policy),
|
|
20
|
+
class: "hrl-btn hrl-btn--primary" %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</section>
|
|
23
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -20,6 +20,16 @@ HrLite::Engine.routes.draw do
|
|
|
20
20
|
member { post :cancel }
|
|
21
21
|
end
|
|
22
22
|
resources :approvals, only: :index
|
|
23
|
+
resources :expenses, only: %i[index new create] do
|
|
24
|
+
member { post :cancel }
|
|
25
|
+
end
|
|
26
|
+
resources :hr_requests, only: %i[index show new create] do
|
|
27
|
+
member { post :cancel }
|
|
28
|
+
end
|
|
29
|
+
resources :policies, only: %i[index show] do
|
|
30
|
+
member { post :acknowledge }
|
|
31
|
+
end
|
|
32
|
+
resources :benefits, only: :index
|
|
23
33
|
get "team", to: "team#show"
|
|
24
34
|
get "org", to: "org#show"
|
|
25
35
|
resources :holidays, only: :index
|
|
@@ -51,6 +61,7 @@ HrLite::Engine.routes.draw do
|
|
|
51
61
|
resources :assignments, only: %i[create destroy], controller: "role_assignments"
|
|
52
62
|
end
|
|
53
63
|
resources :statutory_rate_cards, only: %i[index new create edit update]
|
|
64
|
+
resources :reports, only: %i[index show]
|
|
54
65
|
resources :leave_types, except: :show
|
|
55
66
|
resources :office_locations, except: :show
|
|
56
67
|
resources :holidays, only: %i[index create update destroy] do
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
class CreateHrLiteEmployeeServices < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Four things an employee has to leave the system to do today: claim an
|
|
3
|
+
# expense, see what insurance they have, ask HR a question, and read the
|
|
4
|
+
# policy they are being held to.
|
|
5
|
+
#
|
|
6
|
+
# Expenses and requests route through the approval engine from 0.9.0
|
|
7
|
+
# rather than growing a fifth and sixth approve/reject of their own.
|
|
8
|
+
def change
|
|
9
|
+
create_table :hr_lite_expense_categories do |t|
|
|
10
|
+
t.string :name, null: false
|
|
11
|
+
t.text :monthly_cap # encrypted; null = uncapped
|
|
12
|
+
t.boolean :receipt_required, null: false, default: true
|
|
13
|
+
t.boolean :active, null: false, default: true
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
add_index :hr_lite_expense_categories, :name, unique: true
|
|
17
|
+
|
|
18
|
+
create_table :hr_lite_expenses do |t|
|
|
19
|
+
t.bigint :user_id, null: false
|
|
20
|
+
t.references :category, null: false, index: true,
|
|
21
|
+
foreign_key: { to_table: :hr_lite_expense_categories }
|
|
22
|
+
t.text :amount, null: false # encrypted
|
|
23
|
+
t.date :spent_on, null: false
|
|
24
|
+
t.string :description, null: false
|
|
25
|
+
# draft | submitted | approved | rejected | reimbursed | cancelled
|
|
26
|
+
t.string :status, null: false, default: "draft"
|
|
27
|
+
t.datetime :submitted_at
|
|
28
|
+
t.string :decision_note
|
|
29
|
+
# Which payroll month paid it out, once it was reimbursed.
|
|
30
|
+
t.date :reimbursed_in
|
|
31
|
+
t.timestamps
|
|
32
|
+
end
|
|
33
|
+
add_index :hr_lite_expenses, %i[user_id status]
|
|
34
|
+
add_check_constraint :hr_lite_expenses,
|
|
35
|
+
"status IN ('draft', 'submitted', 'approved', 'rejected', 'reimbursed', 'cancelled')",
|
|
36
|
+
name: "hr_lite_expenses_status_check"
|
|
37
|
+
|
|
38
|
+
create_table :hr_lite_benefits do |t|
|
|
39
|
+
t.string :name, null: false
|
|
40
|
+
# health | life | accident | other
|
|
41
|
+
t.string :kind, null: false, default: "health"
|
|
42
|
+
t.string :provider
|
|
43
|
+
t.string :policy_number
|
|
44
|
+
t.text :coverage # encrypted — sum assured
|
|
45
|
+
t.text :employer_premium # encrypted
|
|
46
|
+
t.text :employee_premium # encrypted
|
|
47
|
+
t.date :effective_from
|
|
48
|
+
t.date :expires_on
|
|
49
|
+
t.text :notes
|
|
50
|
+
t.boolean :active, null: false, default: true
|
|
51
|
+
t.timestamps
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
create_table :hr_lite_benefit_enrolments do |t|
|
|
55
|
+
t.references :benefit, null: false, index: true,
|
|
56
|
+
foreign_key: { to_table: :hr_lite_benefits, on_delete: :cascade }
|
|
57
|
+
t.bigint :user_id, null: false
|
|
58
|
+
t.date :enrolled_on, null: false
|
|
59
|
+
t.date :ended_on
|
|
60
|
+
# Dependants covered, as a count — names and dates of birth are the
|
|
61
|
+
# kind of family data this engine has no reason to hold.
|
|
62
|
+
t.integer :dependants, null: false, default: 0
|
|
63
|
+
t.timestamps
|
|
64
|
+
end
|
|
65
|
+
add_index :hr_lite_benefit_enrolments, %i[benefit_id user_id], unique: true
|
|
66
|
+
|
|
67
|
+
create_table :hr_lite_hr_requests do |t|
|
|
68
|
+
t.bigint :user_id, null: false
|
|
69
|
+
# salary_certificate | address_change | bank_change | payroll_query | ...
|
|
70
|
+
t.string :category, null: false
|
|
71
|
+
t.string :subject, null: false
|
|
72
|
+
t.text :body
|
|
73
|
+
# open | in_progress | resolved | closed | cancelled
|
|
74
|
+
t.string :status, null: false, default: "open"
|
|
75
|
+
t.bigint :assigned_to_id
|
|
76
|
+
t.datetime :resolved_at
|
|
77
|
+
t.text :resolution
|
|
78
|
+
t.timestamps
|
|
79
|
+
end
|
|
80
|
+
add_index :hr_lite_hr_requests, %i[user_id status]
|
|
81
|
+
add_index :hr_lite_hr_requests, :assigned_to_id
|
|
82
|
+
add_check_constraint :hr_lite_hr_requests,
|
|
83
|
+
"status IN ('open', 'in_progress', 'resolved', 'closed', 'cancelled')",
|
|
84
|
+
name: "hr_lite_hr_requests_status_check"
|
|
85
|
+
|
|
86
|
+
create_table :hr_lite_policies do |t|
|
|
87
|
+
t.string :title, null: false
|
|
88
|
+
t.text :body, null: false
|
|
89
|
+
t.integer :version, null: false, default: 1
|
|
90
|
+
t.date :effective_from, null: false
|
|
91
|
+
t.boolean :acknowledgement_required, null: false, default: false
|
|
92
|
+
t.boolean :published, null: false, default: false
|
|
93
|
+
t.timestamps
|
|
94
|
+
end
|
|
95
|
+
add_index :hr_lite_policies, %i[title version], unique: true
|
|
96
|
+
|
|
97
|
+
create_table :hr_lite_policy_acknowledgements do |t|
|
|
98
|
+
t.references :policy, null: false, index: true,
|
|
99
|
+
foreign_key: { to_table: :hr_lite_policies, on_delete: :cascade }
|
|
100
|
+
t.bigint :user_id, null: false
|
|
101
|
+
t.datetime :acknowledged_at, null: false
|
|
102
|
+
t.timestamps
|
|
103
|
+
end
|
|
104
|
+
# One acknowledgement per person per VERSION: re-issuing a policy has to
|
|
105
|
+
# ask everybody again, which is the entire point of versioning it.
|
|
106
|
+
add_index :hr_lite_policy_acknowledgements, %i[policy_id user_id], unique: true
|
|
107
|
+
end
|
|
108
|
+
end
|