hr_lite 0.15.0 → 0.18.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/README.md +5 -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/benefits_controller.rb +61 -0
  7. data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
  8. data/app/controllers/hr_lite/admin/expense_categories_controller.rb +43 -0
  9. data/app/controllers/hr_lite/admin/holidays_controller.rb +7 -0
  10. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -1
  11. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +24 -1
  12. data/app/controllers/hr_lite/admin/payroll_line_items_controller.rb +47 -0
  13. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +3 -1
  14. data/app/controllers/hr_lite/admin/policies_controller.rb +29 -0
  15. data/app/controllers/hr_lite/hr_requests_controller.rb +2 -0
  16. data/app/controllers/hr_lite/id_cards_controller.rb +25 -0
  17. data/app/controllers/hr_lite/leave_requests_controller.rb +3 -1
  18. data/app/controllers/hr_lite/salary_slips_controller.rb +6 -6
  19. data/app/helpers/hr_lite/application_helper.rb +21 -6
  20. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +4 -3
  21. data/app/jobs/hr_lite/slips_ready_job.rb +10 -0
  22. data/app/models/hr_lite/audit_log.rb +1 -1
  23. data/app/models/hr_lite/benefit_enrolment.rb +1 -1
  24. data/app/models/hr_lite/employee_profile.rb +21 -3
  25. data/app/models/hr_lite/hr_request.rb +12 -3
  26. data/app/models/hr_lite/leave_balance.rb +2 -1
  27. data/app/models/hr_lite/leave_request.rb +51 -19
  28. data/app/models/hr_lite/payroll_line_item.rb +5 -3
  29. data/app/models/hr_lite/payroll_run.rb +19 -3
  30. data/app/models/hr_lite/policy.rb +15 -0
  31. data/app/models/hr_lite/salary_slip.rb +6 -0
  32. data/app/models/hr_lite/setting.rb +1 -1
  33. data/app/services/hr_lite/attendance_summary.rb +12 -15
  34. data/app/views/hr_lite/admin/benefits/index.html.erb +65 -0
  35. data/app/views/hr_lite/admin/benefits/new.html.erb +37 -0
  36. data/app/views/hr_lite/admin/employees/_form.html.erb +5 -0
  37. data/app/views/hr_lite/admin/employees/show.html.erb +1 -0
  38. data/app/views/hr_lite/admin/expense_categories/_form.html.erb +16 -0
  39. data/app/views/hr_lite/admin/expense_categories/edit.html.erb +3 -0
  40. data/app/views/hr_lite/admin/expense_categories/index.html.erb +30 -0
  41. data/app/views/hr_lite/admin/expense_categories/new.html.erb +3 -0
  42. data/app/views/hr_lite/admin/hr_requests/show.html.erb +3 -0
  43. data/app/views/hr_lite/admin/leave_balances/index.html.erb +4 -0
  44. data/app/views/hr_lite/admin/leave_requests/index.html.erb +3 -0
  45. data/app/views/hr_lite/admin/leave_requests/new.html.erb +35 -0
  46. data/app/views/hr_lite/admin/leave_requests/show.html.erb +3 -0
  47. data/app/views/hr_lite/admin/leave_types/index.html.erb +1 -0
  48. data/app/views/hr_lite/admin/payroll_line_items/index.html.erb +47 -0
  49. data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +42 -0
  50. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +1 -0
  51. data/app/views/hr_lite/admin/policies/index.html.erb +27 -0
  52. data/app/views/hr_lite/admin/policies/new.html.erb +22 -0
  53. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +5 -1
  54. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +3 -1
  55. data/app/views/hr_lite/id_cards/show.html.erb +86 -0
  56. data/app/views/hr_lite/leave_requests/index.html.erb +1 -0
  57. data/app/views/hr_lite/leave_requests/new.html.erb +1 -0
  58. data/app/views/hr_lite/salary_slips/index.html.erb +1 -1
  59. data/app/views/hr_lite/shared/_bell.html.erb +3 -0
  60. data/app/views/layouts/hr_lite/application.html.erb +6 -4
  61. data/config/routes.rb +9 -1
  62. data/db/migrate/20260927100145_add_id_card_fields_to_hr_lite_employee_profiles.rb +6 -0
  63. data/db/migrate/20260927101031_add_created_by_id_to_hr_lite_leave_requests.rb +5 -0
  64. data/db/migrate/20260929081109_add_paid_days_to_hr_lite_leave_requests.rb +5 -0
  65. data/lib/hr_lite/configuration.rb +15 -2
  66. data/lib/hr_lite/notifications.rb +10 -5
  67. data/lib/hr_lite/version.rb +1 -1
  68. data/lib/hr_lite.rb +1 -0
  69. metadata +38 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec0aa9dccb203487f12a0e30cbbae7567da32b9589af4046fe86795c9a3cb5a4
