hr_lite 0.14.0 → 0.16.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 +102 -1
- data/README.md +4 -1
- data/app/assets/javascripts/hr_lite/print.js +5 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +72 -0
- data/app/controllers/hr_lite/admin/documents_controller.rb +63 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/holidays_controller.rb +7 -0
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +23 -0
- data/app/controllers/hr_lite/admin/loans_controller.rb +59 -0
- data/app/controllers/hr_lite/admin/policies_controller.rb +29 -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/hr_requests_controller.rb +2 -0
- data/app/controllers/hr_lite/id_cards_controller.rb +25 -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 +18 -3
- data/app/models/hr_lite/approval_flow.rb +1 -1
- data/app/models/hr_lite/employee_profile.rb +21 -3
- data/app/models/hr_lite/hr_request.rb +12 -3
- data/app/models/hr_lite/leave_request.rb +15 -4
- data/app/models/hr_lite/policy.rb +15 -0
- data/app/models/hr_lite/setting.rb +1 -1
- data/app/models/hr_lite/tax_declaration.rb +4 -1
- 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/employees/_form.html.erb +5 -0
- data/app/views/hr_lite/admin/employees/show.html.erb +1 -0
- data/app/views/hr_lite/admin/hr_requests/show.html.erb +3 -0
- data/app/views/hr_lite/admin/leave_requests/index.html.erb +3 -0
- data/app/views/hr_lite/admin/leave_requests/new.html.erb +35 -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/policies/index.html.erb +27 -0
- data/app/views/hr_lite/admin/policies/new.html.erb +22 -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/id_cards/show.html.erb +86 -0
- data/app/views/hr_lite/loans/index.html.erb +52 -0
- data/app/views/hr_lite/shared/_bell.html.erb +3 -0
- data/app/views/hr_lite/tax_declarations/show.html.erb +68 -0
- data/app/views/layouts/hr_lite/application.html.erb +2 -0
- data/config/routes.rb +21 -1
- data/db/migrate/20260927100145_add_id_card_fields_to_hr_lite_employee_profiles.rb +6 -0
- data/db/migrate/20260927101031_add_created_by_id_to_hr_lite_leave_requests.rb +5 -0
- data/lib/hr_lite/configuration.rb +4 -1
- data/lib/hr_lite/notifications.rb +4 -3
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +1 -0
- metadata +41 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14d37a1d62a035315390be2c619a74722c58e6b53b747422aa4ab48458287102
|
|
4
|
+
data.tar.gz: fbbb6c7efb893ff38d6992971844fd721deca4819943f0e0dd8f1cd343f4b4fc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 70b42e3a35bd683ab62ecda90d33009511faeda8595cbc9ec9fc9f1ae2388f61592059a4ea1be151ade6ec3062f4ecea2726098f2f58adc55048f8e108de7460
|
|
7
|
+
data.tar.gz: a19199e7cd8a173713e97d77266d4779951ef47d70171c234ee65d2afec5cc35be8670542db4e49ad2e656efe6031b8aba053cc281f687fd08dd6b20e3d83511
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,106 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.16.0] - 2026-09-27
|
|
11
|
+
|
|
12
|
+
Employee ID cards, print requests through Ask HR, recording leave for
|
|
13
|
+
somebody, and notices that reach the right people. Two migrations, both
|
|
14
|
+
additive (`blood_group`/`emergency_contact` on profiles, `created_by_id` on
|
|
15
|
+
leave requests). New runtime dependency: `rqrcode`.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Employee ID card.** `/id_card` shows the card front and back; `/id_card/:user_id`
|
|
20
|
+
lets anyone whose `profile.view` reaches that person open it to print. Print
|
|
21
|
+
gives one CR80 (54 × 85.6 mm) face per page. The QR holds the company brand,
|
|
22
|
+
address and phone as plain text — no URL, nothing to host.
|
|
23
|
+
- **ID photo.** The one field an employee edits on their own profile. JPG, PNG
|
|
24
|
+
or WebP under 5 MB, content-sniffed; every change is audited.
|
|
25
|
+
- Blood group and emergency contact on the leadership employee form, encrypted
|
|
26
|
+
like the other personal fields so the audit email reads `[changed]`.
|
|
27
|
+
- `config.company` may return `brand:` and `phone:`.
|
|
28
|
+
- **Request a printed ID card.** One tap on the card raises an Ask-HR request
|
|
29
|
+
in the new `id_card` category. It needs a photo first, and only one can be
|
|
30
|
+
open at a time. The desk opens the card from the request to print it;
|
|
31
|
+
answering the request stamps "Issued" on the card.
|
|
32
|
+
- **Record leave for somebody.** Approvals → Record leave, for leave taken but
|
|
33
|
+
never applied for. It is saved approved by whoever records it, keeps the
|
|
34
|
+
balance check, stores `created_by_id`, and emails the employee — not the
|
|
35
|
+
approvers. The employee is picked by typing a name or code.
|
|
36
|
+
- A new hire gets the Employee role with their profile, so Ask HR and
|
|
37
|
+
Policies work from day one.
|
|
38
|
+
- **Publish policies and announcements.** Leadership (`policy.manage`) writes
|
|
39
|
+
one at `/admin/policies`; every current employee gets an email and a bell.
|
|
40
|
+
A policy asks to be acknowledged, an announcement does not, and reusing a
|
|
41
|
+
title publishes the next version. The list shows who still has to
|
|
42
|
+
acknowledge.
|
|
43
|
+
- **Holiday notices.** Adding a holiday tells everyone; a pasted list sends one
|
|
44
|
+
notice, not one per line (new matrix row `holiday.published`).
|
|
45
|
+
- `config.notifications = ->(user) { { url:, unread: } }` puts a notifications
|
|
46
|
+
link with the unread count in the HR shell. Off by default.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- Notification defaults: a leave application and an Ask-HR request now EMAIL
|
|
51
|
+
every approver / desk holder as well as belling them; the team "on leave"
|
|
52
|
+
notice is a bell only, and is not sent at all for leave already over.
|
|
53
|
+
- Overlap and attendance errors on a leave request no longer say "you" — HR
|
|
54
|
+
sees them too now.
|
|
55
|
+
- Employee codes are zero-padded to six digits, and the prefix may end in one
|
|
56
|
+
hyphen — `ESA-` gives `ESA-000001`. Existing codes are left as they are.
|
|
57
|
+
|
|
58
|
+
## [0.15.0] - 2026-08-18
|
|
59
|
+
|
|
60
|
+
The screens for three features that shipped as tables nobody could reach. No
|
|
61
|
+
migration — the data layer was already there.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **Documents, tax declarations and loans had no UI at all.** 0.13.0 and
|
|
66
|
+
0.14.0 shipped their models, tables, payroll integration and an expiry job,
|
|
67
|
+
and every one of them passed its specs — but there was no controller, no
|
|
68
|
+
route and no view. An employee could not upload a passport, HR could not
|
|
69
|
+
verify a PAN, and nobody could file an 80C declaration. Loans at least
|
|
70
|
+
reached a payslip; the other two were unreachable rows.
|
|
71
|
+
- **A tax declaration 500ed when a real browser saved it.** The form posts a
|
|
72
|
+
line for every section, because that is what a list of claimable things
|
|
73
|
+
looks like, and the blank ones violated a NOT NULL amount. The specs had
|
|
74
|
+
only ever posted the lines they filled in. Blank sections are now dropped,
|
|
75
|
+
and clearing an existing amount withdraws that claim.
|
|
76
|
+
- `TaxDeclaration` and `ApprovalFlow` were missing `inverse_of` on their
|
|
77
|
+
nested associations. The foreign key is not derived from the class name, so
|
|
78
|
+
a nested item on an unsaved parent could not see it — every first save
|
|
79
|
+
failed with "declaration must exist".
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
|
|
83
|
+
- **Documents.** Employees upload, see what is expiring and remove anything
|
|
84
|
+
not yet verified; HR sees what is waiting, whose file is missing which
|
|
85
|
+
category, and verifies or sends one back with a reason. Downloads go
|
|
86
|
+
through the row's own `readable_by?` and every one is audited — a file
|
|
87
|
+
leaving the building is the event worth recording.
|
|
88
|
+
- **Tax declarations.** A section-by-section claim the employee files
|
|
89
|
+
themselves, replacing an admin typing one lump sum into the profile. HR
|
|
90
|
+
records what the proof actually supported, which is often less than the
|
|
91
|
+
claim. Until it is verified payroll deducts against the CLAIM: making
|
|
92
|
+
somebody overpay tax all year because paperwork is slow is its own kind of
|
|
93
|
+
wrong.
|
|
94
|
+
- **Loans.** Employees see what they owe and what comes out of the next
|
|
95
|
+
payslip; the money tier records a loan, closes it, or cancels one that has
|
|
96
|
+
not been deducted from yet. Cancelling after an instalment has been taken
|
|
97
|
+
is refused — that money has already left somebody's salary.
|
|
98
|
+
- Nav entries for all three, employee and admin.
|
|
99
|
+
|
|
100
|
+
### Changed
|
|
101
|
+
|
|
102
|
+
- Verifying a document requires being able to OPEN it. Attesting to a scan
|
|
103
|
+
you are not allowed to look at is not verification, and it keeps an
|
|
104
|
+
Aadhaar with the money tier rather than with whoever happens to run HR.
|
|
105
|
+
- The spec suite deletes its sqlite file before every run. The role and
|
|
106
|
+
rate-card migrations SEED rows, and seeds never overwrite what exists —
|
|
107
|
+
right in production, wrong in a test database, where a leftover file kept
|
|
108
|
+
yesterday's definitions and a change to `RoleSeeds` silently did not apply.
|
|
109
|
+
|
|
10
110
|
## [0.14.0] - 2026-08-18
|
|
11
111
|
|
|
12
112
|
The admin half, plus the two ends of employment that were still manual.
|
|
@@ -793,7 +893,8 @@ Initial release.
|
|
|
793
893
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
794
894
|
daily leadership digest and an append-only audit trail.
|
|
795
895
|
|
|
796
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
896
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.15.0...HEAD
|
|
897
|
+
[0.15.0]: https://github.com/kshtzkr/hr_lite/compare/v0.14.0...v0.15.0
|
|
797
898
|
[0.14.0]: https://github.com/kshtzkr/hr_lite/compare/v0.13.0...v0.14.0
|
|
798
899
|
[0.13.0]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...v0.13.0
|
|
799
900
|
[0.12.0]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...v0.12.0
|
data/README.md
CHANGED
|
@@ -131,7 +131,10 @@ HrLite.configure do |c|
|
|
|
131
131
|
|
|
132
132
|
c.mailer_from = "hr@example.com"
|
|
133
133
|
c.public_url_base = "https://hr.example.com" # enables deep links in emails
|
|
134
|
-
|
|
134
|
+
# brand + phone are optional; the ID card prints brand, and its QR holds
|
|
135
|
+
# brand, address and phone as plain text.
|
|
136
|
+
c.company = -> { { name: "Acme", brand: "acme.in", address: "Head office address",
|
|
137
|
+
phone: "+91 00000 00000", logo_url: nil } }
|
|
135
138
|
|
|
136
139
|
# Salary-slip PDFs: plug your renderer, or add wicked_pdf to the bundle and
|
|
137
140
|
# the built-in renderer takes over. With neither, PDF export is disabled.
|
|
@@ -324,3 +324,75 @@
|
|
|
324
324
|
.hrl-prose { max-width: 68ch; }
|
|
325
325
|
.hrl-prose p { margin: 0 0 .8rem; line-height: 1.6; }
|
|
326
326
|
.hrl-prose p:last-child { margin-bottom: 0; }
|
|
327
|
+
|
|
328
|
+
/* ID card. Every length is N × --u, where N is the design's pixel value at
|
|
329
|
+
360px wide — so one variable fits a phone, the desktop, and a CR80 print. */
|
|
330
|
+
.hrl-idcard-faces { container-type: inline-size; display: flex; flex-wrap: wrap; gap: 3.5rem; justify-content: center; }
|
|
331
|
+
.hrl-idcard-face { display: flex; flex-direction: column; align-items: center; gap: .85rem; }
|
|
332
|
+
.hrl-idcard__label { font: 600 11px/1 var(--hrl-card-body, var(--hrl-font)); letter-spacing: .14em; color: #64748B; }
|
|
333
|
+
.hrl-idcard {
|
|
334
|
+
--u: min(1px, 100cqi / 360);
|
|
335
|
+
--blue: #0057A8; --green: #00A24F; --red: #ED1C24;
|
|
336
|
+
width: calc(360 * var(--u)); height: calc(570 * var(--u));
|
|
337
|
+
background: #fff; border: calc(1.4 * var(--u)) solid #D9D9D9; border-radius: calc(22 * var(--u));
|
|
338
|
+
box-shadow: 0 6px 18px rgba(15, 23, 42, .08), 0 24px 48px rgba(15, 23, 42, .08);
|
|
339
|
+
overflow: hidden; position: relative; display: flex; flex-direction: column;
|
|
340
|
+
font-family: var(--hrl-card-body, var(--hrl-font)); color: #0F172A; print-color-adjust: exact; -webkit-print-color-adjust: exact;
|
|
341
|
+
}
|
|
342
|
+
.hrl-idcard--back { align-items: center; }
|
|
343
|
+
.hrl-idcard__band { height: calc(176 * var(--u)); background: var(--blue); display: flex; flex-direction: column; align-items: center; padding-top: calc(18 * var(--u)); }
|
|
344
|
+
.hrl-idcard__slot { width: calc(64 * var(--u)); height: calc(10 * var(--u)); border-radius: 9999px; background: #F9F9F9; box-shadow: inset 0 1px 3px rgba(0, 0, 0, .25); }
|
|
345
|
+
.hrl-idcard__slot--back { margin-top: calc(18 * var(--u)); background: #F1F5F9; box-shadow: inset 0 1px 3px rgba(0, 0, 0, .2); }
|
|
346
|
+
.hrl-idcard__logo { margin-top: calc(18 * var(--u)); background: #fff; border-radius: 9999px; padding: calc(8 * var(--u)) calc(18 * var(--u)); display: flex; align-items: center; font: 700 calc(13 * var(--u)) var(--hrl-card-display, var(--hrl-font)); color: var(--blue); }
|
|
347
|
+
.hrl-idcard__logo img { height: calc(26 * var(--u)); display: block; }
|
|
348
|
+
.hrl-idcard__kicker { margin-top: calc(12 * var(--u)); font: 600 calc(10 * var(--u))/1 var(--hrl-card-body, var(--hrl-font)); letter-spacing: .2em; color: rgba(255, 255, 255, .85); }
|
|
349
|
+
.hrl-idcard__photo {
|
|
350
|
+
position: absolute; top: calc(126 * var(--u)); left: 50%; transform: translateX(-50%);
|
|
351
|
+
width: calc(140 * var(--u)); height: calc(164 * var(--u)); border-radius: calc(26 * var(--u));
|
|
352
|
+
border: calc(5 * var(--u)) solid #fff; box-shadow: 0 6px 18px rgba(15, 23, 42, .15); background: #F8FAFC; overflow: hidden;
|
|
353
|
+
display: flex; align-items: center; justify-content: center; font: 700 calc(34 * var(--u)) var(--hrl-card-display, var(--hrl-font)); color: #64748B;
|
|
354
|
+
}
|
|
355
|
+
.hrl-idcard__photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
356
|
+
.hrl-idcard__who { margin-top: calc(124 * var(--u)); text-align: center; padding: 0 calc(24 * var(--u)); }
|
|
357
|
+
.hrl-idcard__name { font: 800 calc(25 * var(--u))/1.1 var(--hrl-card-display, var(--hrl-font)); letter-spacing: -.02em; text-wrap: balance; }
|
|
358
|
+
.hrl-idcard__role { margin-top: calc(6 * var(--u)); font: 500 calc(13 * var(--u))/1.3 var(--hrl-card-body, var(--hrl-font)); color: #334155; }
|
|
359
|
+
.hrl-idcard__green { color: var(--green); }
|
|
360
|
+
.hrl-idcard__blue { color: var(--blue); }
|
|
361
|
+
.hrl-idcard__red { color: var(--red) !important; }
|
|
362
|
+
.hrl-idcard__facts {
|
|
363
|
+
margin: calc(18 * var(--u)) calc(24 * var(--u)) 0; display: grid; grid-template-columns: 1fr 1fr; gap: calc(12 * var(--u)) calc(16 * var(--u));
|
|
364
|
+
padding: calc(14 * var(--u)) calc(16 * var(--u)); background: #F8FAFC; border-radius: calc(16 * var(--u)); border: 1px solid #E2E8F0;
|
|
365
|
+
}
|
|
366
|
+
.hrl-idcard__facts dt { font: 600 calc(9 * var(--u))/1 var(--hrl-card-body, var(--hrl-font)); letter-spacing: .14em; color: #64748B; }
|
|
367
|
+
.hrl-idcard__facts dd { margin: calc(5 * var(--u)) 0 0; font: 600 calc(13 * var(--u))/1.2 var(--hrl-card-body, var(--hrl-font)); color: #0F172A; }
|
|
368
|
+
.hrl-idcard__strip { margin-top: auto; height: calc(10 * var(--u)); display: grid; grid-template-columns: 2fr 1fr 1fr; }
|
|
369
|
+
.hrl-idcard__strip span:nth-child(1) { background: var(--blue); }
|
|
370
|
+
.hrl-idcard__strip span:nth-child(2) { background: var(--green); }
|
|
371
|
+
.hrl-idcard__strip span:nth-child(3) { background: var(--red); }
|
|
372
|
+
.hrl-idcard__title { margin-top: calc(26 * var(--u)); font: 800 calc(18 * var(--u))/1.1 var(--hrl-card-display, var(--hrl-font)); letter-spacing: -.01em; }
|
|
373
|
+
.hrl-idcard__qr { margin-top: calc(16 * var(--u)); padding: calc(14 * var(--u)); border-radius: calc(24 * var(--u)); border: calc(1.4 * var(--u)) solid #D9D9D9; box-shadow: 0 6px 18px rgba(15, 23, 42, .08); }
|
|
374
|
+
.hrl-idcard__qr svg { width: calc(176 * var(--u)); height: calc(176 * var(--u)); display: block; }
|
|
375
|
+
.hrl-idcard__code { margin-top: calc(12 * var(--u)); font: 600 calc(12 * var(--u))/1 var(--hrl-card-body, var(--hrl-font)); letter-spacing: .16em; }
|
|
376
|
+
.hrl-idcard__rows { margin: calc(22 * var(--u)) calc(28 * var(--u)) 0; align-self: stretch; display: flex; flex-direction: column; gap: calc(10 * var(--u)); }
|
|
377
|
+
.hrl-idcard__rows div { display: flex; justify-content: space-between; gap: calc(12 * var(--u)); font: 400 calc(12 * var(--u))/1.4 var(--hrl-card-body, var(--hrl-font)); color: #475569; }
|
|
378
|
+
.hrl-idcard__rows b { font-weight: 600; color: #0F172A; text-align: right; }
|
|
379
|
+
.hrl-idcard__rows hr { margin: 0; border: 0; height: 1px; background: #E2E8F0; }
|
|
380
|
+
.hrl-idcard__note { margin: calc(20 * var(--u)) calc(28 * var(--u)) 0; text-align: center; font: 400 calc(11 * var(--u))/1.5 var(--hrl-card-body, var(--hrl-font)); color: #64748B; text-wrap: pretty; }
|
|
381
|
+
.hrl-idcard__foot { margin-top: auto; align-self: stretch; background: var(--blue); padding: calc(14 * var(--u)) calc(24 * var(--u)); display: flex; justify-content: space-between; align-items: center; }
|
|
382
|
+
.hrl-idcard__foot b { font: 700 calc(13 * var(--u))/1 var(--hrl-card-display, var(--hrl-font)); color: #fff; }
|
|
383
|
+
.hrl-idcard__foot span { font: 500 calc(10 * var(--u))/1 var(--hrl-card-body, var(--hrl-font)); letter-spacing: .14em; color: rgba(255, 255, 255, .85); }
|
|
384
|
+
.hrl-idcard-actions { display: flex; flex-wrap: wrap; gap: .85rem; align-items: flex-start; justify-content: center; margin-top: 1.5rem; }
|
|
385
|
+
|
|
386
|
+
/* Print: one CR80 face per page, nothing else. */
|
|
387
|
+
@media print {
|
|
388
|
+
@page { size: 54mm 85.6mm; margin: 0; }
|
|
389
|
+
.hrl-side, .hrl-appbar, .hrl-tabbar, .hrl-flash, .hrl-page__head, .hrl-idcard__label, .hrl-idcard-actions { display: none !important; }
|
|
390
|
+
.hrl-body { background: #fff; }
|
|
391
|
+
.hrl-main { padding: 0; margin: 0; }
|
|
392
|
+
.hrl-idcard-faces { display: block; }
|
|
393
|
+
.hrl-idcard { --u: calc(54mm / 360); box-shadow: none; break-after: page; }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* Notifications link, shown only when the host configures one. */
|
|
397
|
+
.hrl-bell { display: inline-flex; align-items: center; gap: .4rem; min-height: 44px; font-size: .86rem; font-weight: 600; }
|
|
398
|
+
.hrl-bell__count { min-width: 1.4rem; padding: 0 .4rem; border-radius: 9999px; background: var(--hrl-accent); color: var(--hrl-accent-ink); font-size: .75rem; text-align: center; }
|
|
@@ -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
|
|
@@ -115,7 +115,7 @@ module HrLite
|
|
|
115
115
|
:user_id, :manager_id, :designation, :date_of_birth, :date_of_joining,
|
|
116
116
|
:date_of_exit, :department, :work_location, :pan_number, :pf_uan, :esi_number,
|
|
117
117
|
:bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions,
|
|
118
|
-
:fy_opening_gross, :fy_opening_tds
|
|
118
|
+
:fy_opening_gross, :fy_opening_tds, :blood_group, :emergency_contact
|
|
119
119
|
)
|
|
120
120
|
end
|
|
121
121
|
|
|
@@ -11,6 +11,7 @@ module HrLite
|
|
|
11
11
|
def create
|
|
12
12
|
@holiday = Holiday.new(holiday_params)
|
|
13
13
|
if @holiday.save
|
|
14
|
+
announce("Holiday added: #{@holiday.name} on #{@holiday.date.strftime('%a %d %b %Y')}")
|
|
14
15
|
redirect_to admin_holidays_path(year: @holiday.date.year), notice: "Holiday added."
|
|
15
16
|
else
|
|
16
17
|
redirect_to admin_holidays_path, alert: @holiday.errors.full_messages.to_sentence
|
|
@@ -55,6 +56,7 @@ module HrLite
|
|
|
55
56
|
created += 1
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
announce("#{created} holiday#{'s' unless created == 1} added to the calendar") if created.positive?
|
|
58
60
|
message = "#{created} holiday#{'s' unless created == 1} added."
|
|
59
61
|
message += " Problems: #{problems.join('; ')}" if problems.any?
|
|
60
62
|
redirect_to admin_holidays_path, problems.any? ? { alert: message } : { notice: message }
|
|
@@ -62,6 +64,11 @@ module HrLite
|
|
|
62
64
|
|
|
63
65
|
private
|
|
64
66
|
|
|
67
|
+
def announce(title)
|
|
68
|
+
staff = HrLite.active_employees
|
|
69
|
+
Notifications.publish("holiday.published", title: title, path: "/holidays", bell_to: staff, email_to: staff)
|
|
70
|
+
end
|
|
71
|
+
|
|
65
72
|
def holiday_params
|
|
66
73
|
params.require(:holiday).permit(:date, :name, :optional)
|
|
67
74
|
end
|
|
@@ -16,6 +16,27 @@ module HrLite
|
|
|
16
16
|
@balance = @request.balance
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# HR records leave somebody forgot to apply for. Approved on save, by the
|
|
20
|
+
# person recording it; the balance check still applies.
|
|
21
|
+
def new
|
|
22
|
+
@request = LeaveRequest.new(start_date: Date.current, end_date: Date.current)
|
|
23
|
+
@people = recordable
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create
|
|
27
|
+
profile = recordable.find_by(employee_code: params[:employee_code].to_s.strip)
|
|
28
|
+
@request = LeaveRequest.new(params.require(:leave_request).permit(:leave_type_id, :start_date, :end_date, :half_day, :reason)
|
|
29
|
+
.merge(user_id: profile&.user_id, created_by_id: hr_current_user.id))
|
|
30
|
+
recorded = profile && LeaveRequest.transaction do
|
|
31
|
+
(@request.save && @request.approve!(actor: hr_current_user, note: "Recorded by HR")) || raise(ActiveRecord::Rollback)
|
|
32
|
+
end
|
|
33
|
+
return redirect_to(admin_leave_request_path(@request), notice: "Leave recorded and approved.") if recorded
|
|
34
|
+
|
|
35
|
+
@request.errors.add(:base, profile ? "Not enough balance — adjust it first or use unpaid leave" : "Pick an employee from the list") if @request.errors.empty?
|
|
36
|
+
@people = recordable
|
|
37
|
+
render :new, status: :unprocessable_entity
|
|
38
|
+
end
|
|
39
|
+
|
|
19
40
|
def approve
|
|
20
41
|
request = find_decidable
|
|
21
42
|
if request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
|
|
@@ -68,6 +89,8 @@ module HrLite
|
|
|
68
89
|
end
|
|
69
90
|
|
|
70
91
|
def find_decidable = decidable.find(params[:id])
|
|
92
|
+
|
|
93
|
+
def recordable = hr_scope(EmployeeProfile.includes(:user), "leave.approve")
|
|
71
94
|
end
|
|
72
95
|
end
|
|
73
96
|
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,29 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Write a policy or an announcement and send it to everyone. A policy asks
|
|
4
|
+
# to be acknowledged; an announcement does not. Same title again = the
|
|
5
|
+
# next version.
|
|
6
|
+
class PoliciesController < ApplicationController
|
|
7
|
+
before_action { hr_require_permission!("policy.manage", scope: :all) }
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
@policies = Policy.newest_first
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def new
|
|
14
|
+
@policy = Policy.new(effective_from: Date.current, acknowledgement_required: true)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create
|
|
18
|
+
@policy = Policy.new(params.require(:policy).permit(:title, :body, :effective_from, :acknowledgement_required)
|
|
19
|
+
.merge(published: true))
|
|
20
|
+
@policy.version = (Policy.where(title: @policy.title).maximum(:version) || 0) + 1
|
|
21
|
+
if @policy.save
|
|
22
|
+
redirect_to admin_policies_path, notice: "Published — everyone has been told."
|
|
23
|
+
else
|
|
24
|
+
render :new, status: :unprocessable_entity
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
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
|
|
@@ -19,6 +19,8 @@ module HrLite
|
|
|
19
19
|
@request = HrRequest.new(request_params.merge(user_id: hr_current_user.id))
|
|
20
20
|
if @request.save
|
|
21
21
|
redirect_to hr_requests_path, notice: "Sent to HR."
|
|
22
|
+
elsif @request.category == "id_card"
|
|
23
|
+
redirect_to id_card_path, alert: @request.errors.full_messages.to_sentence
|
|
22
24
|
else
|
|
23
25
|
render :new, status: :unprocessable_entity
|
|
24
26
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The employee ID card: your own, or — for HR printing it — anyone your
|
|
3
|
+
# `profile.view` reaches. The photo is the one thing an employee edits.
|
|
4
|
+
class IdCardsController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
@user = params[:user_id] ? HrLite.user_klass.find(params[:user_id]) : hr_current_user
|
|
7
|
+
hr_require_reach!("profile.view", @user) unless @user == hr_current_user
|
|
8
|
+
@profile = EmployeeProfile.find_by(user_id: @user.id)
|
|
9
|
+
card_requests = HrRequest.where(user_id: @user.id, category: "id_card")
|
|
10
|
+
@issued_on = card_requests.where(status: "resolved").maximum(:resolved_at)
|
|
11
|
+
@print_requested = card_requests.open_requests.pick(:created_at)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def photo
|
|
15
|
+
profile = EmployeeProfile.find_by!(user_id: hr_current_user.id)
|
|
16
|
+
profile.photo = params.require(:photo)
|
|
17
|
+
if profile.save
|
|
18
|
+
AuditLog.record!(action: "id_card.photo_changed", subject: profile, actor: hr_current_user)
|
|
19
|
+
redirect_to id_card_path, notice: "Photo updated.", status: :see_other
|
|
20
|
+
else
|
|
21
|
+
redirect_to id_card_path, alert: profile.errors.full_messages.to_sentence, status: :see_other
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
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
|