hr_lite 0.13.0 → 0.15.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 +86 -1
- data/app/controllers/hr_lite/admin/assets_controller.rb +53 -0
- data/app/controllers/hr_lite/admin/checklists_controller.rb +32 -0
- data/app/controllers/hr_lite/admin/documents_controller.rb +63 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +4 -0
- data/app/controllers/hr_lite/admin/expenses_controller.rb +45 -0
- data/app/controllers/hr_lite/admin/hr_requests_controller.rb +38 -0
- data/app/controllers/hr_lite/admin/loans_controller.rb +59 -0
- data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +75 -0
- data/app/controllers/hr_lite/documents_controller.rb +69 -0
- data/app/controllers/hr_lite/loans_controller.rb +11 -0
- data/app/controllers/hr_lite/tax_declarations_controller.rb +100 -0
- data/app/helpers/hr_lite/application_helper.rb +14 -4
- data/app/models/hr_lite/approval_flow.rb +1 -1
- data/app/models/hr_lite/asset.rb +57 -0
- data/app/models/hr_lite/asset_assignment.rb +24 -0
- data/app/models/hr_lite/checklist_item.rb +43 -0
- data/app/models/hr_lite/checklist_template.rb +50 -0
- data/app/models/hr_lite/employee_profile.rb +13 -0
- data/app/models/hr_lite/tax_declaration.rb +4 -1
- data/app/views/hr_lite/admin/assets/index.html.erb +59 -0
- data/app/views/hr_lite/admin/assets/new.html.erb +23 -0
- data/app/views/hr_lite/admin/checklists/index.html.erb +50 -0
- data/app/views/hr_lite/admin/documents/index.html.erb +89 -0
- data/app/views/hr_lite/admin/documents/show.html.erb +42 -0
- data/app/views/hr_lite/admin/expenses/index.html.erb +54 -0
- data/app/views/hr_lite/admin/hr_requests/index.html.erb +41 -0
- data/app/views/hr_lite/admin/hr_requests/show.html.erb +47 -0
- data/app/views/hr_lite/admin/loans/index.html.erb +43 -0
- data/app/views/hr_lite/admin/loans/new.html.erb +39 -0
- data/app/views/hr_lite/admin/loans/show.html.erb +52 -0
- data/app/views/hr_lite/admin/tax_declarations/index.html.erb +37 -0
- data/app/views/hr_lite/admin/tax_declarations/show.html.erb +54 -0
- data/app/views/hr_lite/documents/index.html.erb +84 -0
- data/app/views/hr_lite/loans/index.html.erb +52 -0
- data/app/views/hr_lite/tax_declarations/show.html.erb +68 -0
- data/config/routes.rb +29 -0
- data/db/migrate/20260818010401_create_hr_lite_assets_and_onboarding.rb +76 -0
- data/lib/hr_lite/permissions.rb +3 -0
- data/lib/hr_lite/role_seeds.rb +5 -3
- data/lib/hr_lite/version.rb +1 -1
- data/lib/tasks/hr_lite_tasks.rake +2 -1
- metadata +32 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec0aa9dccb203487f12a0e30cbbae7567da32b9589af4046fe86795c9a3cb5a4
|
|
4
|
+
data.tar.gz: 0e5b02a62ea2925115f71279dd9772f12a103b6cc17fb6d192f0225a3c95504e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1877020700a05db3f7a5a4a715be0c1e8e7f4ad447d3c42eaf6d17b60e0252ef68968eb58f431e7373e43d3452bae468d6e211eac64c3f4e9ca71703920d412
|
|
7
|
+
data.tar.gz: 28d5a185fd3af1373efb3b2db1e3d53d8b64e66ec676a7cfed01c15ffbb8ed2a07b0a921d4351ac6afca41db18e683ae9ad984e99b5ffbf6eeac32209e108af0
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.0] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
The screens for three features that shipped as tables nobody could reach. No
|
|
13
|
+
migration — the data layer was already there.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Documents, tax declarations and loans had no UI at all.** 0.13.0 and
|
|
18
|
+
0.14.0 shipped their models, tables, payroll integration and an expiry job,
|
|
19
|
+
and every one of them passed its specs — but there was no controller, no
|
|
20
|
+
route and no view. An employee could not upload a passport, HR could not
|
|
21
|
+
verify a PAN, and nobody could file an 80C declaration. Loans at least
|
|
22
|
+
reached a payslip; the other two were unreachable rows.
|
|
23
|
+
- **A tax declaration 500ed when a real browser saved it.** The form posts a
|
|
24
|
+
line for every section, because that is what a list of claimable things
|
|
25
|
+
looks like, and the blank ones violated a NOT NULL amount. The specs had
|
|
26
|
+
only ever posted the lines they filled in. Blank sections are now dropped,
|
|
27
|
+
and clearing an existing amount withdraws that claim.
|
|
28
|
+
- `TaxDeclaration` and `ApprovalFlow` were missing `inverse_of` on their
|
|
29
|
+
nested associations. The foreign key is not derived from the class name, so
|
|
30
|
+
a nested item on an unsaved parent could not see it — every first save
|
|
31
|
+
failed with "declaration must exist".
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **Documents.** Employees upload, see what is expiring and remove anything
|
|
36
|
+
not yet verified; HR sees what is waiting, whose file is missing which
|
|
37
|
+
category, and verifies or sends one back with a reason. Downloads go
|
|
38
|
+
through the row's own `readable_by?` and every one is audited — a file
|
|
39
|
+
leaving the building is the event worth recording.
|
|
40
|
+
- **Tax declarations.** A section-by-section claim the employee files
|
|
41
|
+
themselves, replacing an admin typing one lump sum into the profile. HR
|
|
42
|
+
records what the proof actually supported, which is often less than the
|
|
43
|
+
claim. Until it is verified payroll deducts against the CLAIM: making
|
|
44
|
+
somebody overpay tax all year because paperwork is slow is its own kind of
|
|
45
|
+
wrong.
|
|
46
|
+
- **Loans.** Employees see what they owe and what comes out of the next
|
|
47
|
+
payslip; the money tier records a loan, closes it, or cancels one that has
|
|
48
|
+
not been deducted from yet. Cancelling after an instalment has been taken
|
|
49
|
+
is refused — that money has already left somebody's salary.
|
|
50
|
+
- Nav entries for all three, employee and admin.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- Verifying a document requires being able to OPEN it. Attesting to a scan
|
|
55
|
+
you are not allowed to look at is not verification, and it keeps an
|
|
56
|
+
Aadhaar with the money tier rather than with whoever happens to run HR.
|
|
57
|
+
- The spec suite deletes its sqlite file before every run. The role and
|
|
58
|
+
rate-card migrations SEED rows, and seeds never overwrite what exists —
|
|
59
|
+
right in production, wrong in a test database, where a leftover file kept
|
|
60
|
+
yesterday's definitions and a change to `RoleSeeds` silently did not apply.
|
|
61
|
+
|
|
62
|
+
## [0.14.0] - 2026-08-18
|
|
63
|
+
|
|
64
|
+
The admin half, plus the two ends of employment that were still manual.
|
|
65
|
+
**Adds one migration.**
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **Admin screens for everything shipped since 0.11.0**: decide a claim and
|
|
70
|
+
record the payroll month it was paid with, answer or hand on a help-desk
|
|
71
|
+
request, hand out and take back an asset. The engine had all of this; there
|
|
72
|
+
was no way to do any of it.
|
|
73
|
+
- **Assets.** One live holder at a time, enforced by a partial unique index —
|
|
74
|
+
two open assignments would mean the laptop is in two places, which it is
|
|
75
|
+
not. Assignments are append-only and record what condition it came back in,
|
|
76
|
+
because "screen cracked" is the difference between an asset returned and an
|
|
77
|
+
asset written off.
|
|
78
|
+
- **Joining and leaving checklists**, from templates. The joining list opens
|
|
79
|
+
when a profile is created and the leaving list when an exit date is
|
|
80
|
+
stamped — the checklist nobody remembers to start is the one that does not
|
|
81
|
+
happen. Opening is idempotent, so adding a template later adds only what is
|
|
82
|
+
new. A failure to build one can never stop an employee being created.
|
|
83
|
+
- Each step names the permission its owner should hold, so IT taking back a
|
|
84
|
+
laptop and HR collecting a document are not the same person by accident.
|
|
85
|
+
- Permissions `asset.view`, `asset.manage`, `checklist.manage`.
|
|
86
|
+
|
|
87
|
+
### Why it matters
|
|
88
|
+
|
|
89
|
+
An asset that is never returned is a cost nobody notices, and access that is
|
|
90
|
+
never revoked is a security problem that outlives the employment. Both were
|
|
91
|
+
previously somebody's memory.
|
|
92
|
+
|
|
10
93
|
## [0.13.0] - 2026-08-18
|
|
11
94
|
|
|
12
95
|
The screens. No migration.
|
|
@@ -762,7 +845,9 @@ Initial release.
|
|
|
762
845
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
763
846
|
daily leadership digest and an append-only audit trail.
|
|
764
847
|
|
|
765
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
848
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.15.0...HEAD
|
|
849
|
+
[0.15.0]: https://github.com/kshtzkr/hr_lite/compare/v0.14.0...v0.15.0
|
|
850
|
+
[0.14.0]: https://github.com/kshtzkr/hr_lite/compare/v0.13.0...v0.14.0
|
|
766
851
|
[0.13.0]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...v0.13.0
|
|
767
852
|
[0.12.0]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...v0.12.0
|
|
768
853
|
[0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Who has the laptop.
|
|
4
|
+
class AssetsController < BaseController
|
|
5
|
+
skip_before_action :require_operations_access!
|
|
6
|
+
before_action :require_assets!
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@assets = paginate(Asset.order(:status, :name).includes(asset_assignments: :user))
|
|
10
|
+
@outstanding = AssetAssignment.live.includes(:asset, :user)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def new
|
|
14
|
+
@asset = Asset.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create
|
|
18
|
+
@asset = Asset.new(asset_params)
|
|
19
|
+
if @asset.save
|
|
20
|
+
redirect_to admin_assets_path, notice: "#{@asset.name} added."
|
|
21
|
+
else
|
|
22
|
+
render :new, status: :unprocessable_entity
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def assign
|
|
27
|
+
asset = Asset.find(params[:id])
|
|
28
|
+
user = HrLite.user_klass.find(params[:user_id])
|
|
29
|
+
asset.assign_to!(user, actor: hr_current_user)
|
|
30
|
+
redirect_to admin_assets_path, notice: "#{asset.name} given to #{HrLite.display_name(user)}."
|
|
31
|
+
rescue ActiveRecord::RecordInvalid
|
|
32
|
+
redirect_to admin_assets_path, alert: "#{asset.name} is already with somebody."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def take_back
|
|
36
|
+
asset = Asset.find(params[:id])
|
|
37
|
+
asset.return!(actor: hr_current_user, condition: params[:condition_note],
|
|
38
|
+
status: params[:status].presence_in(Asset::STATUSES) || "available")
|
|
39
|
+
redirect_to admin_assets_path, notice: "#{asset.name} taken back."
|
|
40
|
+
rescue ActiveRecord::RecordInvalid
|
|
41
|
+
redirect_to admin_assets_path, alert: "#{asset.name} is not with anybody."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def require_assets! = hr_require_permission!("asset.manage", scope: :all)
|
|
47
|
+
|
|
48
|
+
def asset_params
|
|
49
|
+
params.require(:asset).permit(:name, :category, :serial_number, :purchased_on, :notes)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Joining and leaving, step by step, with what is overdue at the top.
|
|
4
|
+
class ChecklistsController < BaseController
|
|
5
|
+
skip_before_action :require_operations_access!
|
|
6
|
+
before_action :require_checklists!
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@kind = params[:kind].presence_in(ChecklistTemplate::KINDS) || "onboarding"
|
|
10
|
+
@items = ChecklistItem.for_kind(@kind).outstanding.includes(:user)
|
|
11
|
+
.order(Arel.sql("due_on IS NULL, due_on"))
|
|
12
|
+
@overdue = @items.select(&:overdue?)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def complete
|
|
16
|
+
item = ChecklistItem.find(params[:id])
|
|
17
|
+
item.complete!(actor: hr_current_user, note: params[:note])
|
|
18
|
+
redirect_to admin_checklists_path(kind: item.kind), notice: "#{item.title} — done."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reopen
|
|
22
|
+
item = ChecklistItem.find(params[:id])
|
|
23
|
+
item.reopen!(actor: hr_current_user)
|
|
24
|
+
redirect_to admin_checklists_path(kind: item.kind), notice: "#{item.title} — reopened."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def require_checklists! = hr_require_permission!("checklist.manage", scope: :all)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# HR's side: whose documents are missing, and verifying the ones that
|
|
4
|
+
# arrived. Reaching a person's list is `document.view`; opening the file
|
|
5
|
+
# still goes through the row's own visibility.
|
|
6
|
+
class DocumentsController < BaseController
|
|
7
|
+
skip_before_action :require_operations_access!
|
|
8
|
+
before_action :require_document_access!
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@category = params[:category].presence
|
|
12
|
+
scope = hr_scope(Document.includes(:user).recent_first, "document.view")
|
|
13
|
+
scope = scope.where(category: @category) if @category
|
|
14
|
+
@pending = scope.where(verification: "pending").limit(50).to_a
|
|
15
|
+
@documents = paginate(scope)
|
|
16
|
+
@categories = Document.distinct.order(:category).pluck(:category)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show
|
|
20
|
+
@employee = HrLite.user_klass.find(params[:user_id])
|
|
21
|
+
hr_require_reach!("document.view", @employee)
|
|
22
|
+
@documents = Document.for_user(@employee).recent_first
|
|
23
|
+
@missing = Document::SENSITIVE_CATEGORIES - @documents.map(&:category)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def verify
|
|
27
|
+
document = find_manageable
|
|
28
|
+
document.verify!(actor: hr_current_user, note: params[:note])
|
|
29
|
+
redirect_back fallback_location: admin_documents_path,
|
|
30
|
+
notice: "#{document.title} verified."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reject
|
|
34
|
+
document = find_manageable
|
|
35
|
+
note = params[:note].to_s.strip
|
|
36
|
+
if note.blank?
|
|
37
|
+
return redirect_back fallback_location: admin_documents_path,
|
|
38
|
+
alert: "Say what is wrong with it — the employee has to re-upload."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
document.reject!(actor: hr_current_user, note: note)
|
|
42
|
+
redirect_back fallback_location: admin_documents_path, notice: "#{document.title} rejected."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def require_document_access!
|
|
48
|
+
hr_require_permission!("document.view", scope: :team)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# You may verify a document you can OPEN. Attesting to a scan you are
|
|
52
|
+
# not allowed to look at is not verification, and the row's visibility
|
|
53
|
+
# is what keeps an Aadhaar with the money tier rather than with
|
|
54
|
+
# whoever happens to run HR.
|
|
55
|
+
def find_manageable
|
|
56
|
+
document = Document.find(params[:id])
|
|
57
|
+
raise ActiveRecord::RecordNotFound unless document.readable_by?(hr_current_user)
|
|
58
|
+
|
|
59
|
+
document
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -93,6 +93,10 @@ module HrLite
|
|
|
93
93
|
exit_date = params[:date_of_exit].present? ? parse_date_param(params[:date_of_exit]) : Date.current
|
|
94
94
|
|
|
95
95
|
@profile.update!(date_of_exit: exit_date)
|
|
96
|
+
# The last day has a list too — and it is the one that matters most,
|
|
97
|
+
# since an unreturned laptop and un-revoked access both outlive the
|
|
98
|
+
# employment.
|
|
99
|
+
ChecklistItem.open_for!(@profile.user, kind: "offboarding", anchor_date: exit_date)
|
|
96
100
|
begin
|
|
97
101
|
HrLite.config.offboard_user.call(@profile.user)
|
|
98
102
|
rescue => e
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# The other side of the claim: decide it, then say when it was paid.
|
|
4
|
+
class ExpensesController < BaseController
|
|
5
|
+
skip_before_action :require_operations_access!
|
|
6
|
+
before_action :require_deciding!
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@status = params[:status].presence_in(Expense::STATUSES) || "submitted"
|
|
10
|
+
scope = Expense.where(status: @status).includes(:user, :category).recent_first
|
|
11
|
+
@expenses = paginate(hr_scope(scope, "expense.approve"))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def approve
|
|
15
|
+
expense = decidable.find(params[:id])
|
|
16
|
+
expense.approve!(actor: hr_current_user, note: params[:decision_note].presence)
|
|
17
|
+
redirect_to admin_expenses_path, notice: "Claim approved."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def reject
|
|
21
|
+
expense = decidable.find(params[:id])
|
|
22
|
+
note = params[:decision_note].to_s.strip
|
|
23
|
+
return redirect_to admin_expenses_path, alert: "A note is required to reject." if note.blank?
|
|
24
|
+
|
|
25
|
+
expense.reject!(actor: hr_current_user, note: note)
|
|
26
|
+
redirect_to admin_expenses_path, notice: "Claim rejected."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reimburse
|
|
30
|
+
expense = decidable.find(params[:id])
|
|
31
|
+
month = parse_month_param(params[:period_month])
|
|
32
|
+
expense.reimburse!(actor: hr_current_user, period_month: month)
|
|
33
|
+
redirect_to admin_expenses_path, notice: "Marked reimbursed with #{month.strftime('%B %Y')}."
|
|
34
|
+
rescue ActiveRecord::RecordInvalid
|
|
35
|
+
redirect_to admin_expenses_path, alert: "Only an approved claim can be reimbursed."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def require_deciding! = hr_require_permission!("expense.approve", scope: :team)
|
|
41
|
+
|
|
42
|
+
def decidable = hr_scope(Expense.all, "expense.approve")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# The desk itself.
|
|
4
|
+
class HrRequestsController < BaseController
|
|
5
|
+
skip_before_action :require_operations_access!
|
|
6
|
+
before_action :require_desk!
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@status = params[:status].presence_in(HrRequest::STATUSES) || "open"
|
|
10
|
+
@requests = paginate(HrRequest.where(status: @status).includes(:user, :assigned_to)
|
|
11
|
+
.recent_first)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
@request = HrRequest.find(params[:id])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def assign
|
|
19
|
+
request = HrRequest.find(params[:id])
|
|
20
|
+
assignee = HrLite.user_klass.find(params[:assignee_id])
|
|
21
|
+
request.assign!(actor: hr_current_user, assignee: assignee)
|
|
22
|
+
redirect_to admin_hr_request_path(request), notice: "Assigned to #{HrLite.display_name(assignee)}."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolve
|
|
26
|
+
request = HrRequest.find(params[:id])
|
|
27
|
+
request.resolve!(actor: hr_current_user, resolution: params[:resolution].to_s.strip)
|
|
28
|
+
redirect_to admin_hr_requests_path, notice: "Answered."
|
|
29
|
+
rescue ArgumentError
|
|
30
|
+
redirect_to admin_hr_request_path(request), alert: "Write an answer before resolving."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def require_desk! = hr_require_permission!("hr_request.manage", scope: :all)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Loans and salary advances. Money tier: the instalment lands as a
|
|
4
|
+
# deduction on a payslip, so whoever sets it is setting somebody's pay.
|
|
5
|
+
class LoansController < SuperadminController
|
|
6
|
+
def index
|
|
7
|
+
@status = params[:status].presence_in(Loan::STATUSES) || "active"
|
|
8
|
+
@loans = paginate(Loan.includes(:user).where(status: @status).order(starts_on: :desc))
|
|
9
|
+
@outstanding = Loan.active.includes(:loan_repayments).sum(BigDecimal(0), &:outstanding)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def new
|
|
13
|
+
@loan = Loan.new(starts_on: Date.current.next_month.beginning_of_month)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create
|
|
17
|
+
@loan = Loan.new(loan_params.merge(approved_by_id: hr_current_user.id))
|
|
18
|
+
if @loan.save
|
|
19
|
+
redirect_to admin_loans_path,
|
|
20
|
+
notice: "Loan recorded for #{HrLite.display_name(@loan.user)} — " \
|
|
21
|
+
"deductions start #{@loan.starts_on.strftime('%b %Y')}."
|
|
22
|
+
else
|
|
23
|
+
render :new, status: :unprocessable_entity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def show
|
|
28
|
+
@loan = Loan.includes(:loan_repayments).find(params[:id])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Closing stops the deduction. It does NOT delete the repayments —
|
|
32
|
+
# those are on published payslips and are what the outstanding balance
|
|
33
|
+
# is derived from.
|
|
34
|
+
def close
|
|
35
|
+
loan = Loan.find(params[:id])
|
|
36
|
+
loan.update!(status: "closed")
|
|
37
|
+
redirect_to admin_loans_path, notice: "Closed — no further deductions."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cancel
|
|
41
|
+
loan = Loan.find(params[:id])
|
|
42
|
+
if loan.loan_repayments.exists?
|
|
43
|
+
return redirect_to admin_loan_path(loan),
|
|
44
|
+
alert: "Instalments have already been deducted. Close it instead — " \
|
|
45
|
+
"cancelling would deny money that has left somebody's salary."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
loan.update!(status: "cancelled")
|
|
49
|
+
redirect_to admin_loans_path, notice: "Cancelled before any deduction."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def loan_params
|
|
55
|
+
params.require(:loan).permit(:user_id, :principal, :monthly_instalment, :starts_on, :reason)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# HR checking the proof behind a declaration, line by line. The verified
|
|
4
|
+
# amount is what payroll deducts against once this is done, so it is
|
|
5
|
+
# entered per section rather than accepted wholesale.
|
|
6
|
+
class TaxDeclarationsController < BaseController
|
|
7
|
+
skip_before_action :require_operations_access!
|
|
8
|
+
before_action :require_tax_access!
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@status = params[:status].presence_in(TaxDeclaration::STATUSES) || "submitted"
|
|
12
|
+
@declarations = paginate(
|
|
13
|
+
hr_scope(TaxDeclaration.includes(:user, :tax_declaration_items), "tax.view")
|
|
14
|
+
.where(status: @status).order(created_at: :desc)
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
@declaration = find_visible
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def update
|
|
23
|
+
@declaration = find_manageable
|
|
24
|
+
# Recording what the proof supported is a separate act from deciding
|
|
25
|
+
# — an amount can be corrected while the decision is still pending.
|
|
26
|
+
if @declaration.update(verification_params)
|
|
27
|
+
redirect_to admin_tax_declaration_path(@declaration), notice: "Amounts recorded."
|
|
28
|
+
else
|
|
29
|
+
render :show, status: :unprocessable_entity
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def verify
|
|
34
|
+
declaration = find_manageable
|
|
35
|
+
declaration.verify!(actor: hr_current_user, note: params[:note])
|
|
36
|
+
redirect_to admin_tax_declarations_path, notice: "Verified — payroll will use the checked amounts."
|
|
37
|
+
rescue ActiveRecord::RecordInvalid
|
|
38
|
+
redirect_to admin_tax_declaration_path(declaration), alert: "Only a submitted declaration can be verified."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reject
|
|
42
|
+
declaration = find_manageable
|
|
43
|
+
declaration.reject!(actor: hr_current_user, note: params[:note].to_s.strip)
|
|
44
|
+
redirect_to admin_tax_declarations_path, notice: "Sent back to the employee."
|
|
45
|
+
rescue ArgumentError
|
|
46
|
+
redirect_to admin_tax_declaration_path(declaration),
|
|
47
|
+
alert: "Say what is missing — they have to know what to fix."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def require_tax_access!
|
|
53
|
+
hr_require_permission!("tax.view", scope: :all)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def find_visible
|
|
57
|
+
declaration = TaxDeclaration.includes(:tax_declaration_items).find(params[:id])
|
|
58
|
+
hr_require_reach!("tax.view", declaration.user)
|
|
59
|
+
declaration
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def find_manageable
|
|
63
|
+
declaration = TaxDeclaration.find(params[:id])
|
|
64
|
+
hr_require_reach!("tax.manage", declaration.user)
|
|
65
|
+
declaration
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def verification_params
|
|
69
|
+
params.require(:tax_declaration).permit(
|
|
70
|
+
tax_declaration_items_attributes: %i[id verified_amount]
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# An employee's own documents, and the download itself.
|
|
3
|
+
#
|
|
4
|
+
# The FILE is the sensitive part, so every read goes through
|
|
5
|
+
# `Document#readable_by?` rather than through the scope that found the row.
|
|
6
|
+
# A passport is not a payslip: HR can see that one exists without being
|
|
7
|
+
# handed the scan.
|
|
8
|
+
class DocumentsController < ApplicationController
|
|
9
|
+
def index
|
|
10
|
+
@documents = Document.for_user(hr_current_user).recent_first
|
|
11
|
+
@document = Document.new
|
|
12
|
+
@expiring = @documents.select { |d| d.expires_on && d.expires_on <= 60.days.from_now.to_date }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
@document = Document.new(document_params)
|
|
17
|
+
@document.user_id = hr_current_user.id
|
|
18
|
+
@document.uploaded_by_id = hr_current_user.id
|
|
19
|
+
|
|
20
|
+
if @document.save
|
|
21
|
+
redirect_to documents_path, notice: "#{@document.title} uploaded."
|
|
22
|
+
else
|
|
23
|
+
@documents = Document.for_user(hr_current_user).recent_first
|
|
24
|
+
@expiring = []
|
|
25
|
+
render :index, status: :unprocessable_entity
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def destroy
|
|
30
|
+
document = Document.for_user(hr_current_user).find(params[:id])
|
|
31
|
+
# A verified identity document is evidence HR acted on; removing it
|
|
32
|
+
# quietly would leave their verification pointing at nothing.
|
|
33
|
+
if document.verified?
|
|
34
|
+
return redirect_to documents_path, status: :see_other,
|
|
35
|
+
alert: "#{document.title} has been verified — ask HR to replace it."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
document.destroy!
|
|
39
|
+
redirect_to documents_path, notice: "#{document.title} removed.", status: :see_other
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The one place a file leaves the building. Scoped by nothing — the
|
|
43
|
+
# permission check is the row's own, because a document may be readable
|
|
44
|
+
# by its owner, by HR, or by nobody but the money tier.
|
|
45
|
+
def download
|
|
46
|
+
document = Document.find(params[:id])
|
|
47
|
+
unless document.readable_by?(hr_current_user)
|
|
48
|
+
raise ActiveRecord::RecordNotFound
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
AuditLog.record!(
|
|
52
|
+
action: "document.downloaded", subject: document, actor: hr_current_user,
|
|
53
|
+
changes: { "title" => document.title, "category" => document.category,
|
|
54
|
+
"owner" => HrLite.display_name(document.user) }
|
|
55
|
+
)
|
|
56
|
+
# rails_blob_path, not url_for: an engine controller has no default
|
|
57
|
+
# host and url_for(attachment) raises without one.
|
|
58
|
+
redirect_to Rails.application.routes.url_helpers.rails_blob_path(
|
|
59
|
+
document.file, disposition: "attachment", only_path: true
|
|
60
|
+
), allow_other_host: false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def document_params
|
|
66
|
+
params.require(:document).permit(:category, :title, :reference_number, :issued_on, :expires_on, :file)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# What an employee owes and what is coming out of the next payslip. Read
|
|
3
|
+
# only: a loan is agreed with the company, not self-served.
|
|
4
|
+
class LoansController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@loans = Loan.where(user_id: hr_current_user.id).order(starts_on: :desc)
|
|
7
|
+
@outstanding = @loans.sum(BigDecimal(0), &:outstanding)
|
|
8
|
+
@next_month = Date.current.next_month.beginning_of_month
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The employee's own declaration for a financial year. Replaces an admin
|
|
3
|
+
# typing one lump sum into the profile: the whole year's TDS is projected
|
|
4
|
+
# from this, so the person whose money it is fills it in.
|
|
5
|
+
class TaxDeclarationsController < ApplicationController
|
|
6
|
+
def show
|
|
7
|
+
@declaration = current_declaration || build_declaration
|
|
8
|
+
@regime = @declaration.regime
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def update
|
|
12
|
+
# A bare record, not the form's scaffold: `build_declaration` fills in
|
|
13
|
+
# one blank line per section so the form is a list rather than an empty
|
|
14
|
+
# box, and saving those blanks fails a NOT NULL amount.
|
|
15
|
+
@declaration = current_declaration ||
|
|
16
|
+
TaxDeclaration.new(user_id: hr_current_user.id,
|
|
17
|
+
financial_year: financial_year, regime: employee_regime)
|
|
18
|
+
# A verified declaration is what payroll is deducting against; changing
|
|
19
|
+
# it would silently re-open a decision HR has already made.
|
|
20
|
+
if @declaration.verified?
|
|
21
|
+
return redirect_to tax_declaration_path,
|
|
22
|
+
alert: "This year's declaration is verified. Ask HR to reopen it."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if @declaration.update(declaration_params)
|
|
26
|
+
redirect_to tax_declaration_path, notice: "Saved."
|
|
27
|
+
else
|
|
28
|
+
# Re-render needs the full set of lines back, or the form loses the
|
|
29
|
+
# sections the person had not filled in yet.
|
|
30
|
+
fill_missing_sections(@declaration)
|
|
31
|
+
render :show, status: :unprocessable_entity
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def submit
|
|
36
|
+
@declaration = current_declaration
|
|
37
|
+
return redirect_to tax_declaration_path, alert: "Nothing to submit yet." if @declaration.nil?
|
|
38
|
+
|
|
39
|
+
@declaration.submit!(actor: hr_current_user)
|
|
40
|
+
redirect_to tax_declaration_path, notice: "Submitted — HR will check the proof."
|
|
41
|
+
rescue ActiveRecord::RecordInvalid
|
|
42
|
+
redirect_to tax_declaration_path, alert: "Only a draft can be submitted."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def financial_year
|
|
48
|
+
FinancialYear.start_for(params[:financial_year].present? ? Date.parse(params[:financial_year]) : Date.current)
|
|
49
|
+
rescue Date::Error
|
|
50
|
+
FinancialYear.start_for(Date.current)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def current_declaration
|
|
54
|
+
TaxDeclaration.find_by(user_id: hr_current_user.id, financial_year: financial_year)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_declaration
|
|
58
|
+
declaration = TaxDeclaration.new(user_id: hr_current_user.id, financial_year: financial_year,
|
|
59
|
+
regime: employee_regime)
|
|
60
|
+
# One blank line per section, so the form is a list of the things a
|
|
61
|
+
# person can actually claim rather than an empty box and a plus button.
|
|
62
|
+
fill_missing_sections(declaration)
|
|
63
|
+
declaration
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Every section gets a line, whether or not it has been claimed yet.
|
|
67
|
+
def fill_missing_sections(declaration)
|
|
68
|
+
present = declaration.tax_declaration_items.map(&:section)
|
|
69
|
+
(TaxDeclarationItem::SECTIONS - present).each do |section|
|
|
70
|
+
declaration.tax_declaration_items.build(section: section)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def employee_regime
|
|
75
|
+
EmployeeProfile.find_by(user_id: hr_current_user.id)&.tax_regime.presence || "new"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The form posts a line for every section, because that is what a list of
|
|
79
|
+
# claimable things looks like. Most of them are blank — a section with no
|
|
80
|
+
# amount is not a claim, and saving it violates a NOT NULL amount.
|
|
81
|
+
# An EXISTING line blanked out is a claim being withdrawn, so it is
|
|
82
|
+
# destroyed rather than skipped.
|
|
83
|
+
def declaration_params
|
|
84
|
+
permitted = params.require(:tax_declaration).permit(
|
|
85
|
+
:regime,
|
|
86
|
+
tax_declaration_items_attributes: %i[id section label declared_amount _destroy]
|
|
87
|
+
)
|
|
88
|
+
items = permitted[:tax_declaration_items_attributes]
|
|
89
|
+
return permitted if items.blank?
|
|
90
|
+
|
|
91
|
+
permitted[:tax_declaration_items_attributes] = items.to_h.filter_map { |key, row|
|
|
92
|
+
next [ key, row.merge("_destroy" => "1") ] if row["declared_amount"].blank? && row["id"].present?
|
|
93
|
+
next if row["declared_amount"].blank?
|
|
94
|
+
|
|
95
|
+
[ key, row ]
|
|
96
|
+
}.to_h
|
|
97
|
+
permitted
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|