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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -1
  3. data/app/assets/stylesheets/hr_lite/hr_lite.css +6 -0
  4. data/app/controllers/hr_lite/admin/reports_controller.rb +151 -0
  5. data/app/controllers/hr_lite/benefits_controller.rb +17 -0
  6. data/app/controllers/hr_lite/expenses_controller.rb +50 -0
  7. data/app/controllers/hr_lite/hr_requests_controller.rb +43 -0
  8. data/app/controllers/hr_lite/policies_controller.rb +26 -0
  9. data/app/helpers/hr_lite/application_helper.rb +6 -1
  10. data/app/models/hr_lite/approval_flow.rb +1 -1
  11. data/app/models/hr_lite/benefit.rb +37 -0
  12. data/app/models/hr_lite/benefit_enrolment.rb +30 -0
  13. data/app/models/hr_lite/expense.rb +142 -0
  14. data/app/models/hr_lite/expense_category.rb +33 -0
  15. data/app/models/hr_lite/hr_request.rb +75 -0
  16. data/app/models/hr_lite/policy.rb +59 -0
  17. data/app/models/hr_lite/policy_acknowledgement.rb +13 -0
  18. data/app/views/hr_lite/admin/reports/index.html.erb +40 -0
  19. data/app/views/hr_lite/admin/reports/show.html.erb +32 -0
  20. data/app/views/hr_lite/benefits/index.html.erb +33 -0
  21. data/app/views/hr_lite/expenses/index.html.erb +68 -0
  22. data/app/views/hr_lite/expenses/new.html.erb +38 -0
  23. data/app/views/hr_lite/hr_requests/index.html.erb +39 -0
  24. data/app/views/hr_lite/hr_requests/new.html.erb +28 -0
  25. data/app/views/hr_lite/hr_requests/show.html.erb +25 -0
  26. data/app/views/hr_lite/policies/index.html.erb +34 -0
  27. data/app/views/hr_lite/policies/show.html.erb +23 -0
  28. data/config/routes.rb +11 -0
  29. data/db/migrate/20260818004707_create_hr_lite_employee_services.rb +108 -0
  30. data/lib/hr_lite/notifications.rb +8 -0
  31. data/lib/hr_lite/permissions.rb +9 -0
  32. data/lib/hr_lite/role_seeds.rb +10 -3
  33. data/lib/hr_lite/version.rb +1 -1
  34. 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
+ &nbsp;
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