hr_lite 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a035599be75b81c0d2f3d1fc1b8f95328df845445f6c93d997a9906f9b43846e
4
- data.tar.gz: 8a840cc6c1af2ef49af660859fc6edf0f2ccec4b59377017c9cc0467793e8396
3
+ metadata.gz: 8202157b6f35309884c9df17895d711553e114a57925c8c2e381e51a7be863ba
4
+ data.tar.gz: 3cb55e413c6bcc70bd3aa39a5c212690a21c9cf747b17bfdf5894ef6d73945de
5
5
  SHA512:
6
- metadata.gz: 9bef92ea728cc960bc548c37fe3a8a34090ad531bec3ab248dc572ce9931f44fb5bf2591b1578df0c22f68ffd5ab7c02e6e7a8ed43445b1087524fc59584889b
7
- data.tar.gz: 2598075e1f67e8e2c9e7fa4142db5e7561f42f17e32a9be6207ca560116a8e9d3ac7b7876c02e4bc77c250df6ef0ec0b05592ad9c073b22aaa8f01158129276f
6
+ metadata.gz: 0ddf504c20a84c58a712fd4efe695e9b90b27eea8acc8883bfb37d33df398778169c35eb2c11f37830e91c9ceec63560df57c631246b2b22381b52df5ce30144
7
+ data.tar.gz: 4a724d4a55068129237e010aca71ce94c392656bb56953a6f1ec5329ddf5d33b6ffd91f76c82229a152f85d134a60ca29308634133f0a3a72779cff3cc6dafdb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-08-18
11
+
12
+ The screens. No migration.
13
+
14
+ ### Added
15
+
16
+ - **Employee self-service for everything built since 0.11.0**: claim an
17
+ expense (with what is left of each cap shown before you type an amount),
18
+ see what you are covered for, read and acknowledge a policy, ask HR a
19
+ question and read the answer.
20
+ - **A reports hub** — headcount, joiners and leavers, leave balances,
21
+ attendance and expenses — each with a CSV download, and each **scoped by
22
+ the same permission that guards the screen its data comes from**. A report
23
+ is not a side door into rows somebody cannot otherwise reach: a manager's
24
+ headcount is their own team. The hub lists only the reports the reader can
25
+ actually open, since a list of links that turn you away is worse than a
26
+ short list.
27
+ - CSV downloads are audited, the same way the payout register is.
28
+
29
+ ### Fixed
30
+
31
+ - **The reports hub locked Finance out of the expense report.** It inherited
32
+ the operations gate, and Finance approves expenses but runs none of the
33
+ operations screens. Found by writing the spec for it.
34
+
35
+ ### Changed
36
+
37
+ - `.hrl-prose` for long-form policy text, capped at 68 characters a line — a
38
+ policy read across the full width of a laptop is a policy nobody finishes.
39
+ - The demo sandbox seeds an expense claim, a benefit enrolment, a policy
40
+ awaiting acknowledgement and an open HR request, so the new screens have
41
+ something on them at first boot.
42
+
10
43
  ## [0.12.0] - 2026-08-18
11
44
 
12
45
  The four things an employee had to leave the system to do. **Adds one
@@ -729,7 +762,8 @@ Initial release.
729
762
  bus with per-event channel matrix (bell, email, leadership email/bell),
730
763
  daily leadership digest and an append-only audit trail.
731
764
 
732
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...HEAD
765
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.13.0...HEAD
766
+ [0.13.0]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...v0.13.0
733
767
  [0.12.0]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...v0.12.0
