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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -1
  3. data/README.md +4 -1
  4. data/app/assets/javascripts/hr_lite/print.js +5 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +72 -0
  6. data/app/controllers/hr_lite/admin/documents_controller.rb +63 -0
  7. data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
  8. data/app/controllers/hr_lite/admin/holidays_controller.rb +7 -0
  9. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +23 -0
  10. data/app/controllers/hr_lite/admin/loans_controller.rb +59 -0
  11. data/app/controllers/hr_lite/admin/policies_controller.rb +29 -0
  12. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +75 -0
  13. data/app/controllers/hr_lite/documents_controller.rb +69 -0
  14. data/app/controllers/hr_lite/hr_requests_controller.rb +2 -0
  15. data/app/controllers/hr_lite/id_cards_controller.rb +25 -0
  16. data/app/controllers/hr_lite/loans_controller.rb +11 -0
  17. data/app/controllers/hr_lite/tax_declarations_controller.rb +100 -0
  18. data/app/helpers/hr_lite/application_helper.rb +18 -3
  19. data/app/models/hr_lite/approval_flow.rb +1 -1
  20. data/app/models/hr_lite/employee_profile.rb +21 -3
  21. data/app/models/hr_lite/hr_request.rb +12 -3
  22. data/app/models/hr_lite/leave_request.rb +15 -4
  23. data/app/models/hr_lite/policy.rb +15 -0
  24. data/app/models/hr_lite/setting.rb +1 -1
  25. data/app/models/hr_lite/tax_declaration.rb +4 -1
  26. data/app/views/hr_lite/admin/documents/index.html.erb +89 -0
  27. data/app/views/hr_lite/admin/documents/show.html.erb +42 -0
  28. data/app/views/hr_lite/admin/employees/_form.html.erb +5 -0
  29. data/app/views/hr_lite/admin/employees/show.html.erb +1 -0
  30. data/app/views/hr_lite/admin/hr_requests/show.html.erb +3 -0
  31. data/app/views/hr_lite/admin/leave_requests/index.html.erb +3 -0
  32. data/app/views/hr_lite/admin/leave_requests/new.html.erb +35 -0
  33. data/app/views/hr_lite/admin/loans/index.html.erb +43 -0
  34. data/app/views/hr_lite/admin/loans/new.html.erb +39 -0
  35. data/app/views/hr_lite/admin/loans/show.html.erb +52 -0
  36. data/app/views/hr_lite/admin/policies/index.html.erb +27 -0
  37. data/app/views/hr_lite/admin/policies/new.html.erb +22 -0
  38. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +37 -0
  39. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +54 -0
  40. data/app/views/hr_lite/documents/index.html.erb +84 -0
  41. data/app/views/hr_lite/id_cards/show.html.erb +86 -0
  42. data/app/views/hr_lite/loans/index.html.erb +52 -0
  43. data/app/views/hr_lite/shared/_bell.html.erb +3 -0
  44. data/app/views/hr_lite/tax_declarations/show.html.erb +68 -0
  45. data/app/views/layouts/hr_lite/application.html.erb +2 -0
  46. data/config/routes.rb +21 -1
  47. data/db/migrate/20260927100145_add_id_card_fields_to_hr_lite_employee_profiles.rb +6 -0
  48. data/db/migrate/20260927101031_add_created_by_id_to_hr_lite_leave_requests.rb +5 -0
  49. data/lib/hr_lite/configuration.rb +4 -1
  50. data/lib/hr_lite/notifications.rb +4 -3
  51. data/lib/hr_lite/version.rb +1 -1
  52. data/lib/hr_lite.rb +1 -0
  53. metadata +41 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '04966d2c7a57a06e49c64c6a375b148c497f83340a79445eb0f243dd783246f0'
4
- data.tar.gz: 1112874bf22548910a651e53123056fd1341e045598060660c12f93b284789bb
3
+ metadata.gz: 14d37a1d62a035315390be2c619a74722c58e6b53b747422aa4ab48458287102
4
+ data.tar.gz: fbbb6c7efb893ff38d6992971844fd721deca4819943f0e0dd8f1cd343f4b4fc
5
5
  SHA512:
6
- metadata.gz: '00219df8c7244175712e7a87768f50301fa2f8b0110ae201f343409064bdb1083cce3eef587ad046cf7e1831a1981eb7d3c6e816e2a9d431cbbb86aaf5ef8c89'
7
- data.tar.gz: 53af8da6feddf2be67301f9754f2912a08fe246bada0b68eb89b2eb49b70a3279db4ded5645f3438bc859e1a57be3cde29e6c1fc59526db2747d1cc98a5525c6
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.14.0...HEAD
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
- c.company = -> { { name: "Acme", address: "Head office address", logo_path: nil } }
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.
@@ -0,0 +1,5 @@
1
+ // The ID card's Print button. The engine ships no framework, and an inline
2
+ // onclick would need a CSP nonce the host may not issue.
3
+ document.addEventListener("click", function (event) {
4
+ if (event.target.closest("[data-hrl-print]")) window.print();
5
+ });
@@ -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