4
- data.tar.gz: 0e5b02a62ea2925115f71279dd9772f12a103b6cc17fb6d192f0225a3c95504e
3
+ metadata.gz: c73e1512e67fbae1abb7b12f6134564974819e2055c937e3fabcb294a196bea9
4
+ data.tar.gz: 93bd305c7aa148e6e2f034f9355cc15078ddf8252cf0af92f938da136b945ac1
5
5
  SHA512:
6
- metadata.gz: e1877020700a05db3f7a5a4a715be0c1e8e7f4ad447d3c42eaf6d17b60e0252ef68968eb58f431e7373e43d3452bae468d6e211eac64c3f4e9ca71703920d412
7
- data.tar.gz: 28d5a185fd3af1373efb3b2db1e3d53d8b64e66ec676a7cfed01c15ffbb8ed2a07b0a921d4351ac6afca41db18e683ae9ad984e99b5ffbf6eeac32209e108af0
6
+ metadata.gz: f4c2331de1a65bbfb074277c9a309b1276c213a55747fd8f8446c5d54c72a41edfb3087a950bfd19aff5fc00ed2b96130e20d200583e9e8970dc0edd10750806
7
+ data.tar.gz: 25103c4830b09f09e13996557e926dfb4bde694fb0cb60a8f10d8adb355ba4f25d553b226ec38f039df395a339a832c4822ecd2a4aaa5ebbf5b6f111a957e88c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,117 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.0] - 2026-09-29
11
+
12
+ Salary follows leave. One migration (`paid_days` on leave requests, nullable;
13
+ existing rows read as fully paid).
14
+
15
+ ### Changed
16
+
17
+ - **Leave beyond the balance is loss of pay, not refused.** On approval —
18
+ applied by the employee or recorded by HR — the balance covers the leave's
19
+ earliest working days in half-day steps (`paid_days`), and payroll cuts the
20
+ rest at the month's gross divided by its calendar days. The balance is never
21
+ overdrawn by it, so next month's accrual is not swallowed. Each unpaid day is
22
+ cut in its own month when a leave spans two. The split is fixed at approval;
23
+ correct it with the slip's LOP override while the run is in review.
24
+ - Comp-off is still refused past its earned credit.
25
+ - The monthly draft alert on the 1st goes to whoever runs payroll
26
+ (`payroll.manage`), with email, instead of leadership — who cannot open it.
27
+
28
+ ### Added
29
+
30
+ - The employee is told on applying how many days will be unpaid; the approver
31
+ sees it on the request before approving.
32
+ - `config.slip_release_day` (1..28): employees see a published slip from that
33
+ day of the following month, and the "slip ready" bell and email wait until
34
+ then. `nil` (default) keeps today's behaviour. Needs an ActiveJob backend
35
+ that runs scheduled jobs.
36
+
37
+ ## [0.17.0] - 2026-09-29
38
+
39
+ Screens for the things other screens were waiting on. An audit of every HR
40
+ screen found four that could never show anything, because the rows they list
41
+ could only be created from a console. No migration.
42
+
43
+ ### Added
44
+
45
+ - **Expense categories** (Settings → Expense categories, leadership). Until
46
+ now nothing created one, so the claim form had no categories and every
47
+ expense claim failed with "Category must exist". Add or edit name, monthly
48
+ cap, receipt rule and active; untick Active to retire one (no delete —
49
+ claims point at it).
50
+ - **One-off pay items** (Payroll → a run → One-off pay items, money tier): a
51
+ bonus, incentive, arrears, reimbursement or one-time deduction for a person
52
+ and month, carried by that month's computed slip. A month already paid
53
+ refuses both adding and removing an item; the loan repayment line stays
54
+ with the loan. Items are money-tier in the audit trail, so the leadership
55
+ email no longer carries the amount.
56
+ - **Benefits admin** (Manage → Benefits, `benefit.manage`): add a benefit,
57
+ enrol somebody with their dependants, end their cover. Employees' Benefits
58
+ page was always empty before.
59
+
60
+ ### Fixed
61
+
62
+ - **A manager could adjust anybody's leave balance.** The adjust action never
63
+ asked whose balance it was. It now needs `leave.manage` reaching that person;
64
+ the balances grid lists only people the viewer can see, and the adjust form
65
+ shows only to someone who holds `leave.manage`.
66
+ - The professional-tax state picker on the salary structure form had no
67
+ options.
68
+ - Sidebar links that led nowhere: the employee Approvals inbox shows only once
69
+ an approval flow exists; Claims shows to whoever approves claims (Finance
70
+ sees it, HR no longer lands on access denied); Profile is linked, and with it
71
+ Resignation; both leave balance pages are linked.
72
+
73
+ ## [0.16.0] - 2026-09-27
74
+
75
+ Employee ID cards, print requests through Ask HR, recording leave for
76
+ somebody, and notices that reach the right people. Two migrations, both
77
+ additive (`blood_group`/`emergency_contact` on profiles, `created_by_id` on
78
+ leave requests). New runtime dependency: `rqrcode`.
79
+
80
+ ### Added
81
+
82
+ - **Employee ID card.** `/id_card` shows the card front and back; `/id_card/:user_id`
83
+ lets anyone whose `profile.view` reaches that person open it to print. Print
84
+ gives one CR80 (54 × 85.6 mm) face per page. The QR holds the company brand,
85
+ address and phone as plain text — no URL, nothing to host.
86
+ - **ID photo.** The one field an employee edits on their own profile. JPG, PNG
87
+ or WebP under 5 MB, content-sniffed; every change is audited.
88
+ - Blood group and emergency contact on the leadership employee form, encrypted
89
+ like the other personal fields so the audit email reads `[changed]`.
90
+ - `config.company` may return `brand:` and `phone:`.
91
+ - **Request a printed ID card.** One tap on the card raises an Ask-HR request
92
+ in the new `id_card` category. It needs a photo first, and only one can be
93
+ open at a time. The desk opens the card from the request to print it;
94
+ answering the request stamps "Issued" on the card.
95
+ - **Record leave for somebody.** Approvals → Record leave, for leave taken but
96
+ never applied for. It is saved approved by whoever records it, keeps the
97
+ balance check, stores `created_by_id`, and emails the employee — not the
98
+ approvers. The employee is picked by typing a name or code.
99
+ - A new hire gets the Employee role with their profile, so Ask HR and
100
+ Policies work from day one.
101
+ - **Publish policies and announcements.** Leadership (`policy.manage`) writes
102
+ one at `/admin/policies`; every current employee gets an email and a bell.
103
+ A policy asks to be acknowledged, an announcement does not, and reusing a
104
+ title publishes the next version. The list shows who still has to
105
+ acknowledge.
106
+ - **Holiday notices.** Adding a holiday tells everyone; a pasted list sends one
107
+ notice, not one per line (new matrix row `holiday.published`).
108
+ - `config.notifications = ->(user) { { url:, unread: } }` puts a notifications
109
+ link with the unread count in the HR shell. Off by default.
110
+
111
+ ### Changed
112
+
113
+ - Notification defaults: a leave application and an Ask-HR request now EMAIL
114
+ every approver / desk holder as well as belling them; the team "on leave"
115
+ notice is a bell only, and is not sent at all for leave already over.
116
+ - Overlap and attendance errors on a leave request no longer say "you" — HR
117
+ sees them too now.
118
+ - Employee codes are zero-padded to six digits, and the prefix may end in one
119
+ hyphen — `ESA-` gives `ESA-000001`. Existing codes are left as they are.
120
+
10
121
  ## [0.15.0] - 2026-08-18
