hr_lite 0.15.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  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/employees_controller.rb +1 -1
  7. data/app/controllers/hr_lite/admin/holidays_controller.rb +7 -0
  8. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +23 -0
  9. data/app/controllers/hr_lite/admin/policies_controller.rb +29 -0
  10. data/app/controllers/hr_lite/hr_requests_controller.rb +2 -0
  11. data/app/controllers/hr_lite/id_cards_controller.rb +25 -0
  12. data/app/helpers/hr_lite/application_helper.rb +9 -0
  13. data/app/models/hr_lite/employee_profile.rb +21 -3
  14. data/app/models/hr_lite/hr_request.rb +12 -3
  15. data/app/models/hr_lite/leave_request.rb +15 -4
  16. data/app/models/hr_lite/policy.rb +15 -0
  17. data/app/models/hr_lite/setting.rb +1 -1
  18. data/app/views/hr_lite/admin/employees/_form.html.erb +5 -0
  19. data/app/views/hr_lite/admin/employees/show.html.erb +1 -0
  20. data/app/views/hr_lite/admin/hr_requests/show.html.erb +3 -0
  21. data/app/views/hr_lite/admin/leave_requests/index.html.erb +3 -0
  22. data/app/views/hr_lite/admin/leave_requests/new.html.erb +35 -0
  23. data/app/views/hr_lite/admin/policies/index.html.erb +27 -0
  24. data/app/views/hr_lite/admin/policies/new.html.erb +22 -0
  25. data/app/views/hr_lite/id_cards/show.html.erb +86 -0
  26. data/app/views/hr_lite/shared/_bell.html.erb +3 -0
  27. data/app/views/layouts/hr_lite/application.html.erb +2 -0
  28. data/config/routes.rb +4 -1
  29. data/db/migrate/20260927100145_add_id_card_fields_to_hr_lite_employee_profiles.rb +6 -0
  30. data/db/migrate/20260927101031_add_created_by_id_to_hr_lite_leave_requests.rb +5 -0
  31. data/lib/hr_lite/configuration.rb +4 -1
  32. data/lib/hr_lite/notifications.rb +4 -3
  33. data/lib/hr_lite/version.rb +1 -1
  34. data/lib/hr_lite.rb +1 -0
  35. metadata +25 -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: 14d37a1d62a035315390be2c619a74722c58e6b53b747422aa4ab48458287102
4
+ data.tar.gz: fbbb6c7efb893ff38d6992971844fd721deca4819943f0e0dd8f1cd343f4b4fc
5
5
  SHA512:
6
- metadata.gz: e1877020700a05db3f7a5a4a715be0c1e8e7f4ad447d3c42eaf6d17b60e0252ef68968eb58f431e7373e43d3452bae468d6e211eac64c3f4e9ca71703920d412
7
- data.tar.gz: 28d5a185fd3af1373efb3b2db1e3d53d8b64e66ec676a7cfed01c15ffbb8ed2a07b0a921d4351ac6afca41db18e683ae9ad984e99b5ffbf6eeac32209e108af0
6
+ metadata.gz: 70b42e3a35bd683ab62ecda90d33009511faeda8595cbc9ec9fc9f1ae2388f61592059a4ea1be151ade6ec3062f4ecea2726098f2f58adc55048f8e108de7460
7
+ data.tar.gz: a19199e7cd8a173713e97d77266d4779951ef47d70171c234ee65d2afec5cc35be8670542db4e49ad2e656efe6031b8aba053cc281f687fd08dd6b20e3d83511
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ 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
+
10
58
  ## [0.15.0] - 2026-08-18
11
59
 
12
60
  The screens for three features that shipped as tables nobody could reach. No
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; }
@@ -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,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
@@ -21,6 +21,7 @@ module HrLite
21
21
  { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
22
22
  { label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] },
23
23
  { label: "Documents", path: :documents_path, match: [ "/documents" ] },
