hr_lite 0.5.3 → 0.7.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 +107 -1
- data/README.md +67 -31
- data/app/controllers/hr_lite/admin/attendances_controller.rb +18 -2
- data/app/controllers/hr_lite/admin/base_controller.rb +16 -6
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/leadership_controller.rb +11 -8
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
- data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
- data/app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb +97 -0
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
- data/app/controllers/hr_lite/application_controller.rb +39 -1
- data/app/helpers/hr_lite/application_helper.rb +6 -2
- data/app/models/hr_lite/professional_tax_slab.rb +41 -0
- data/app/models/hr_lite/role.rb +66 -0
- data/app/models/hr_lite/role_assignment.rb +18 -0
- data/app/models/hr_lite/role_grant.rb +15 -0
- data/app/models/hr_lite/statutory_rate_card_record.rb +95 -0
- data/app/services/hr_lite/access.rb +111 -0
- data/app/services/hr_lite/calculators/professional_tax.rb +34 -5
- data/app/services/hr_lite/payroll_run_processor.rb +17 -0
- data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
- data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
- data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
- data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +109 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/edit.html.erb +4 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/index.html.erb +59 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/new.html.erb +4 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
- data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
- data/db/migrate/20260817190159_create_hr_lite_statutory_rate_cards.rb +44 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
- data/lib/hr_lite/configuration.rb +8 -1
- data/lib/hr_lite/current.rb +4 -0
- data/lib/hr_lite/permissions.rb +79 -0
- data/lib/hr_lite/role_seeds.rb +81 -0
- data/lib/hr_lite/statutory_rate_card.rb +55 -10
- data/lib/hr_lite/statutory_seeds.rb +71 -0
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +64 -10
- data/lib/tasks/hr_lite_tasks.rake +18 -2
- metadata +24 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<section class="hrl-card">
|
|
2
|
+
<%= form_with model: role, url: url, method: method, local: true do |f| %>
|
|
3
|
+
<%= render "hr_lite/shared/form_errors", record: role %>
|
|
4
|
+
|
|
5
|
+
<div class="hrl-field">
|
|
6
|
+
<%= f.label :name %>
|
|
7
|
+
<% if role.system? %>
|
|
8
|
+
<%= f.text_field :name, disabled: true %>
|
|
9
|
+
<span class="hrl-small hrl-muted">
|
|
10
|
+
Built-in roles keep their name — the upgrade path and the seed both
|
|
11
|
+
identify them by it. The permissions below are yours to change.
|
|
12
|
+
</span>
|
|
13
|
+
<% else %>
|
|
14
|
+
<%= f.text_field :name %>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="hrl-field">
|
|
18
|
+
<%= f.label :description, "Description (shown on the roles list)" %>
|
|
19
|
+
<%= f.text_field :description %>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<% HrLite::Permissions.grouped.each do |group, keys| %>
|
|
23
|
+
<fieldset>
|
|
24
|
+
<legend><%= group %></legend>
|
|
25
|
+
<% keys.each do |key| %>
|
|
26
|
+
<% held = role.grant_map[key] || "none" %>
|
|
27
|
+
<div class="hrl-field">
|
|
28
|
+
<%= label_tag "grants_#{key.tr('.', '_')}", HrLite::Permissions.description(key) %>
|
|
29
|
+
<%= select_tag "grants[#{key}]",
|
|
30
|
+
options_for_select([
|
|
31
|
+
[ "Not granted", "none" ],
|
|
32
|
+
[ "Their own records", "self" ],
|
|
33
|
+
[ "Their team's records", "team" ],
|
|
34
|
+
[ "Everyone's records", "all" ]
|
|
35
|
+
], held),
|
|
36
|
+
id: "grants_#{key.tr('.', '_')}" %>
|
|
37
|
+
</div>
|
|
38
|
+
<% end %>
|
|
39
|
+
</fieldset>
|
|
40
|
+
<% end %>
|
|
41
|
+
|
|
42
|
+
<div class="hrl-form-actions">
|
|
43
|
+
<%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
|
|
44
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_roles_path %>">Cancel</a>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
</section>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<% content_for(:page_title) { @role.name } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= @role.name %></h1>
|
|
4
|
+
<% unless @role.system? %>
|
|
5
|
+
<div class="hrl-page__actions">
|
|
6
|
+
<%= button_to "Delete role", hr_lite.admin_role_path(@role), method: :delete,
|
|
7
|
+
class: "hrl-btn hrl-btn--danger",
|
|
8
|
+
form: { data: { turbo_confirm: "Delete #{@role.name}? Everyone holding it loses that access." } } %>
|
|
9
|
+
</div>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<%= render "form", role: @role, url: hr_lite.admin_role_path(@role), method: :patch %>
|
|
14
|
+
|
|
15
|
+
<section class="hrl-card">
|
|
16
|
+
<h2 class="hrl-card__title">Who holds this role</h2>
|
|
17
|
+
<% assignments = @role.role_assignments.includes(:user).sort_by { |a| hr_display_name(a.user).downcase } %>
|
|
18
|
+
<% if assignments.empty? %>
|
|
19
|
+
<p class="hrl-muted">Nobody yet.</p>
|
|
20
|
+
<% else %>
|
|
21
|
+
<div class="hrl-table-wrap">
|
|
22
|
+
<table class="hrl-table hrl-table--stack">
|
|
23
|
+
<thead><tr><th>Person</th><th>Added</th><th></th></tr></thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
<% assignments.each do |assignment| %>
|
|
26
|
+
<tr>
|
|
27
|
+
<td data-label="Person"><%= hr_display_name(assignment.user) %></td>
|
|
28
|
+
<td data-label="Added"><%= assignment.created_at.to_date.strftime("%d %b %Y") %></td>
|
|
29
|
+
<td data-label="">
|
|
30
|
+
<%= button_to "Remove", hr_lite.admin_role_assignment_path(@role, assignment),
|
|
31
|
+
method: :delete, class: "hrl-btn",
|
|
32
|
+
form: { data: { turbo_confirm: "Remove #{hr_display_name(assignment.user)} from #{@role.name}?" } } %>
|
|
33
|
+
</td>
|
|
34
|
+
</tr>
|
|
35
|
+
<% end %>
|
|
36
|
+
</tbody>
|
|
37
|
+
</table>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<%= form_with url: hr_lite.admin_role_assignments_path(@role), method: :post, local: true do %>
|
|
42
|
+
<div class="hrl-field">
|
|
43
|
+
<%= label_tag :user_id, "Add somebody to this role" %>
|
|
44
|
+
<% held = @role.role_assignments.map(&:user_id) %>
|
|
45
|
+
<%= select_tag :user_id,
|
|
46
|
+
options_for_select(HrLite.employees.reject { |u| held.include?(u.id) }
|
|
47
|
+
.map { |u| [ hr_display_name(u), u.id ] }) %>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="hrl-form-actions">
|
|
50
|
+
<%= submit_tag "Add", class: "hrl-btn hrl-btn--primary" %>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
53
|
+
</section>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Roles" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Roles & permissions</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_role_path %>">Add role</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<section class="hrl-card">
|
|
10
|
+
<p class="hrl-muted hrl-small">
|
|
11
|
+
A permission is what somebody may do; its scope is whose records they may
|
|
12
|
+
do it to — only their own, their reports', or everyone's.
|
|
13
|
+
</p>
|
|
14
|
+
<div class="hrl-table-wrap">
|
|
15
|
+
<table class="hrl-table hrl-table--stack">
|
|
16
|
+
<thead><tr><th>Role</th><th>People</th><th>Reaches</th><th></th></tr></thead>
|
|
17
|
+
<tbody>
|
|
18
|
+
<% @roles.each do |role| %>
|
|
19
|
+
<tr>
|
|
20
|
+
<td data-label="Role">
|
|
21
|
+
<strong><%= role.name %></strong>
|
|
22
|
+
<% if role.system? %><span class="hrl-badge hrl-badge--muted">Built-in</span><% end %>
|
|
23
|
+
<% if role.description.present? %>
|
|
24
|
+
<div class="hrl-small hrl-muted"><%= role.description %></div>
|
|
25
|
+
<% end %>
|
|
26
|
+
</td>
|
|
27
|
+
<td data-label="People" class="hrl-num"><%= role.role_assignments.size %></td>
|
|
28
|
+
<td data-label="Reaches">
|
|
29
|
+
<% company_wide = role.role_grants.count { |g| g.scope == "all" } %>
|
|
30
|
+
<% team = role.role_grants.count { |g| g.scope == "team" } %>
|
|
31
|
+
<%= role.role_grants.size %> permission<%= "s" unless role.role_grants.size == 1 %>
|
|
32
|
+
<%# Joined rather than concatenated: a role with team grants and
|
|
33
|
+
no company-wide ones rendered a leading "·" with nothing
|
|
34
|
+
in front of it. %>
|
|
35
|
+
<% reach = [ ("#{company_wide} company-wide" if company_wide.positive?),
|
|
36
|
+
("#{team} own team" if team.positive?) ].compact %>
|
|
37
|
+
<% if reach.any? %>
|
|
38
|
+
<span class="hrl-small hrl-muted"><%= reach.join(" · ") %></span>
|
|
39
|
+
<% end %>
|
|
40
|
+
</td>
|
|
41
|
+
<td data-label="">
|
|
42
|
+
<a class="hrl-small" href="<%= hr_lite.edit_admin_role_path(role) %>">Edit</a>
|
|
43
|
+
</td>
|
|
44
|
+
</tr>
|
|
45
|
+
<% end %>
|
|
46
|
+
</tbody>
|
|
47
|
+
</table>
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<section class="hrl-card">
|
|
2
|
+
<% if local_assigns[:copied_from] %>
|
|
3
|
+
<p class="hrl-muted hrl-small">
|
|
4
|
+
Copied from FY <%= copied_from.financial_year %>. Most years carry PF and
|
|
5
|
+
ESI over untouched and move only the slabs — change what the Finance Act
|
|
6
|
+
changed, and leave the rest.
|
|
7
|
+
</p>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<%= form_with model: card, url: url, method: method, local: true,
|
|
11
|
+
scope: :statutory_rate_card do |f| %>
|
|
12
|
+
<%= render "hr_lite/shared/form_errors", record: card %>
|
|
13
|
+
|
|
14
|
+
<div class="hrl-field">
|
|
15
|
+
<%= f.label :effective_from, "Financial year starts (1 April)" %>
|
|
16
|
+
<%= f.date_field :effective_from %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<fieldset>
|
|
20
|
+
<legend>Provident fund</legend>
|
|
21
|
+
<% { employee_rate: "Employee rate (0.12 = 12%)", employer_rate: "Employer rate",
|
|
22
|
+
eps_rate: "EPS rate", wage_ceiling: "PF wage ceiling (₹)",
|
|
23
|
+
eps_wage_ceiling: "EPS wage ceiling (₹)", edli_rate: "EDLI rate",
|
|
24
|
+
edli_ceiling: "EDLI ceiling (₹)", admin_rate: "PF admin rate" }.each do |key, label| %>
|
|
25
|
+
<div class="hrl-field">
|
|
26
|
+
<%= label_tag "statutory_rate_card_pf_#{key}", label %>
|
|
27
|
+
<%= text_field_tag "statutory_rate_card[pf][#{key}]", card.pf[key.to_s],
|
|
28
|
+
id: "statutory_rate_card_pf_#{key}", inputmode: "decimal" %>
|
|
29
|
+
</div>
|
|
30
|
+
<% end %>
|
|
31
|
+
</fieldset>
|
|
32
|
+
|
|
33
|
+
<fieldset>
|
|
34
|
+
<legend>ESI</legend>
|
|
35
|
+
<% { employee_rate: "Employee rate", employer_rate: "Employer rate",
|
|
36
|
+
gross_ceiling: "Gross ceiling (₹)" }.each do |key, label| %>
|
|
37
|
+
<div class="hrl-field">
|
|
38
|
+
<%= label_tag "statutory_rate_card_esi_#{key}", label %>
|
|
39
|
+
<%= text_field_tag "statutory_rate_card[esi][#{key}]", card.esi[key.to_s],
|
|
40
|
+
id: "statutory_rate_card_esi_#{key}", inputmode: "decimal" %>
|
|
41
|
+
</div>
|
|
42
|
+
<% end %>
|
|
43
|
+
</fieldset>
|
|
44
|
+
|
|
45
|
+
<% %w[new old].each do |regime| %>
|
|
46
|
+
<% table = card.income_tax[regime] || {} %>
|
|
47
|
+
<fieldset>
|
|
48
|
+
<legend><%= regime.capitalize %> regime</legend>
|
|
49
|
+
<% { "standard_deduction" => "Standard deduction (₹)",
|
|
50
|
+
"rebate_cap" => "§87A rebate cap (₹)",
|
|
51
|
+
"cess_rate" => "Cess rate (0.04 = 4%)" }.each do |key, label| %>
|
|
52
|
+
<div class="hrl-field">
|
|
53
|
+
<%= label_tag "statutory_rate_card_it_#{regime}_#{key}", label %>
|
|
54
|
+
<%= text_field_tag "statutory_rate_card[income_tax][#{regime}][#{key}]", table[key],
|
|
55
|
+
id: "statutory_rate_card_it_#{regime}_#{key}", inputmode: "decimal" %>
|
|
56
|
+
</div>
|
|
57
|
+
<% end %>
|
|
58
|
+
<div class="hrl-field hrl-field--check">
|
|
59
|
+
<%= hidden_field_tag "statutory_rate_card[income_tax][#{regime}][marginal_relief]", "0" %>
|
|
60
|
+
<%= check_box_tag "statutory_rate_card[income_tax][#{regime}][marginal_relief]", "1",
|
|
61
|
+
table["marginal_relief"] %>
|
|
62
|
+
<%= label_tag "statutory_rate_card_income_tax_#{regime}_marginal_relief",
|
|
63
|
+
"Marginal relief just above the rebate cap" %>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<p class="hrl-small hrl-muted">
|
|
67
|
+
Slabs, lowest first. Leave the last band's "up to" empty — that is
|
|
68
|
+
the open-ended top rate.
|
|
69
|
+
</p>
|
|
70
|
+
<% slabs = Array(table["slabs"]) %>
|
|
71
|
+
<% (slabs + [ nil, nil ]).each_with_index do |row, index| %>
|
|
72
|
+
<div class="hrl-row">
|
|
73
|
+
<%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][from]",
|
|
74
|
+
row&.first, placeholder: "From ₹", inputmode: "decimal" %>
|
|
75
|
+
<%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][to]",
|
|
76
|
+
row&.at(1), placeholder: "Up to ₹ (blank = top)", inputmode: "decimal" %>
|
|
77
|
+
<%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][rate]",
|
|
78
|
+
row&.last, placeholder: "Rate (0.05)", inputmode: "decimal" %>
|
|
79
|
+
</div>
|
|
80
|
+
<% end %>
|
|
81
|
+
</fieldset>
|
|
82
|
+
<% end %>
|
|
83
|
+
|
|
84
|
+
<fieldset>
|
|
85
|
+
<legend>Sign-off</legend>
|
|
86
|
+
<p class="hrl-small hrl-muted">
|
|
87
|
+
Until a card is signed off, every payroll run computed on it carries a
|
|
88
|
+
warning. Record the person who actually checked the figures.
|
|
89
|
+
</p>
|
|
90
|
+
<div class="hrl-field">
|
|
91
|
+
<%= f.label :verified_by, "Checked by (name)" %>
|
|
92
|
+
<%= f.text_field :verified_by %>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="hrl-field">
|
|
95
|
+
<%= f.label :verified_on, "Checked on" %>
|
|
96
|
+
<%= f.date_field :verified_on %>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="hrl-field">
|
|
99
|
+
<%= f.label :notes, "Notes" %>
|
|
100
|
+
<%= f.text_field :notes %>
|
|
101
|
+
</div>
|
|
102
|
+
</fieldset>
|
|
103
|
+
|
|
104
|
+
<div class="hrl-form-actions">
|
|
105
|
+
<%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
|
|
106
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_statutory_rate_cards_path %>">Cancel</a>
|
|
107
|
+
</div>
|
|
108
|
+
<% end %>
|
|
109
|
+
</section>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Statutory rates" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Statutory rates</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_statutory_rate_card_path %>">Add a year</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<section class="hrl-card">
|
|
10
|
+
<p class="hrl-muted hrl-small">
|
|
11
|
+
PF, ESI and the income-tax slabs, per financial year. Payroll resolves the
|
|
12
|
+
card that was in force for the month it is computing, so correcting a year
|
|
13
|
+
never changes an earlier one.
|
|
14
|
+
</p>
|
|
15
|
+
<div class="hrl-table-wrap">
|
|
16
|
+
<table class="hrl-table hrl-table--stack">
|
|
17
|
+
<thead><tr><th>Year</th><th>PF ceiling</th><th>ESI ceiling</th><th>Verified</th><th></th></tr></thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
<% @cards.each do |card| %>
|
|
20
|
+
<tr>
|
|
21
|
+
<td data-label="Year">
|
|
22
|
+
<strong>FY <%= card.financial_year %></strong>
|
|
23
|
+
<div class="hrl-small hrl-muted">from <%= card.effective_from.strftime("%d %b %Y") %></div>
|
|
24
|
+
</td>
|
|
25
|
+
<td data-label="PF ceiling" class="hrl-num"><%= HrLite::Money.format(card.to_card[:pf][:wage_ceiling]) %></td>
|
|
26
|
+
<td data-label="ESI ceiling" class="hrl-num"><%= HrLite::Money.format(card.to_card[:esi][:gross_ceiling]) %></td>
|
|
27
|
+
<td data-label="Verified">
|
|
28
|
+
<% if card.verified? %>
|
|
29
|
+
<span class="hrl-badge"><%= card.verified_by %></span>
|
|
30
|
+
<div class="hrl-small hrl-muted"><%= card.verified_on.strftime("%d %b %Y") %></div>
|
|
31
|
+
<% else %>
|
|
32
|
+
<span class="hrl-badge hrl-badge--muted">Not checked</span>
|
|
33
|
+
<% end %>
|
|
34
|
+
</td>
|
|
35
|
+
<td data-label="">
|
|
36
|
+
<a class="hrl-small" href="<%= hr_lite.edit_admin_statutory_rate_card_path(card) %>">Edit</a>
|
|
37
|
+
</td>
|
|
38
|
+
</tr>
|
|
39
|
+
<% end %>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
43
|
+
</section>
|
|
44
|
+
|
|
45
|
+
<section class="hrl-card">
|
|
46
|
+
<h2 class="hrl-card__title">Professional tax</h2>
|
|
47
|
+
<% if @states.empty? %>
|
|
48
|
+
<p class="hrl-muted">
|
|
49
|
+
No state bands are configured. Everybody's professional tax is being
|
|
50
|
+
deducted at ₹0 — which is right only if no state you employ in levies it.
|
|
51
|
+
</p>
|
|
52
|
+
<% else %>
|
|
53
|
+
<p class="hrl-muted hrl-small">
|
|
54
|
+
Bands are configured for: <%= @states.map(&:humanize).to_sentence %>.
|
|
55
|
+
An employee whose salary structure names any other state is deducted ₹0,
|
|
56
|
+
and payroll says so on the run.
|
|
57
|
+
</p>
|
|
58
|
+
<% end %>
|
|
59
|
+
</section>
|
data/config/routes.rb
CHANGED
|
@@ -46,6 +46,10 @@ HrLite::Engine.routes.draw do
|
|
|
46
46
|
resources :regularization_requests, only: %i[index show] do
|
|
47
47
|
member { post :approve; post :reject }
|
|
48
48
|
end
|
|
49
|
+
resources :roles, except: :show do
|
|
50
|
+
resources :assignments, only: %i[create destroy], controller: "role_assignments"
|
|
51
|
+
end
|
|
52
|
+
resources :statutory_rate_cards, only: %i[index new create edit update]
|
|
49
53
|
resources :leave_types, except: :show
|
|
50
54
|
resources :office_locations, except: :show
|
|
51
55
|
resources :holidays, only: %i[index create update destroy] do
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
class CreateHrLiteRolesAndGrants < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Roles replace the three configured lambdas (admin_check, leadership_emails,
|
|
3
|
+
# superadmin_emails). Two of those matched a MUTABLE email string — a
|
|
4
|
+
# permission stored in a column the host lets people edit.
|
|
5
|
+
#
|
|
6
|
+
# Permissions themselves are not a table: the vocabulary is declared in
|
|
7
|
+
# HrLite::Permissions::REGISTRY, so a grant is a (role, key, scope) row and
|
|
8
|
+
# an unknown key cannot be stored. That keeps seeding idempotent, and makes
|
|
9
|
+
# retiring a permission a code change with a spec rather than silent data.
|
|
10
|
+
def change
|
|
11
|
+
create_table :hr_lite_roles do |t|
|
|
12
|
+
t.string :name, null: false
|
|
13
|
+
t.string :description
|
|
14
|
+
# Roles the engine seeds and refers to by name. Renaming or deleting one
|
|
15
|
+
# is refused by the model — the upgrade path off the email lists lands
|
|
16
|
+
# people in these, and the roles screen has to keep meaning something.
|
|
17
|
+
t.boolean :system, null: false, default: false
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
add_index :hr_lite_roles, :name, unique: true
|
|
21
|
+
|
|
22
|
+
create_table :hr_lite_role_grants do |t|
|
|
23
|
+
t.references :role, null: false, index: false,
|
|
24
|
+
foreign_key: { to_table: :hr_lite_roles, on_delete: :cascade }
|
|
25
|
+
t.string :permission_key, null: false
|
|
26
|
+
# self | team | all — see HrLite::Permissions. Stored per grant so one
|
|
27
|
+
# key covers "own leave", "my reports' leave" and "everyone's leave".
|
|
28
|
+
t.string :scope, null: false, default: "self"
|
|
29
|
+
t.timestamps
|
|
30
|
+
end
|
|
31
|
+
# One scope per (role, permission): a role either reaches a set of rows or
|
|
32
|
+
# it does not, and two rows for one key would make that a lookup race.
|
|
33
|
+
add_index :hr_lite_role_grants, %i[role_id permission_key], unique: true
|
|
34
|
+
add_check_constraint :hr_lite_role_grants, "scope IN ('self', 'team', 'all')",
|
|
35
|
+
name: "hr_lite_role_grants_scope_check"
|
|
36
|
+
|
|
37
|
+
create_table :hr_lite_role_assignments do |t|
|
|
38
|
+
# No FK to the host user table — its name is host-specific, the same
|
|
39
|
+
# reason every other user_id in this engine is a bare bigint.
|
|
40
|
+
t.bigint :user_id, null: false
|
|
41
|
+
t.references :role, null: false, index: false,
|
|
42
|
+
foreign_key: { to_table: :hr_lite_roles, on_delete: :cascade }
|
|
43
|
+
t.bigint :granted_by_id
|
|
44
|
+
t.timestamps
|
|
45
|
+
end
|
|
46
|
+
add_index :hr_lite_role_assignments, %i[user_id role_id], unique: true
|
|
47
|
+
add_index :hr_lite_role_assignments, :role_id
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
class SeedHrLiteRolesFromEmailTiers < ActiveRecord::Migration[8.1]
|
|
2
|
+
# The upgrade path off the email lists. On an install that is already
|
|
3
|
+
# running, this is the migration that decides whether anyone loses access,
|
|
4
|
+
# so it is deliberately generous: everybody who could reach something
|
|
5
|
+
# before can reach at least as much afterwards.
|
|
6
|
+
#
|
|
7
|
+
# config.superadmin_emails -> Super Admin
|
|
8
|
+
# config.leadership_emails -> Leadership
|
|
9
|
+
# admin_check == true -> HR
|
|
10
|
+
# everybody else -> Employee
|
|
11
|
+
#
|
|
12
|
+
# It reads the host's OWN configuration, so nothing has to be typed twice,
|
|
13
|
+
# and it says out loud what it did — an upgrade that silently grants the
|
|
14
|
+
# money tier to the wrong person is the failure worth being loud about.
|
|
15
|
+
def up
|
|
16
|
+
require "hr_lite/role_seeds"
|
|
17
|
+
created = HrLite::RoleSeeds.call
|
|
18
|
+
say "Seeded roles: #{created.join(', ')}" if created.any?
|
|
19
|
+
|
|
20
|
+
# A host that has already assigned roles by hand is not re-derived from
|
|
21
|
+
# email lists — that would undo their work.
|
|
22
|
+
if HrLite::RoleAssignment.exists?
|
|
23
|
+
say "Role assignments already exist — leaving them alone."
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
roles = HrLite::Role.where(name: [
|
|
28
|
+
HrLite::Role::EMPLOYEE, HrLite::Role::HR,
|
|
29
|
+
HrLite::Role::LEADERSHIP, HrLite::Role::SUPER_ADMIN
|
|
30
|
+
]).index_by(&:name)
|
|
31
|
+
|
|
32
|
+
# The legacy predicates, read directly rather than through HrLite.admin?
|
|
33
|
+
# — which by now answers off roles, and would say no to everyone.
|
|
34
|
+
superadmins = HrLite.normalize_email_list(HrLite.config.superadmin_emails)
|
|
35
|
+
leaders = HrLite.normalize_email_list(HrLite.config.leadership_emails)
|
|
36
|
+
# Pre-0.5.0 behaviour, still live on hosts that never set the money list:
|
|
37
|
+
# an empty superadmin list meant "the same people as leadership".
|
|
38
|
+
superadmins = leaders if superadmins.empty?
|
|
39
|
+
|
|
40
|
+
assigned = Hash.new { |hash, key| hash[key] = [] }
|
|
41
|
+
|
|
42
|
+
HrLite.config.employees_scope.call.find_each do |user|
|
|
43
|
+
address = user.respond_to?(:email) ? user.email.to_s.downcase.strip : ""
|
|
44
|
+
names = [ HrLite::Role::EMPLOYEE ]
|
|
45
|
+
names << HrLite::Role::SUPER_ADMIN if address.present? && superadmins.include?(address)
|
|
46
|
+
names << HrLite::Role::LEADERSHIP if address.present? && leaders.include?(address)
|
|
47
|
+
names << HrLite::Role::HR if legacy_admin?(user)
|
|
48
|
+
|
|
49
|
+
names.uniq.each do |name|
|
|
50
|
+
role = roles[name] or next
|
|
51
|
+
|
|
52
|
+
HrLite::RoleAssignment.create!(user_id: user.id, role: role)
|
|
53
|
+
assigned[name] << HrLite.display_name(user)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if assigned.empty?
|
|
58
|
+
say "No users matched employees_scope — assign roles from the Roles screen."
|
|
59
|
+
else
|
|
60
|
+
assigned.each { |name, people| say "#{name}: #{people.sort.join(', ')}" }
|
|
61
|
+
end
|
|
62
|
+
say "Set `config.legacy_tier_checks = true` to keep the old email lists in " \
|
|
63
|
+
"charge for now; it is honoured until 0.7.0."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Assignments only — the roles themselves stay, because dropping them would
|
|
67
|
+
# take every hand-made grant with them.
|
|
68
|
+
def down
|
|
69
|
+
HrLite::RoleAssignment.delete_all
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# admin_check is a host lambda over the host's own user model; a host whose
|
|
75
|
+
# users do not answer it must not break the whole upgrade.
|
|
76
|
+
def legacy_admin?(user)
|
|
77
|
+
!!HrLite.config.admin_check.call(user)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
say "admin_check raised for #{HrLite.display_name(user)} (#{e.class}) — treated as not HR."
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
class CreateHrLiteStatutoryRateCards < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Statutory rates lived in a frozen hash in Ruby, so adding a financial year
|
|
3
|
+
# meant a gem release. Every April that is a deadline the gem controls and
|
|
4
|
+
# the company does not — and 0.5.3 shipped a warning about exactly that
|
|
5
|
+
# because the FY2026-27 card did not exist.
|
|
6
|
+
#
|
|
7
|
+
# Rates are not secret (they are published by the government), so the
|
|
8
|
+
# figures are plain JSON. What matters is that they are EFFECTIVE-DATED and
|
|
9
|
+
# carry who checked them: a payroll rerun of an old month must resolve to
|
|
10
|
+
# the card that was in force for that month, not to today's.
|
|
11
|
+
def change
|
|
12
|
+
create_table :hr_lite_statutory_rate_cards do |t|
|
|
13
|
+
t.date :effective_from, null: false
|
|
14
|
+
t.json :pf, null: false, default: {}
|
|
15
|
+
t.json :esi, null: false, default: {}
|
|
16
|
+
t.json :income_tax, null: false, default: {}
|
|
17
|
+
# Who signed it off, and when. A card nobody has confirmed is a card
|
|
18
|
+
# payroll should say something about — see SlipBuilder's warnings.
|
|
19
|
+
t.string :verified_by
|
|
20
|
+
t.date :verified_on
|
|
21
|
+
t.text :notes
|
|
22
|
+
t.timestamps
|
|
23
|
+
end
|
|
24
|
+
# One card per date: two would make "which rates applied in June" a
|
|
25
|
+
# question about row order.
|
|
26
|
+
add_index :hr_lite_statutory_rate_cards, :effective_from, unique: true
|
|
27
|
+
|
|
28
|
+
create_table :hr_lite_professional_tax_slabs do |t|
|
|
29
|
+
# Professional tax is a STATE levy. Karnataka was the only state with
|
|
30
|
+
# real slabs in code; every other state silently computed zero, which
|
|
31
|
+
# is a wrong answer delivered confidently.
|
|
32
|
+
t.string :state, null: false
|
|
33
|
+
t.date :effective_from, null: false
|
|
34
|
+
# Inclusive lower bound on monthly earned gross.
|
|
35
|
+
t.decimal :from_amount, precision: 12, scale: 2, null: false
|
|
36
|
+
t.decimal :monthly, precision: 12, scale: 2, null: false
|
|
37
|
+
# Several states collect a larger amount in the last month of the year.
|
|
38
|
+
t.decimal :feb_extra, precision: 12, scale: 2
|
|
39
|
+
t.timestamps
|
|
40
|
+
end
|
|
41
|
+
add_index :hr_lite_professional_tax_slabs, %i[state effective_from from_amount],
|
|
42
|
+
unique: true, name: "index_hr_lite_pt_slabs_on_state_date_and_bound"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -7,13 +7,19 @@ HrLite.configure do |c|
|
|
|
7
7
|
c.current_user_method = :current_user
|
|
8
8
|
c.authenticate_method = :authenticate_user!
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
10
|
+
# Access is a role table, not configuration — `rake hr_lite:seed` creates
|
|
11
|
+
# the built-in roles and you assign people from /admin/roles. The first
|
|
12
|
+
# person needs Super Admin, which the upgrade migration grants on an
|
|
13
|
+
# existing install; on a fresh one, from a console:
|
|
14
|
+
#
|
|
15
|
+
# HrLite::RoleAssignment.create!(
|
|
16
|
+
# user_id: User.find_by!(email: "you@example.com").id,
|
|
17
|
+
# role: HrLite::Role.find_by!(name: HrLite::Role::SUPER_ADMIN))
|
|
18
|
+
#
|
|
19
|
+
# Upgrading from pre-0.6.0 and not ready to move? Uncomment this and keep
|
|
20
|
+
# your leadership_emails / superadmin_emails / admin_check as they were.
|
|
21
|
+
# It is honoured until 0.8.0.
|
|
22
|
+
# c.legacy_tier_checks = true
|
|
17
23
|
|
|
18
24
|
# Where the portal is reachable (subdomain or path). Enables email link
|
|
19
25
|
# buttons and HrLite.public_url / HrLite.public_url? for deep links.
|
|
@@ -10,7 +10,8 @@ module HrLite
|
|
|
10
10
|
:leadership_emails, :leadership_check, :extra_stylesheets,
|
|
11
11
|
:superadmin_emails, :superadmin_check,
|
|
12
12
|
:mailer_from, :public_url_base, :notification_matrix, :back_link,
|
|
13
|
-
:onboard_user, :offboard_user, :invite_url_for
|
|
13
|
+
:onboard_user, :offboard_user, :invite_url_for,
|
|
14
|
+
:legacy_tier_checks
|
|
14
15
|
|
|
15
16
|
attr_reader :leave_year_start_month
|
|
16
17
|
|
|
@@ -45,6 +46,12 @@ module HrLite
|
|
|
45
46
|
@time_zone = "Asia/Kolkata"
|
|
46
47
|
@currency_symbol = "₹"
|
|
47
48
|
@on_designation_change = ->(user, designation) { }
|
|
49
|
+
# Access came from three lambdas before 0.6.0, two of which matched the
|
|
50
|
+
# user's EMAIL — a mutable, host-owned, unverified column that a host
|
|
51
|
+
# then had to remember never to let anyone edit. Roles replace them.
|
|
52
|
+
# This flag hands authority back to the old lambdas for a host that has
|
|
53
|
+
# not finished migrating; honoured for one more minor version, gone in 0.8.0.
|
|
54
|
+
@legacy_tier_checks = false
|
|
48
55
|
@leadership_emails = []
|
|
49
56
|
@leadership_check = ->(user) { HrLite.email_listed?(user, HrLite.config.leadership_emails) }
|
|
50
57
|
# Money tier: salary structures, payroll, slips, appraisals. Empty
|
data/lib/hr_lite/current.rb
CHANGED
|
@@ -3,5 +3,9 @@ module HrLite
|
|
|
3
3
|
# readable from any model callback without threading it through.
|
|
4
4
|
class Current < ActiveSupport::CurrentAttributes
|
|
5
5
|
attribute :actor
|
|
6
|
+
# Resolved permissions, keyed by user id. Every screen asks several times
|
|
7
|
+
# per request and resolution joins three tables; CurrentAttributes is
|
|
8
|
+
# reset between requests, so a role change takes effect on the next one.
|
|
9
|
+
attribute :access_cache
|
|
6
10
|
end
|
|
7
11
|
end
|