11
122
 
12
123
  The screens for three features that shipped as tables nobody could reach. No
data/README.md CHANGED
@@ -131,13 +131,17 @@ 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.
138
141
  c.render_pdf = ->(template:, assigns:, cache_key:) {
139
142
  PdfRenderer.render(template: template, assigns: assigns, cache_key: cache_key)
140
143
  }
144
+ c.slip_release_day = 10 # employees see a slip from the 10th of the next month; nil (default) = on publish
141
145
 
142
146
  # Mirror promotions into your own user model (optional).
143
147
  c.on_designation_change = ->(user, designation) { user.update!(designation: designation) }
@@ -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,61 @@
1
+ module HrLite
2
+ module Admin
3
+ # Who is covered by what. Before this screen nothing created a benefit,
4
+ # so every employee's /benefits page said "not enrolled".
5
+ class BenefitsController < BaseController
6
+ skip_before_action :require_operations_access!
7
+ before_action :require_benefits!
8
+
9
+ def index
10
+ @benefits = Benefit.order(:name)
11
+ @enrolments = paginate(covered.includes(:benefit, :user).order(:enrolled_on))
12
+ end
13
+
14
+ def new
15
+ @benefit = Benefit.new
16
+ end
17
+
18
+ def create
19
+ @benefit = Benefit.new(benefit_params)
20
+ if @benefit.save
21
+ redirect_to admin_benefits_path, notice: "#{@benefit.name} added."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def enrol
28
+ benefit = Benefit.find(params[:id])
29
+ user = HrLite.user_klass.find(params[:user_id])
30
+ benefit.benefit_enrolments.create!(user_id: user.id, enrolled_on: parse_date_param(params[:enrolled_on]),
31
+ dependants: params[:dependants].presence || 0)
32
+ redirect_to admin_benefits_path, notice: "#{HrLite.display_name(user)} enrolled in #{benefit.name}."
33
+ rescue ActiveRecord::RecordInvalid => e
34
+ redirect_to admin_benefits_path, alert: e.record.errors.full_messages.to_sentence
35
+ end
36
+
37
+ # Ends cover rather than deleting the row: who was covered when is
38
+ # the record an insurance claim gets checked against.
39
+ def unenrol
40
+ enrolment = covered.where(benefit_id: params[:id]).find_by!(user_id: params[:user_id])
41
+ enrolment.update!(ended_on: parse_date_param(params[:ended_on]))
42
+ redirect_to admin_benefits_path, notice: "Cover ended for #{HrLite.display_name(enrolment.user)}."
43
+ rescue ActiveRecord::RecordInvalid => e
44
+ redirect_to admin_benefits_path, alert: e.record.errors.full_messages.to_sentence
45
+ end
46
+
47
+ private
48
+
49
+ def require_benefits! = hr_require_permission!("benefit.manage", scope: :all)
50
+
51
+ # Still covered today — including cover that ends on a future date, which
52
+ # HR must still be able to see and change.
53
+ def covered = BenefitEnrolment.where("ended_on IS NULL OR ended_on >= ?", Date.current)
54
+
55
+ def benefit_params
56
+ params.require(:benefit).permit(:name, :kind, :provider, :policy_number, :coverage, :employer_premium,
57
+ :employee_premium, :effective_from, :expires_on, :notes)
58
+ end
59
+ end
60
+ end
61
+ 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
 