24
+ { label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
24
25
  { label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
25
26
  { label: "Loans", path: :loans_path, match: [ "/loans" ] }
26
27
  ].freeze
@@ -40,6 +41,7 @@ module HrLite
40
41
  { label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
41
42
  { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
42
43
  { label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
44
+ { label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
43
45
  { label: "Documents", path: :admin_documents_path, match: [ "/admin/documents" ] }
44
46
  ].freeze
45
47
 
@@ -54,6 +56,13 @@ module HrLite
54
56
  { label: "Loans", path: :admin_loans_path, match: [ "/admin/loans" ] }
55
57
  ].freeze
56
58
 
59
+ # The ID card's QR: office address and phone as plain text, so any phone
60
+ # camera shows who to call about a found card. No URL, nothing to host.
61
+ def hrl_office_qr_svg(company, brand)
62
+ text = [ brand, company[:address], company[:phone] ].compact_blank.join("\n")
63
+ RQRCode::QRCode.new(text).as_svg(use_path: true, viewbox: true, color: "0F172A").html_safe
64
+ end
65
+
57
66
  # Only items whose routes exist yet (the nav grows with each phase).
58
67
  def hrl_nav_items(items)
59
68
  items.select { |item| hr_lite_route?(item[:path]) }
@@ -13,7 +13,7 @@ module HrLite
13
13
  # profiles as roots (founders/directors).
14
14
  belongs_to :manager, class_name: HrLite.config.user_class, optional: true
15
15
 
16
- encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc
16
+ encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc, :blood_group, :emergency_contact
17
17
  # fy_opening_*: income already paid this financial year that this install
18
18
  # did not run — a previous employer, or the months before payroll was
19
19
  # switched on here. Feeds the TDS projection so a mid-year start does not
@@ -21,12 +21,21 @@ module HrLite
21
21
  encrypted_money :declared_annual_deductions, :fy_opening_gross, :fy_opening_tds
22
22
 
23
23
  TAX_REGIMES = %w[new old].freeze
24
+ BLOOD_GROUPS = %w[A+ A- B+ B- AB+ AB- O+ O-].freeze
25
+ # The ID-card photo. Images a browser can print; content-sniffed like
26
+ # Document, so the check is on the real bytes.
27
+ PHOTO_TYPES = %w[image/jpeg image/png image/webp].freeze
28
+ PHOTO_MAX_BYTES = 5.megabytes
29
+
30
+ has_one_attached :photo
24
31
 
25
32
  # Onboarding-form virtuals (the controller hands them to
26
33
  # config.onboard_user; never persisted, never audited).
27
34
  attr_accessor :new_user_name, :new_user_email, :new_user_password
28
35
 
29
36
  before_validation :assign_employee_code, on: :create
37
+ # Without a role a new hire cannot see policies or raise an HR request.
38
+ after_create { (role = Role.find_by(name: Role::EMPLOYEE)) && RoleAssignment.find_or_create_by!(user_id:, role:) }
30
39
 
31
40
  validates :employee_code, presence: true, uniqueness: true
32
41
  validates :user_id, uniqueness: true
@@ -37,6 +46,8 @@ module HrLite
37
46
  validates :bank_ifsc, format: { with: /\A[A-Z]{4}0[A-Z0-9]{6}\z/, message: "is not a valid IFSC" },
38
47
  allow_blank: true
39
48
  validates :pf_uan, format: { with: /\A\d{12}\z/, message: "must be 12 digits" }, allow_blank: true
49
+ validates :blood_group, inclusion: { in: BLOOD_GROUPS }, allow_blank: true
50
+ validate :photo_is_a_small_image
40
51
  validate :exit_after_joining
41
52
  validate :manager_chain_acyclic
42
53
  validate :manager_is_active_staff, if: :manager_id_changed?
@@ -97,7 +108,7 @@ module HrLite
97
108
  private
98
109
 
99
110
  # Codes are system-assigned: Settings prefix + zero-padded next number
100
- # (EMP001, EMP002, ...). Scans the highest existing suffix for the
111
+ # (ESA-000001, ESA-000002, ...). Scans the highest existing suffix for the
101
112
  # CURRENT prefix so changing the prefix restarts a fresh sequence
102
113
  # without colliding with history.
103
114
  def assign_employee_code
@@ -108,7 +119,7 @@ module HrLite
108
119
  .pluck(:employee_code)
109
120
  .filter_map { |code| code.delete_prefix(prefix)[/\A\d+\z/]&.to_i }
110
121
  .max || 0
111
- self.employee_code = format("%s%03d", prefix, last + 1)
122
+ self.employee_code = format("%s%06d", prefix, last + 1)
112
123
  end
113
124
 
114
125
  def sanitize_sql_like(value)
@@ -140,6 +151,13 @@ module HrLite
140
151
  end
141
152
  end
142
153
 
154
+ def photo_is_a_small_image
155
+ return unless photo.attached?
156
+ return errors.add(:photo, "must be a JPG, PNG or WebP image") unless PHOTO_TYPES.include?(photo.blob.content_type)
157
+
158
+ errors.add(:photo, "must be under #{PHOTO_MAX_BYTES / 1.megabyte} MB") if photo.blob.byte_size > PHOTO_MAX_BYTES
159
+ end
160
+
143
161
  def mask_middle(value)
144
162
  return nil if value.blank?
145
163
  return "•" * value.length if value.length <= 4
@@ -8,7 +8,7 @@ module HrLite
8
8
 
9
9
  CATEGORIES = %w[
10
10
  salary_certificate employment_certificate address_change bank_change
11
- document_request payroll_query tax_query insurance_query policy_query other
11
+ document_request payroll_query tax_query insurance_query policy_query id_card other
12
12
  ].freeze
13
13
 
14
14
  belongs_to :user, class_name: HrLite.config.user_class
@@ -17,6 +17,7 @@ module HrLite
17
17
  validates :subject, presence: true
18
18
  validates :category, inclusion: { in: CATEGORIES }
19
19
  validates :status, inclusion: { in: STATUSES }
20
+ validate :id_card_can_be_printed, on: :create, if: -> { category == "id_card" }
20
21
 
21
22
  scope :open_requests, -> { where(status: %w[open in_progress]) }
22
23
  scope :recent_first, -> { order(created_at: :desc) }
@@ -25,7 +26,7 @@ module HrLite
25
26
 
26
27
  after_create_commit :notify_desk
27
28
 
28
- def category_label = category.humanize
29
+ def category_label = category.humanize.sub(/\AId\b/, "ID")
29
30
 
30
31
  def assign!(actor:, assignee:)
31
32
  update!(assigned_to_id: assignee.id, status: "in_progress")
@@ -68,8 +69,16 @@ module HrLite
68
69
  Notifications.publish(
69
70
  "hr_request.raised",
70
71
  title: "#{HrLite.display_name(user)} asked: #{subject}",
71
- body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk
72
+ body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk, email_to: desk
72
73
  )
73
74
  end
75
+
76
+ def id_card_can_be_printed
77
+ if !EmployeeProfile.find_by(user_id: user_id)&.photo&.attached?
78
+ errors.add(:base, "Add your photo to the ID card first")
79
+ elsif self.class.open_requests.exists?(user_id: user_id, category: "id_card")
80
+ errors.add(:base, "Your ID card print is already requested")
81
+ end
82
+ end
74
83
  end
75
84
  end
@@ -7,6 +7,8 @@ module HrLite
7
7
  belongs_to :user, class_name: HrLite.config.user_class
8
8
  belongs_to :leave_type
9
9
  belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
10
+ # Set when HR records leave the employee forgot to apply for.
11
+ belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
10
12
 
11
13
  validates :start_date, :end_date, presence: true
12
14
  validates :status, inclusion: { in: STATUSES }
@@ -22,7 +24,7 @@ module HrLite
22
24
  end
23
25
 
24
26
  before_validation :cache_days_count, on: :create
25
- after_create :notify_requested
27
+ after_create :notify_requested, unless: :recorded_by_hr?
26
28
 
27
29
  scope :pending, -> { where(status: "pending") }
28
30
  scope :approved, -> { where(status: "approved") }
@@ -209,13 +211,20 @@ module HrLite
209
211
  LeaveDayCounter.count(self) > balance.available(as_of: start_date)
210
212
  end
211
213
 
214
+ def recorded_by_hr? = created_by_id.present? && created_by_id != user_id
215
+
216
+ # Recorded leave is approved in the same transaction, so there is nothing
217
+ # left for a flow to route.
218
+ def open_approval_route = pending? && super
219
+
212
220
  def notify_requested
213
221
  Notifications.publish(
214
222
  "leave.requested",
215
223
  title: "#{HrLite.display_name(user)} applied for #{leave_type.name} (#{date_range_label})",
216
224
  body: reason.presence,
217
225
  path: "/admin/leave_requests/#{id}",
218
- bell_to: HrLite.admin_users
226
+ bell_to: HrLite.admin_users,
227
+ email_to: HrLite.admin_users
219
228
  )
220
229
  end
221
230
 
@@ -224,6 +233,8 @@ module HrLite
224
233
  # channel). Deliberately excludes the reason: dates are team-relevant,
225
234
  # the why is not.
226
235
  def notify_team
236
+ return if end_date < Date.current # nobody needs to hear who was out
237
+
227
238
  team = HrLite.active_employees.reject { |member| member.id == user_id }
228
239
  return if team.empty?
229
240
 
@@ -285,7 +296,7 @@ module HrLite
285
296
  clash = self.class.where(user_id: user_id, status: %w[pending approved])
286
297
  .where.not(id: id)
287
298
  .overlapping_range(start_date, end_date)
288
- errors.add(:base, "You already have leave overlapping these dates") if clash.exists?
299
+ errors.add(:base, "Leave already overlaps these dates") if clash.exists?
289
300
  end
290
301
 
291
302
  # A full-day leave over a day that already has a check-in makes no
@@ -296,7 +307,7 @@ module HrLite
296
307
 
297
308
  punched = AttendanceRecord.where(user_id: user_id, date: start_date..end_date)
298
309
  .where.not(check_in_at: nil)
299
- errors.add(:base, "You have marked attendance in this period") if punched.exists?
310
+ errors.add(:base, "Attendance is marked in this period") if punched.exists?
300
311
  end
301
312
 
302
313
  def sufficient_balance
@@ -12,6 +12,8 @@ module HrLite
12
12
  validates :effective_from, presence: true
13
13
  validates :version, numericality: { greater_than: 0 }
14
14
 
15
+ after_create_commit :announce, if: :published?
16
+
15
17
  scope :published, -> { where(published: true) }
16
18
  scope :live_on, ->(date) { published.where(effective_from: ..date) }
17
19
  scope :newest_first, -> { order(effective_from: :desc, version: :desc) }
@@ -34,6 +36,8 @@ module HrLite
34
36
  )
35
37
  end
36
38
 
39
+ def announcement? = !acknowledgement_required
40
+
37
41
  def acknowledged_by?(user)
38
42
  policy_acknowledgements.exists?(user_id: user.id)
39
43
  end
@@ -55,5 +59,16 @@ module HrLite
55
59
  rescue ActiveRecord::RecordNotUnique
56
60
  policy_acknowledgements.find_by!(user_id: user.id)
57
61
  end
62
+
63
+ private
64
+
65
+ def announce
66
+ staff = HrLite.active_employees
67
+ Notifications.publish(
68
+ "policy.published",
69
+ title: "#{announcement? ? 'Announcement' : 'Policy'}: #{title}#{' (please read and acknowledge)' unless announcement?}",
70
+ body: body.truncate(300), path: "/policies/#{id}", bell_to: staff, email_to: staff
71
+ )
72
+ end
58
73
  end
59
74
  end
@@ -7,7 +7,7 @@ module HrLite
7
7
 
8
8
  validates :weekend_policy, inclusion: { in: WEEKEND_POLICIES }
9
9
  validates :employee_code_prefix, presence: true,
10
- format: { with: /\A[A-Za-z]{1,10}\z/, message: "letters only, max 10" }
10
+ format: { with: /\A[A-Za-z]{1,10}-?\z/, message: "letters only (max 10), optionally ending in -" }
11
11
 
12
12
  def self.instance
13
13
  first_or_create!
@@ -57,6 +57,11 @@
57
57
  <div class="hrl-field"><%= f.label :work_location %><%= f.text_field :work_location %></div>
58
58
  <div class="hrl-field"><%= f.label :date_of_birth %><%= f.date_field :date_of_birth %></div>
59
59
  <div class="hrl-field"><%= f.label :date_of_joining %><%= f.date_field :date_of_joining %></div>
60
+ <div class="hrl-field">
61
+ <%= f.label :blood_group %>
62
+ <%= f.select :blood_group, HrLite::EmployeeProfile::BLOOD_GROUPS, include_blank: "—" %>
63
+ </div>
64
+ <div class="hrl-field"><%= f.label :emergency_contact, "Emergency contact (printed on the ID card)" %><%= f.telephone_field :emergency_contact %></div>
60
65
  <div class="hrl-field">
61
66
  <%= f.label :date_of_exit, "Date of exit (leave blank while employed)" %>
62
67
  <%= f.date_field :date_of_exit %>
@@ -3,6 +3,7 @@
3
3
  <h1 class="hrl-page__title"><%= hr_display_name(@profile.user) %> <span class="hrl-muted">(<%= @profile.employee_code %>)</span></h1>
4
4
  <div class="hrl-page__actions">
5
5
  <a class="hrl-btn" href="<%= hr_lite.edit_admin_employee_path(@profile) %>">Edit</a>
6
+ <a class="hrl-btn" href="<%= hr_lite.id_card_path(user_id: @profile.user_id) %>">ID card</a>
6
7
  <% if hr_superadmin? %>
7
8
  <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_appraisal_path(@profile) %>">New appraisal</a>
8
9
  <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_designation_change_path(@profile) %>">Role change</a>
@@ -7,6 +7,9 @@
7
7
  <%= @request.created_at.to_date.strftime("%d %b %Y") %> ·
8
8
  <%= hrl_request_status_badge(@request.status) %>
9
9
  </p>
10
+ <% if @request.category == "id_card" %>
11
+ <a class="hrl-btn hrl-mt" href="<%= hr_lite.id_card_path(user_id: @request.user_id) %>">Open the ID card to print</a>
12
+ <% end %>
10
13
  <% if @request.body.present? %>
11
14
  <div class="hrl-prose"><%= simple_format(@request.body) %></div>
12
15
  <% end %>
@@ -1,6 +1,9 @@
1
1
  <% content_for(:page_title) { "Leave approvals" } %>
2
2
  <div class="hrl-page__head">
3
3
  <h1 class="hrl-page__title">Leave approvals</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn" href="<%= hr_lite.new_admin_leave_request_path %>">Record leave</a>
6
+ </div>
4
7
  </div>
5
8
 
6
9
  <%= render "hr_lite/admin/shared/approvals_tabs", active: :leaves %>
@@ -0,0 +1,35 @@
1
+ <% content_for(:page_title) { "Record leave" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Record leave for an employee</h1>
4
+ <p class="hrl-page__sub">For leave somebody took but never applied for. It is saved as approved, and they get an email.</p>
5
+ </div>
6
+
7
+ <section class="hrl-card">
8
+ <%= form_with model: @request, url: hr_lite.admin_leave_requests_path, method: :post, local: true do |f| %>
9
+ <%= render "hr_lite/shared/form_errors", record: @request %>
10
+ <div class="hrl-field">
11
+ <%= label_tag :employee_code, "Employee (type a name or code)" %>
12
+ <%= text_field_tag :employee_code, params[:employee_code], list: "hrl-employees", required: true, autocomplete: "off" %>
13
+ <datalist id="hrl-employees">
14
+ <% @people.each do |p| %>
15
+ <option value="<%= p.employee_code %>"><%= hr_display_name(p.user) %></option>
16
+ <% end %>
17
+ </datalist>
18
+ </div>
19
+ <div class="hrl-field">
20
+ <%= f.label :leave_type_id, "Type" %>
21
+ <%= f.collection_select :leave_type_id, HrLite::LeaveType.active, :id, :name %>
22
+ </div>
23
+ <div class="hrl-field"><%= f.label :start_date, "From" %><%= f.date_field :start_date %></div>
24
+ <div class="hrl-field"><%= f.label :end_date, "To" %><%= f.date_field :end_date %></div>
25
+ <div class="hrl-field hrl-field--check">
26
+ <%= f.check_box :half_day %>
27
+ <%= f.label :half_day, "Half day (single-day only)" %>
28
+ </div>
29
+ <div class="hrl-field"><%= f.label :reason, "Note (optional)" %><%= f.text_area :reason %></div>
30
+ <div class="hrl-form-actions">
31
+ <%= f.submit "Record leave", class: "hrl-btn hrl-btn--primary" %>
32
+ <a class="hrl-btn" href="<%= hr_lite.admin_leave_requests_path %>">Cancel</a>
33
+ </div>
34
+ <% end %>
35
+ </section>
@@ -0,0 +1,27 @@
1
+ <% content_for(:page_title) { "Policies and announcements" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Policies and announcements</h1>
4
+ <div class="hrl-page__actions"><a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_policy_path %>">Publish</a></div>
5
+ </div>
6
+
7
+ <section class="hrl-card">
8
+ <% if @policies.empty? %>
9
+ <p class="hrl-muted">Nothing published yet.</p>
10
+ <% else %>
11
+ <div class="hrl-table-wrap">
12
+ <table class="hrl-table hrl-table--stack">
13
+ <thead><tr><th>Title</th><th>Kind</th><th>In force</th><th>Still to acknowledge</th></tr></thead>
14
+ <tbody>
15
+ <% @policies.each do |policy| %>
16
+ <tr>
17
+ <td data-label="Title"><%= policy.title %> <span class="hrl-badge hrl-badge--muted">v<%= policy.version %></span></td>
18
+ <td data-label="Kind"><%= policy.announcement? ? "Announcement" : "Policy" %></td>
19
+ <td data-label="In force"><%= policy.effective_from.strftime("%d %b %Y") %></td>
20
+ <td data-label="Still to acknowledge"><%= policy.announcement? ? "—" : policy.outstanding_for.count %></td>
21
+ </tr>
22
+ <% end %>
23
+ </tbody>
24
+ </table>
25
+ </div>
26
+ <% end %>
27
+ </section>
@@ -0,0 +1,22 @@
1
+ <% content_for(:page_title) { "Publish" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Publish a policy or announcement</h1>
4
+ <p class="hrl-page__sub">Every employee gets an email and a notification. Reusing a title publishes the next version and asks everyone again.</p>
5
+ </div>
6
+
7
+ <section class="hrl-card">
8
+ <%= form_with model: @policy, url: hr_lite.admin_policies_path, method: :post, local: true do |f| %>
9
+ <%= render "hr_lite/shared/form_errors", record: @policy %>
10
+ <div class="hrl-field"><%= f.label :title %><%= f.text_field :title, required: true %></div>
11
+ <div class="hrl-field"><%= f.label :body, "Text" %><%= f.text_area :body, rows: 10, required: true %></div>
12
+ <div class="hrl-field"><%= f.label :effective_from, "In force from" %><%= f.date_field :effective_from %></div>
13
+ <div class="hrl-field hrl-field--check">
14
+ <%= f.check_box :acknowledgement_required %>
15
+ <%= f.label :acknowledgement_required, "Policy — everyone must acknowledge it (untick for an announcement)" %>
16
+ </div>
17
+ <div class="hrl-form-actions">
18
+ <%= f.submit "Publish", class: "hrl-btn hrl-btn--primary" %>
19
+ <a class="hrl-btn" href="<%= hr_lite.admin_policies_path %>">Cancel</a>
20
+ </div>
21
+ <% end %>
22
+ </section>
@@ -0,0 +1,86 @@
1
+ <% content_for(:page_title) { "ID card" } %>
2
+ <% company = HrLite.config.company.call %>
3
+ <% brand = company[:brand].presence || company[:name].presence || "HR" %>
4
+ <% own = @user == hr_current_user %>
5
+ <div class="hrl-page__head">
6
+ <h1 class="hrl-page__title"><%= own ? "My ID card" : "ID card — #{hr_display_name(@user)}" %></h1>
7
+ </div>
8
+
9
+ <% if @profile.nil? %>
10
+ <p class="hrl-card hrl-card--empty"><%= own ? "Your HR profile has not been set up yet, so there is no card to show." : "No HR profile yet." %></p>
11
+ <% else %>
12
+ <% first, rest = hr_display_name(@user).to_s.split(" ", 2) %>
13
+ <div class="hrl-idcard-faces">
14
+ <div class="hrl-idcard-face">
15
+ <span class="hrl-idcard__label">FRONT</span>
16
+ <div class="hrl-idcard">
17
+ <div class="hrl-idcard__band">
18
+ <span class="hrl-idcard__slot"></span>
19
+ <span class="hrl-idcard__logo">
20
+ <% if company[:logo_url].present? %><img src="<%= company[:logo_url] %>" alt="<%= brand %>"><% else %><%= brand %><% end %>
21
+ </span>
22
+ <span class="hrl-idcard__kicker">EMPLOYEE IDENTITY CARD</span>
23
+ </div>
24
+ <div class="hrl-idcard__photo">
25
+ <% if @profile.photo.attached? %>
26
+ <img src="<%= Rails.application.routes.url_helpers.rails_blob_path(@profile.photo, disposition: "inline", only_path: true) %>" alt="<%= hr_display_name(@user) %>">
27
+ <% else %>
28
+ <span><%= hr_display_name(@user).to_s.scan(/\b[[:alpha:]]/).first(2).join.upcase %></span>
29
+ <% end %>
30
+ </div>
31
+ <div class="hrl-idcard__who">
32
+ <div class="hrl-idcard__name"><span class="hrl-idcard__green"><%= first %></span> <span class="hrl-idcard__blue"><%= rest %></span></div>
33
+ <div class="hrl-idcard__role"><%= @profile.designation %></div>
34
+ </div>
35
+ <dl class="hrl-idcard__facts">
36
+ <div><dt>EMPLOYEE ID</dt><dd><%= @profile.employee_code %></dd></div>
37
+ <div><dt>DEPARTMENT</dt><dd><%= @profile.department.presence || "—" %></dd></div>
38
+ <div><dt>BLOOD GROUP</dt><dd class="hrl-idcard__red"><%= @profile.blood_group.presence || "—" %></dd></div>
39
+ <div><dt>JOINED</dt><dd><%= @profile.date_of_joining.strftime("%b %Y") %></dd></div>
40
+ </dl>
41
+ <div class="hrl-idcard__strip"><span></span><span></span><span></span></div>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="hrl-idcard-face">
46
+ <span class="hrl-idcard__label">BACK</span>
47
+ <div class="hrl-idcard hrl-idcard--back">
48
+ <span class="hrl-idcard__slot hrl-idcard__slot--back"></span>
49
+ <div class="hrl-idcard__title"><span class="hrl-idcard__green">Scan to</span> <span class="hrl-idcard__blue">Verify</span></div>
50
+ <div class="hrl-idcard__qr"><%= hrl_office_qr_svg(company, brand) %></div>
51
+ <div class="hrl-idcard__code"><%= @profile.employee_code %></div>
52
+ <div class="hrl-idcard__rows">
53
+ <div><span>Emergency contact</span><b><%= @profile.emergency_contact.presence || "—" %></b></div>
54
+ <hr>
55
+ <div><span>Issued</span><b><%= @issued_on&.strftime("%b %Y") || "Not yet issued" %></b></div>
56
+ </div>
57
+ <p class="hrl-idcard__note">This card is the property of <%= brand %>. If found, please return it to our office<%= " or call #{company[:phone]}" if company[:phone].present? %>.</p>
58
+ <div class="hrl-idcard__foot"><b><%= brand %></b><span>AUTHORISED SIGNATORY</span></div>
59
+ </div>
60
+ </div>
61
+ </div>
62
+
63
+ <div class="hrl-idcard-actions">
64
+ <button type="button" class="hrl-btn hrl-btn--primary" data-hrl-print>Print</button>
65
+ <% if own && @print_requested %>
66
+ <p class="hrl-small hrl-muted">Printed card requested on <%= @print_requested.to_date.strftime("%d %b %Y") %>.</p>
67
+ <% elsif own %>
68
+ <%= button_to "Request printed card", hr_lite.hr_requests_path, class: "hrl-btn",
69
+ params: { hr_request: { category: "id_card", subject: "Please print my ID card" } } %>
70
+ <% end %>
71
+ <% if own %>
72
+ <%= form_with url: hr_lite.id_card_photo_path, method: :patch, multipart: true, class: "hrl-row" do |f| %>
73
+ <div class="hrl-field">
74
+ <label for="photo">Change photo</label>
75
+ <%= f.file_field :photo, id: "photo", accept: HrLite::EmployeeProfile::PHOTO_TYPES.join(","), required: true %>
76
+ <p class="hrl-hint">Face centred, plain background. JPG, PNG or WebP, under <%= HrLite::EmployeeProfile::PHOTO_MAX_BYTES / 1.megabyte %> MB.</p>
77
+ </div>
78
+ <%= f.submit "Upload photo", class: "hrl-btn" %>
79
+ <% end %>
80
+ <% end %>
81
+ </div>
82
+ <% end %>
83
+
84
+ <% content_for :scripts do %>
85
+ <%= javascript_include_tag "hr_lite/print", defer: true %>
86
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% if (bell = HrLite.config.notifications&.call(hr_current_user)) %>
2
+ <a class="hrl-bell" href="<%= bell[:url] %>">Notifications<% if bell[:unread].to_i.positive? %> <span class="hrl-bell__count"><%= bell[:unread] %></span><% end %></a>
3
+ <% end %>
@@ -56,6 +56,7 @@
56
56
 
57
57
  <div class="hrl-side__foot">
58
58
  <span class="hrl-side__user" title="<%= hr_current_user&.email %>"><%= hr_display_name(hr_current_user) %></span>
59
+ <%= render "hr_lite/shared/bell" %>
59
60
  <% if HrLite.config.back_link %>
60
61
  <a class="hrl-side__back" href="<%= HrLite.config.back_link[:url] %>"><%= HrLite.config.back_link[:label] %></a>
61
62
  <% end %>
@@ -71,6 +72,7 @@
71
72
  <%= company[:name].presence || "HR" %>
72
73
  <% end %>
73
74
  </a>
75
+ <%= render "hr_lite/shared/bell" %>
74
76
  <span class="hrl-appbar__user"><%= hr_display_name(hr_current_user) %></span>
75
77
  </header>
76
78
 
data/config/routes.rb CHANGED
@@ -43,6 +43,8 @@ HrLite::Engine.routes.draw do
43
43
  get "calendar", to: "calendar#show"
44
44
  resources :salary_slips, only: %i[index show]
45
45
  resource :employee_profile, only: :show, path: "profile"
46
+ patch "id_card/photo", to: "id_cards#photo", as: :id_card_photo
47
+ get "id_card(/:user_id)", to: "id_cards#show", as: :id_card
46
48
  resource :resignation, only: %i[show create], controller: "resignations" do
47
49
  post :withdraw
48
50
  end
@@ -50,9 +52,10 @@ HrLite::Engine.routes.draw do
50
52
  resources :appraisals, only: %i[index show]
51
53
 
52
54
  namespace :admin do
55
+ resources :policies, only: %i[index new create]
53
56
  get "overview", to: "overview#index"
54
57
  resources :attendances, only: %i[index show update], param: :user_id
55
- resources :leave_requests, only: %i[index show] do
58
+ resources :leave_requests, only: %i[index show new create] do
56
59
  member { post :approve; post :reject; post :cancel }
57
60
  end
58
61
  resources :leave_balances, only: :index do
@@ -0,0 +1,6 @@
1
+ class AddIdCardFieldsToHrLiteEmployeeProfiles < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hr_lite_employee_profiles, :blood_group, :text
4
+ add_column :hr_lite_employee_profiles, :emergency_contact, :text
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddCreatedByIdToHrLiteLeaveRequests < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hr_lite_leave_requests, :created_by_id, :integer, limit: 8
4
+ end
5
+ end
@@ -9,7 +9,10 @@ module HrLite
9
9
  :time_zone, :currency_symbol, :on_designation_change,
10
10
  :leadership_emails, :extra_stylesheets, :superadmin_emails,
11
11
  :mailer_from, :public_url_base, :notification_matrix, :back_link,
12
- :onboard_user, :offboard_user, :invite_url_for
12
+ :onboard_user, :offboard_user, :invite_url_for,
13
+ # ->(user) { { url:, unread: } } — shows a notifications link in
14
+ # the HR shell; nil (default) shows nothing.
15
+ :notifications
13
16
 
14
17
  attr_reader :leave_year_start_month
15
18
 
@@ -12,7 +12,7 @@ module HrLite
12
12
  # the matrix is purely the on/off routing table.
13
13
  module Notifications
14
14
  DEFAULT_MATRIX = {
15
- "leave.requested" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
15
+ "leave.requested" => { bell: true, email: true, leadership_email: true, leadership_bell: true },
16
16
  "leave.approved" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
17
17
  "leave.rejected" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
18
18
  "leave.cancelled" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
@@ -38,17 +38,18 @@ module HrLite
38
38
  "expense.approved" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
39
39
  "expense.rejected" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
40
40
  "expense.reimbursed" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
41
- "hr_request.raised" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
41
+ "hr_request.raised" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
42
42
  "hr_request.assigned" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
43
43
  "hr_request.resolved" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
44
44
  "policy.published" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
45
+ "holiday.published" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
45
46
  "digest.daily" => { bell: false, email: false, leadership_email: true, leadership_bell: false },
46
47
  "resignation.submitted" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
47
48
  "resignation.accepted" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
48
49
  "resignation.withdrawn" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
49
50
  "employee.onboarded" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
50
51
  "payroll.draft_ready" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
51
- "leave.team_notice" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
52
+ "leave.team_notice" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
52
53
  "comp_off.requested" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
53
54
  "comp_off.approved" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
54
55
  "comp_off.rejected" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.15.0"
2
+ VERSION = "0.16.0"
3
3
  end
data/lib/hr_lite.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "hr_lite/version"
2
2
  require "hr_lite/engine"
3
+ require "rqrcode"
3
4
  require "hr_lite/configuration"
4
5
  require "hr_lite/current"
5
6
  require "hr_lite/leave_year"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rqrcode
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
40
54
  description: Mountable Rails engine providing attendance with geolocation, leave management,
41
55
  holiday calendar, Indian payroll (PF/ESI/PT/TDS) with salary-slip PDFs, kudos with
42
56
  @mentions, appraisals and promotions — integrated into a host app via config hooks.
@@ -53,6 +67,7 @@ files:
53
67
  - app/assets/javascripts/hr_lite/confirm.js
54
68
  - app/assets/javascripts/hr_lite/geo_punch.js
55
69
  - app/assets/javascripts/hr_lite/mention.js
70
+ - app/assets/javascripts/hr_lite/print.js
56
71
  - app/assets/stylesheets/hr_lite/application.css
57
72
  - app/assets/stylesheets/hr_lite/hr_lite.css
58
73
  - app/controllers/hr_lite/admin/appraisals_controller.rb
@@ -76,6 +91,7 @@ files:
76
91
  - app/controllers/hr_lite/admin/office_locations_controller.rb
77
92
  - app/controllers/hr_lite/admin/overview_controller.rb
78
93
  - app/controllers/hr_lite/admin/payroll_runs_controller.rb
94
+ - app/controllers/hr_lite/admin/policies_controller.rb
79
95
  - app/controllers/hr_lite/admin/regularization_requests_controller.rb
80
96
  - app/controllers/hr_lite/admin/reports_controller.rb
81
97
  - app/controllers/hr_lite/admin/resignations_controller.rb
@@ -101,6 +117,7 @@ files:
101
117
  - app/controllers/hr_lite/holidays_controller.rb
102
118
  - app/controllers/hr_lite/home_controller.rb
103
119
  - app/controllers/hr_lite/hr_requests_controller.rb
120
+ - app/controllers/hr_lite/id_cards_controller.rb
104
121
  - app/controllers/hr_lite/kudos_controller.rb
105
122
  - app/controllers/hr_lite/leave_balances_controller.rb
106
123
  - app/controllers/hr_lite/leave_requests_controller.rb
@@ -214,6 +231,7 @@ files:
214
231
  - app/views/hr_lite/admin/hr_requests/show.html.erb
215
232
  - app/views/hr_lite/admin/leave_balances/index.html.erb
216
233
  - app/views/hr_lite/admin/leave_requests/index.html.erb
234
+ - app/views/hr_lite/admin/leave_requests/new.html.erb
217
235
  - app/views/hr_lite/admin/leave_requests/show.html.erb
218
236
  - app/views/hr_lite/admin/leave_types/_form.html.erb
219
237
  - app/views/hr_lite/admin/leave_types/edit.html.erb
@@ -230,6 +248,8 @@ files:
230
248
  - app/views/hr_lite/admin/payroll_runs/index.html.erb
231
249
  - app/views/hr_lite/admin/payroll_runs/new.html.erb
232
250
  - app/views/hr_lite/admin/payroll_runs/show.html.erb
251
+ - app/views/hr_lite/admin/policies/index.html.erb
252
+ - app/views/hr_lite/admin/policies/new.html.erb
233
253
  - app/views/hr_lite/admin/regularization_requests/index.html.erb
234
254
  - app/views/hr_lite/admin/regularization_requests/show.html.erb
235
255
  - app/views/hr_lite/admin/reports/index.html.erb
@@ -275,6 +295,7 @@ files:
275
295
  - app/views/hr_lite/hr_requests/index.html.erb
276
296
  - app/views/hr_lite/hr_requests/new.html.erb
277
297
  - app/views/hr_lite/hr_requests/show.html.erb
298
+ - app/views/hr_lite/id_cards/show.html.erb
278
299
  - app/views/hr_lite/kudos/_kudo.html.erb
279
300
  - app/views/hr_lite/kudos/index.html.erb
280
301
  - app/views/hr_lite/leave_balances/index.html.erb
@@ -294,6 +315,7 @@ files:
294
315
  - app/views/hr_lite/salary_slips/index.html.erb
295
316
  - app/views/hr_lite/salary_slips/pdf.html.erb
296
317
  - app/views/hr_lite/salary_slips/show.html.erb
318
+ - app/views/hr_lite/shared/_bell.html.erb
297
319
  - app/views/hr_lite/shared/_form_errors.html.erb
298
320
  - app/views/hr_lite/shared/_pagination.html.erb
299
321
  - app/views/hr_lite/tax_declarations/show.html.erb
@@ -337,6 +359,8 @@ files:
337
359
  - db/migrate/20260818003616_create_hr_lite_documents_and_declarations.rb
338
360
  - db/migrate/20260818004707_create_hr_lite_employee_services.rb
339
361
  - db/migrate/20260818010401_create_hr_lite_assets_and_onboarding.rb
362
+ - db/migrate/20260927100145_add_id_card_fields_to_hr_lite_employee_profiles.rb
363
+ - db/migrate/20260927101031_add_created_by_id_to_hr_lite_leave_requests.rb
340
364
  - lib/generators/hr_lite/install/install_generator.rb
341
365
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
342
366
  - lib/generators/hr_lite/install/templates/initializer.rb