734
768
  [0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
735
769
  [0.10.0]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...v0.10.0
@@ -318,3 +318,9 @@
318
318
  .hrl-mt { margin-top: .85rem; }
319
319
  .hrl-right { margin-left: auto; }
320
320
  .hrl-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
321
+
322
+ /* Long-form policy text. Constrained measure — a policy read across the
323
+ full width of a laptop is a policy nobody finishes. */
324
+ .hrl-prose { max-width: 68ch; }
325
+ .hrl-prose p { margin: 0 0 .8rem; line-height: 1.6; }
326
+ .hrl-prose p:last-child { margin-bottom: 0; }
@@ -0,0 +1,151 @@
1
+ module HrLite
2
+ module Admin
3
+ # The numbers somebody asks for at the end of a month, and the CSV they
4
+ # ask for straight afterwards.
5
+ #
6
+ # Every report is scoped by the SAME permission that guards the screens
7
+ # its data comes from — a report is not a side door into rows somebody
8
+ # cannot otherwise reach.
9
+ class ReportsController < BaseController
10
+ # Finance can approve expenses but runs none of the operations screens,
11
+ # so the operations gate would have shut them out of their own expense
12
+ # report. The hub is reachable by anybody who can see AT LEAST ONE
13
+ # report; each report then checks its own permission.
14
+ skip_before_action :require_operations_access!
15
+ before_action :require_any_report!
16
+
17
+ REPORTS = {
18
+ "headcount" => "Headcount by department",
19
+ "joiners_and_leavers" => "Joiners and leavers",
20
+ "leave_balances" => "Leave balances",
21
+ "attendance" => "Attendance summary",
22
+ "expenses" => "Expense claims"
23
+ }.freeze
24
+
25
+ def index
26
+ # Only the reports this person could actually open — a list of links
27
+ # that turn you away is worse than a shorter list.
28
+ @reports = REPORTS.select { |name, _| permitted?(name) }
29
+ @month = parse_month_param(params[:month])
30
+ end
31
+
32
+ def show
33
+ @month = parse_month_param(params[:month])
34
+ @name = params[:id]
35
+ return hr_access_denied unless REPORTS.key?(@name)
36
+ return hr_access_denied unless permitted?(@name)
37
+
38
+ @rows = rows_for(@name)
39
+ respond_to do |format|
40
+ format.html
41
+ format.csv do
42
+ AuditLog.record!(action: "report.exported", subject: hr_current_user,
43
+ actor: hr_current_user,
44
+ changes: { "report" => @name, "rows" => @rows.size,
45
+ "month" => @month.strftime("%Y-%m") })
46
+ send_data to_csv(@rows), filename: "#{@name}-#{@month.strftime('%Y-%m')}.csv",
47
+ type: "text/csv"
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def require_any_report!
55
+ hr_access_denied if REPORTS.keys.none? { |name| permitted?(name) }
56
+ end
57
+
58
+ # A report reaches exactly as far as the screen it summarises.
59
+ def permitted?(name)
60
+ case name
61
+ when "expenses" then hr_can?("expense.approve", scope: :all)
62
+ when "leave_balances" then hr_can?("leave.view", scope: :team)
63
+ when "attendance" then hr_can?("attendance.view", scope: :team)
64
+ else hr_can?("profile.view", scope: :team)
65
+ end
66
+ end
67
+
68
+ def visible_users
69
+ ids = hr_access.visible_user_ids("profile.view")
70
+ employees = HrLite.employees
71
+ ids.nil? ? employees : employees.select { |user| ids.include?(user.id) }
72
+ end
73
+
74
+ def rows_for(name)
75
+ case name
76
+ when "headcount" then headcount_rows
77
+ when "joiners_and_leavers" then joiners_and_leavers_rows
78
+ when "leave_balances" then leave_balance_rows
79
+ when "attendance" then attendance_rows
80
+ when "expenses" then expense_rows
81
+ end
82
+ end
83
+
84
+ def headcount_rows
85
+ profiles = EmployeeProfile.where(user_id: visible_users.map(&:id))
86
+ .active_for(@month)
87
+ profiles.group_by { |p| p.department.presence || "Unassigned" }
88
+ .sort_by(&:first)
89
+ .map { |department, rows| { "Department" => department, "People" => rows.size } }
90
+ end
91
+
92
+ def joiners_and_leavers_rows
93
+ window = @month..@month.end_of_month
94
+ EmployeeProfile.where(user_id: visible_users.map(&:id))
95
+ .where("date_of_joining BETWEEN ? AND ? OR date_of_exit BETWEEN ? AND ?",
96
+ window.first, window.last, window.first, window.last)
97
+ .includes(:user)
98
+ .map do |profile|
99
+ { "Employee" => HrLite.display_name(profile.user),
100
+ "Department" => profile.department,
101
+ "Joined" => profile.date_of_joining&.to_s,
102
+ "Left" => profile.date_of_exit&.to_s }
103
+ end
104
+ end
105
+
106
+ def leave_balance_rows
107
+ year = LeaveYear.key_for(@month)
108
+ types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
109
+ visible_users.flat_map do |user|
110
+ types.map do |type|
111
+ balance = LeaveBalance.for(user, type, year)
112
+ { "Employee" => HrLite.display_name(user), "Type" => type.code,
113
+ "Entitled" => balance.entitled.to_f, "Used" => balance.used.to_f,
114
+ "Available" => balance.available.to_f }
115
+ end
116
+ end
117
+ end
118
+
119
+ def attendance_rows
120
+ visible_users.map do |user|
121
+ summary = AttendanceSummary.for(user: user, month: @month)
122
+ { "Employee" => HrLite.display_name(user),
123
+ "Payable days" => summary[:payable_days].to_f,
124
+ "LOP" => summary[:lop_days].to_f,
125
+ "Days in month" => summary[:days_in_month] }
126
+ end
127
+ end
128
+
129
+ def expense_rows
130
+ Expense.where(spent_on: @month..@month.end_of_month)
131
+ .includes(:user, :category).recent_first.map do |expense|
132
+ { "Employee" => HrLite.display_name(expense.user),
133
+ "Category" => expense.category.name,
134
+ "Spent on" => expense.spent_on.to_s,
135
+ "Amount" => expense.amount.to_s("F"),
136
+ "Status" => expense.status }
137
+ end
138
+ end
139
+
140
+ def to_csv(rows)
141
+ require "csv"
142
+ return "" if rows.empty?
143
+
144
+ CSV.generate do |csv|
145
+ csv << rows.first.keys
146
+ rows.each { |row| csv << row.values }
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,17 @@
1
+ module HrLite
2
+ # What the signed-in person is actually covered for — previously something
3
+ # you had to email somebody to find out.
4
+ class BenefitsController < ApplicationController
5
+ before_action :require_viewing!
6
+
7
+ def index
8
+ @enrolments = BenefitEnrolment.live_on(Date.current)
9
+ .where(user_id: hr_current_user.id)
10
+ .includes(:benefit)
11
+ end
12
+
13
+ private
14
+
15
+ def require_viewing! = hr_require_permission!("benefit.view")
16
+ end
17
+ end
@@ -0,0 +1,50 @@
1
+ module HrLite
2
+ # An employee's own claims. Scoped to the signed-in person the same way
3
+ # every other self-service screen is: a foreign id 404s through the
4
+ # relation rather than 403ing.
5
+ class ExpensesController < ApplicationController
6
+ before_action :require_claiming!
7
+
8
+ def index
9
+ @expenses = paginate(own.includes(:category).recent_first)
10
+ @categories = ExpenseCategory.active.alphabetical
11
+ end
12
+
13
+ def new
14
+ @expense = Expense.new(spent_on: Date.current)
15
+ @categories = ExpenseCategory.active.alphabetical
16
+ end
17
+
18
+ def create
19
+ @expense = Expense.new(expense_params.merge(user_id: hr_current_user.id))
20
+ if @expense.save
21
+ @expense.submit!(actor: hr_current_user)
22
+ redirect_to expenses_path, notice: "Claim submitted."
23
+ else
24
+ @categories = ExpenseCategory.active.alphabetical
25
+ render :new, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ def cancel
30
+ expense = own.find(params[:id])
31
+ if expense.draft? || expense.submitted?
32
+ expense.update!(status: "cancelled")
33
+ expense.approval_route.cancel_all!
34
+ redirect_to expenses_path, notice: "Claim withdrawn."
35
+ else
36
+ redirect_to expenses_path, alert: "Only a claim still waiting can be withdrawn."
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def require_claiming! = hr_require_permission!("expense.claim")
43
+
44
+ def own = Expense.where(user_id: hr_current_user.id)
45
+
46
+ def expense_params
47
+ params.require(:expense).permit(:category_id, :amount, :spent_on, :description, :receipt)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,43 @@
1
+ module HrLite
2
+ # The employee's side of the help desk.
3
+ class HrRequestsController < ApplicationController
4
+ before_action :require_raising!
5
+
6
+ def index
7
+ @requests = paginate(own.recent_first)
8
+ end
9
+
10
+ def show
11
+ @request = own.find(params[:id])
12
+ end
13
+
14
+ def new
15
+ @request = HrRequest.new
16
+ end
17
+
18
+ def create
19
+ @request = HrRequest.new(request_params.merge(user_id: hr_current_user.id))
20
+ if @request.save
21
+ redirect_to hr_requests_path, notice: "Sent to HR."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def cancel
28
+ request = own.find(params[:id])
29
+ request.cancel!(actor: hr_current_user)
30
+ redirect_to hr_requests_path, notice: "Request withdrawn."
31
+ rescue ActiveRecord::RecordInvalid
32
+ redirect_to hr_requests_path, alert: "That request has already been answered."
33
+ end
34
+
35
+ private
36
+
37
+ def require_raising! = hr_require_permission!("hr_request.raise")
38
+
39
+ def own = HrRequest.where(user_id: hr_current_user.id)
40
+
41
+ def request_params = params.require(:hr_request).permit(:category, :subject, :body)
42
+ end
43
+ end
@@ -0,0 +1,26 @@
1
+ module HrLite
2
+ # What people are held to, and the button that says they have read it.
3
+ class PoliciesController < ApplicationController
4
+ before_action :require_reading!
5
+
6
+ def index
7
+ @policies = Policy.current.sort_by(&:title)
8
+ @acknowledged = PolicyAcknowledgement.where(user_id: hr_current_user.id)
9
+ .pluck(:policy_id).to_set
10
+ end
11
+
12
+ def show
13
+ @policy = Policy.published.find(params[:id])
14
+ end
15
+
16
+ def acknowledge
17
+ policy = Policy.published.find(params[:id])
18
+ policy.acknowledge!(hr_current_user)
19
+ redirect_to policies_path, notice: "Thank you — recorded against #{policy.title} v#{policy.version}."
20
+ end
21
+
22
+ private
23
+
24
+ def require_reading! = hr_require_permission!("policy.view", scope: :all)
25
+ end
26
+ end
@@ -12,6 +12,10 @@ module HrLite
12
12
  # Employee tier all the same — holding an approval IS the authorisation,
13
13
  # so a manager and a stand-in covering for one reach the same screen.
14
14
  { label: "Approvals", path: :approvals_path, match: [ "/approvals" ] },
15
+ { label: "Expenses", path: :expenses_path, match: [ "/expenses" ] },
16
+ { label: "Benefits", path: :benefits_path, match: [ "/benefits" ] },
17
+ { label: "Policies", path: :policies_path, match: [ "/policies" ] },
18
+ { label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ] },
15
19
  { label: "Org", path: :org_path, match: [ "/org" ] },