@@ -0,0 +1,43 @@
1
+ module HrLite
2
+ module Admin
3
+ # No destroy: claims hold the category by foreign key. Retire one by
4
+ # unticking Active, which takes it off the claim form.
5
+ class ExpenseCategoriesController < LeadershipController
6
+ def index
7
+ @expense_categories = ExpenseCategory.alphabetical
8
+ end
9
+
10
+ def new
11
+ @expense_category = ExpenseCategory.new
12
+ end
13
+
14
+ def create
15
+ @expense_category = ExpenseCategory.new(expense_category_params)
16
+ if @expense_category.save
17
+ redirect_to admin_expense_categories_path, notice: "Category added."
18
+ else
19
+ render :new, status: :unprocessable_entity
20
+ end
21
+ end
22
+
23
+ def edit
24
+ @expense_category = ExpenseCategory.find(params[:id])
25
+ end
26
+
27
+ def update
28
+ @expense_category = ExpenseCategory.find(params[:id])
29
+ if @expense_category.update(expense_category_params)
30
+ redirect_to admin_expense_categories_path, notice: "Category updated."
31
+ else
32
+ render :edit, status: :unprocessable_entity
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def expense_category_params
39
+ params.require(:expense_category).permit(:name, :monthly_cap, :receipt_required, :active)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -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
@@ -4,12 +4,15 @@ module HrLite
4
4
  def index
5
5
  @year = sanitized_year
6
6
  @types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
7
- @employees = HrLite.employees
7
+ @employees = HrLite.employees.select { |employee| hr_reaches?("leave.view", employee) }
8
8
  end
9
9
 
10
10
  # Manual credit/debit — also the comp-off credit mechanism.
11
11
  def adjust
12
12
  user = HrLite.user_klass.find(params[:user_id])
13
+ # A manager reaches this screen through leave.approve; changing
14
+ # somebody's quota is leave.manage, for THAT person.
15
+ hr_require_reach!("leave.manage", user)
13
16
  type = LeaveType.find(params[:leave_type_id])
14
17
  # Unsanitised, a missing param wrote the adjustment into leave year 0,
15
18
  # where no screen can ever show it.
@@ -16,13 +16,34 @@ 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; days beyond the balance become loss of pay.
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 comp-off credit" : "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)
22
43
  redirect_to admin_leave_requests_path, notice: "Leave approved."
23
44
  else
24
45
  redirect_to admin_leave_request_path(request),
25
- alert: "Cannot approve — balance no longer covers this request."
46
+ alert: "Cannot approve — comp-off credit no longer covers this request."
26
47
  end
