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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +5 -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/benefits_controller.rb +61 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/expense_categories_controller.rb +43 -0
- data/app/controllers/hr_lite/admin/holidays_controller.rb +7 -0
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -1
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +24 -1
- data/app/controllers/hr_lite/admin/payroll_line_items_controller.rb +47 -0
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +3 -1
- data/app/controllers/hr_lite/admin/policies_controller.rb +29 -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/leave_requests_controller.rb +3 -1
- data/app/controllers/hr_lite/salary_slips_controller.rb +6 -6
- data/app/helpers/hr_lite/application_helper.rb +21 -6
- data/app/jobs/hr_lite/payroll_auto_draft_job.rb +4 -3
- data/app/jobs/hr_lite/slips_ready_job.rb +10 -0
- data/app/models/hr_lite/audit_log.rb +1 -1
- data/app/models/hr_lite/benefit_enrolment.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_balance.rb +2 -1
- data/app/models/hr_lite/leave_request.rb +51 -19
- data/app/models/hr_lite/payroll_line_item.rb +5 -3
- data/app/models/hr_lite/payroll_run.rb +19 -3
- data/app/models/hr_lite/policy.rb +15 -0
- data/app/models/hr_lite/salary_slip.rb +6 -0
- data/app/models/hr_lite/setting.rb +1 -1
- data/app/services/hr_lite/attendance_summary.rb +12 -15
- data/app/views/hr_lite/admin/benefits/index.html.erb +65 -0
- data/app/views/hr_lite/admin/benefits/new.html.erb +37 -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/expense_categories/_form.html.erb +16 -0
- data/app/views/hr_lite/admin/expense_categories/edit.html.erb +3 -0
- data/app/views/hr_lite/admin/expense_categories/index.html.erb +30 -0
- data/app/views/hr_lite/admin/expense_categories/new.html.erb +3 -0
- data/app/views/hr_lite/admin/hr_requests/show.html.erb +3 -0
- data/app/views/hr_lite/admin/leave_balances/index.html.erb +4 -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/leave_requests/show.html.erb +3 -0
- data/app/views/hr_lite/admin/leave_types/index.html.erb +1 -0
- data/app/views/hr_lite/admin/payroll_line_items/index.html.erb +47 -0
- data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +42 -0
- data/app/views/hr_lite/admin/payroll_runs/show.html.erb +1 -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/salary_structures/_form.html.erb +5 -1
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +3 -1
- data/app/views/hr_lite/id_cards/show.html.erb +86 -0
- data/app/views/hr_lite/leave_requests/index.html.erb +1 -0
- data/app/views/hr_lite/leave_requests/new.html.erb +1 -0
- data/app/views/hr_lite/salary_slips/index.html.erb +1 -1
- data/app/views/hr_lite/shared/_bell.html.erb +3 -0
- data/app/views/layouts/hr_lite/application.html.erb +6 -4
- data/config/routes.rb +9 -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/db/migrate/20260929081109_add_paid_days_to_hr_lite_leave_requests.rb +5 -0
- data/lib/hr_lite/configuration.rb +15 -2
- data/lib/hr_lite/notifications.rb +10 -5
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +1 -0
- metadata +38 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c73e1512e67fbae1abb7b12f6134564974819e2055c937e3fabcb294a196bea9
|
|
4
|
+
data.tar.gz: 93bd305c7aa148e6e2f034f9355cc15078ddf8252cf0af92f938da136b945ac1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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) }
|
|
@@ -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 —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
#
|
|
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(
|
|
6
|
+
@slips = paginate(own_released.recent_first)
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
|
-
@slip =
|
|
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
|
|
21
|
-
SalarySlip.
|
|
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)
|