16
20
  { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
17
21
  { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
@@ -21,7 +25,8 @@ module HrLite
21
25
  ADMIN_NAV_ITEMS = [
22
26
  { label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
23
27
  { label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
24
- { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] }
28
+ { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
29
+ { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] }
25
30
  ].freeze
26
31
 
27
32
  LEADERSHIP_NAV_ITEMS = [
@@ -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
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.12.0"
2
+ VERSION = "0.13.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -71,6 +71,7 @@ files:
71
71
  - app/controllers/hr_lite/admin/overview_controller.rb
72
72
  - app/controllers/hr_lite/admin/payroll_runs_controller.rb
73
73
  - app/controllers/hr_lite/admin/regularization_requests_controller.rb
74
+ - app/controllers/hr_lite/admin/reports_controller.rb
74
75
  - app/controllers/hr_lite/admin/resignations_controller.rb
75
76
  - app/controllers/hr_lite/admin/role_assignments_controller.rb
76
77
  - app/controllers/hr_lite/admin/roles_controller.rb
@@ -83,16 +84,20 @@ files:
83
84
  - app/controllers/hr_lite/appraisals_controller.rb
84
85
  - app/controllers/hr_lite/approvals_controller.rb
85
86
  - app/controllers/hr_lite/attendance_controller.rb
87
+ - app/controllers/hr_lite/benefits_controller.rb
86
88
  - app/controllers/hr_lite/calendar_controller.rb
87
89
  - app/controllers/hr_lite/career_controller.rb
88
90
  - app/controllers/hr_lite/comp_off_requests_controller.rb
89
91
  - app/controllers/hr_lite/employee_profiles_controller.rb
92
+ - app/controllers/hr_lite/expenses_controller.rb
90
93
  - app/controllers/hr_lite/holidays_controller.rb
91
94
  - app/controllers/hr_lite/home_controller.rb
95
+ - app/controllers/hr_lite/hr_requests_controller.rb
92
96
  - app/controllers/hr_lite/kudos_controller.rb
93
97
  - app/controllers/hr_lite/leave_balances_controller.rb
94
98
  - app/controllers/hr_lite/leave_requests_controller.rb
95
99
  - app/controllers/hr_lite/org_controller.rb
100
+ - app/controllers/hr_lite/policies_controller.rb
96
101
  - app/controllers/hr_lite/regularization_requests_controller.rb
97
102
  - app/controllers/hr_lite/resignations_controller.rb
98
103
  - app/controllers/hr_lite/salary_slips_controller.rb
@@ -202,6 +207,8 @@ files:
202
207
  - app/views/hr_lite/admin/payroll_runs/show.html.erb
203
208
  - app/views/hr_lite/admin/regularization_requests/index.html.erb
204
209
  - app/views/hr_lite/admin/regularization_requests/show.html.erb
210
+ - app/views/hr_lite/admin/reports/index.html.erb
211
+ - app/views/hr_lite/admin/reports/show.html.erb
205
212
  - app/views/hr_lite/admin/roles/_form.html.erb
206
213
  - app/views/hr_lite/admin/roles/edit.html.erb
207
214
  - app/views/hr_lite/admin/roles/index.html.erb
@@ -222,6 +229,7 @@ files:
222
229
  - app/views/hr_lite/attendance/_month_grid.html.erb
223
230
  - app/views/hr_lite/attendance/_punch_card.html.erb
224
231
  - app/views/hr_lite/attendance/show.html.erb
232
+ - app/views/hr_lite/benefits/index.html.erb
225
233
  - app/views/hr_lite/calendar/show.html.erb
226
234
  - app/views/hr_lite/career/show.html.erb
227
235
  - app/views/hr_lite/comp_off_requests/index.html.erb
@@ -231,9 +239,14 @@ files:
231
239
  - app/views/hr_lite/event_mailer/event.text.erb
232
240
  - app/views/hr_lite/event_mailer/leadership.html.erb
233
241
  - app/views/hr_lite/event_mailer/leadership.text.erb
242
+ - app/views/hr_lite/expenses/index.html.erb
243
+ - app/views/hr_lite/expenses/new.html.erb
234
244
  - app/views/hr_lite/holidays/_next_holiday.html.erb
235
245
  - app/views/hr_lite/holidays/index.html.erb
236
246
  - app/views/hr_lite/home/index.html.erb
247
+ - app/views/hr_lite/hr_requests/index.html.erb
248
+ - app/views/hr_lite/hr_requests/new.html.erb
249
+ - app/views/hr_lite/hr_requests/show.html.erb
237
250
  - app/views/hr_lite/kudos/_kudo.html.erb
238
251
  - app/views/hr_lite/kudos/index.html.erb
239
252
  - app/views/hr_lite/leave_balances/index.html.erb
@@ -243,6 +256,8 @@ files:
243
256
  - app/views/hr_lite/leave_requests/show.html.erb
244
257
  - app/views/hr_lite/org/_node.html.erb
245
258
  - app/views/hr_lite/org/show.html.erb
259
+ - app/views/hr_lite/policies/index.html.erb
260
+ - app/views/hr_lite/policies/show.html.erb
246
261
  - app/views/hr_lite/regularization_requests/index.html.erb
247
262
  - app/views/hr_lite/regularization_requests/new.html.erb
248
263
  - app/views/hr_lite/resignations/show.html.erb