27
48
  rescue ActiveRecord::RecordInvalid
28
49
  redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
@@ -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,47 @@
1
+ module HrLite
2
+ module Admin
3
+ # One-off payslip lines: a bonus, an incentive, arrears, a reimbursement,
4
+ # a one-time deduction. Money tier, like loans — this is somebody's pay.
5
+ # Create and destroy are audited by PayrollLineItem's Audited concern.
6
+ class PayrollLineItemsController < SuperadminController
7
+ def index
8
+ @month = parse_month_param(params[:month])
9
+ @items = paginate(PayrollLineItem.for_month(@month).includes(:user, :component).order(:created_at))
10
+ end
11
+
12
+ def new
13
+ @item = PayrollLineItem.new(period_month: parse_month_param(params[:month]))
14
+ end
15
+
16
+ def create
17
+ @item = PayrollLineItem.new(item_params.merge(
18
+ period_month: parse_month_param(params.dig(:payroll_line_item, :period_month)),
19
+ created_by_id: hr_current_user.id,
20
+ # Same list the form offers: loan repayments come from the loan itself.
21
+ component: addable_components.find_by(id: params.dig(:payroll_line_item, :component_id))
22
+ ))
23
+ if @item.save
24
+ redirect_to admin_payroll_line_items_path(month: @item.period_month.strftime("%Y-%m")),
25
+ notice: "#{@item.component.label} added for #{HrLite.display_name(@item.user)}."
26
+ else
27
+ render :new, status: :unprocessable_entity
28
+ end
29
+ end
30
+
31
+ def destroy
32
+ item = PayrollLineItem.find(params[:id])
33
+ removed = item.destroy
34
+ redirect_to admin_payroll_line_items_path(month: item.period_month.strftime("%Y-%m")), status: :see_other,
35
+ **(removed ? { notice: "Removed." } : { alert: "That month has already been paid — it can't change now." })
36
+ end
37
+
38
+ private
39
+
40
+ def item_params
41
+ params.require(:payroll_line_item).permit(:user_id, :amount, :note)
42
+ end
43
+
44
+ def addable_components = SalaryComponent.active.where.not(code: "loan_repayment")
45
+ end
46
+ end
47
+ end
@@ -48,7 +48,9 @@ module HrLite
48
48
  end
49
49
 
50
50
  def publish
51
- transition { |run| run.publish!(actor: hr_current_user) && "Published — employees notified." }
51
+ day = HrLite.config.slip_release_day
52
+ opens = day && "Published — employees can open it, and are notified, from the #{day.ordinalize} of next month."
53
+ transition { |run| run.publish!(actor: hr_current_user) && (opens || "Published — employees notified.") }
52
54
  end
53
55
 
54
56
  # Payout register: the full money sheet including bank details —
@@ -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
@@ -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
@@ -19,7 +19,9 @@ module HrLite
19
19
  def create
20
20
  @request = LeaveRequest.new(request_params.merge(user_id: hr_current_user.id))
21
21
  if @request.save
22
- redirect_to leave_requests_path, notice: "Leave request submitted."
22
+ # Say it now, not on the payslip: days past the balance are unpaid.
23
+ short = @request.days_beyond_balance
24
+ redirect_to leave_requests_path, notice: "Leave request submitted.#{" #{short.to_s('F')} day(s) are beyond your balance and will be unpaid if approved." if short.positive?}"
23
25
  else
24
26
  @balances = balance_cards
25
27
  render :new, status: :unprocessable_entity
@@ -1,13 +1,13 @@
1
1
  module HrLite
2
- # Employee surface: strictly own PUBLISHED slips — a foreign or
3
- # unpublished id 404s through the scoped relation, never 403s.
2
+ # Employee surface: strictly own RELEASED slips — a foreign, unpublished
3
+ # or not-yet-released id 404s through the scoped relation, never 403s.
4
4
  class SalarySlipsController < ApplicationController
5
5
  def index
6
- @slips = paginate(own_published.recent_first)
6
+ @slips = paginate(own_released.recent_first)
7
7
  end
8
8
 
9
9
  def show
10
- @slip = own_published.find(params[:id])
10
+ @slip = own_released.find(params[:id])
11
11
 
12
12
  respond_to do |format|
13
13
  format.html
@@ -17,8 +17,8 @@ module HrLite
17
17
 
18
18
  private
19
19
 
20
- def own_published
21
- SalarySlip.published.where(user_id: hr_current_user.id)
20
+ def own_released
21
+ SalarySlip.released.where(user_id: hr_current_user.id)
22
22
  end
23
23
 
24
24
  def send_slip_pdf(slip)