hr_lite 0.53.0 → 0.55.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +5 -2
  4. data/app/assets/javascripts/hr_lite/confirm.js +9 -5
  5. data/app/assets/javascripts/hr_lite/countdown.js +9 -2
  6. data/app/assets/javascripts/hr_lite/emoji_pick.js +4 -4
  7. data/app/assets/javascripts/hr_lite/search.js +1 -1
  8. data/app/assets/stylesheets/hr_lite/hr_lite.css +24 -14
  9. data/app/controllers/hr_lite/admin/attendances_controller.rb +12 -3
  10. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +1 -0
  11. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +1 -1
  12. data/app/controllers/hr_lite/admin/documents_controller.rb +1 -1
  13. data/app/controllers/hr_lite/admin/employees_controller.rb +11 -4
  14. data/app/controllers/hr_lite/admin/expenses_controller.rb +17 -1
  15. data/app/controllers/hr_lite/admin/hr_requests_controller.rb +4 -0
  16. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +5 -2
  17. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +9 -5
  18. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +1 -1
  19. data/app/controllers/hr_lite/admin/reports_controller.rb +3 -2
  20. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +2 -0
  21. data/app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb +2 -1
  22. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +15 -4
  23. data/app/controllers/hr_lite/admin/work_locations_controller.rb +1 -1
  24. data/app/controllers/hr_lite/approvals_controller.rb +1 -0
  25. data/app/controllers/hr_lite/home_controller.rb +2 -11
  26. data/app/controllers/hr_lite/loans_controller.rb +1 -1
  27. data/app/controllers/hr_lite/regularization_requests_controller.rb +4 -4
  28. data/app/controllers/hr_lite/tax_declarations_controller.rb +9 -5
  29. data/app/helpers/hr_lite/application_helper.rb +34 -9
  30. data/app/jobs/hr_lite/attendance_close_job.rb +1 -1
  31. data/app/jobs/hr_lite/payroll_compute_job.rb +12 -0
  32. data/app/models/concerns/hr_lite/audited.rb +3 -1
  33. data/app/models/concerns/hr_lite/encrypted_money.rb +8 -5
  34. data/app/models/hr_lite/attendance_record.rb +21 -4
  35. data/app/models/hr_lite/comp_off_request.rb +2 -0
  36. data/app/models/hr_lite/document.rb +1 -1
  37. data/app/models/hr_lite/expense.rb +3 -0
  38. data/app/models/hr_lite/hr_request.rb +2 -0
  39. data/app/models/hr_lite/kudo.rb +2 -2
  40. data/app/models/hr_lite/leave_request.rb +25 -6
  41. data/app/models/hr_lite/payroll_line_item.rb +8 -0
  42. data/app/models/hr_lite/payroll_run.rb +15 -6
  43. data/app/models/hr_lite/regularization_request.rb +2 -2
  44. data/app/models/hr_lite/resignation.rb +5 -1
  45. data/app/models/hr_lite/role.rb +5 -0
  46. data/app/models/hr_lite/salary_slip.rb +2 -1
  47. data/app/models/hr_lite/statutory_rate_card_record.rb +6 -0
  48. data/app/models/hr_lite/tax_declaration.rb +5 -0
  49. data/app/services/hr_lite/attendance_puncher.rb +4 -0
  50. data/app/services/hr_lite/overview_query.rb +0 -12
  51. data/app/services/hr_lite/work_status.rb +5 -2
  52. data/app/views/hr_lite/admin/assets/index.html.erb +9 -2
  53. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -4
  54. data/app/views/hr_lite/admin/attendances/show.html.erb +4 -2
  55. data/app/views/hr_lite/admin/attendances/summary.html.erb +5 -2
  56. data/app/views/hr_lite/admin/audit_logs/index.html.erb +6 -4
  57. data/app/views/hr_lite/admin/benefits/index.html.erb +10 -4
  58. data/app/views/hr_lite/admin/ca_reviews/index.html.erb +3 -3
  59. data/app/views/hr_lite/admin/checklists/index.html.erb +3 -3
  60. data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +1 -2
  61. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +6 -6
  62. data/app/views/hr_lite/admin/designation_changes/new.html.erb +4 -3
  63. data/app/views/hr_lite/admin/documents/index.html.erb +9 -4
  64. data/app/views/hr_lite/admin/documents/show.html.erb +1 -0
  65. data/app/views/hr_lite/admin/employees/_form.html.erb +12 -5
  66. data/app/views/hr_lite/admin/employees/show.html.erb +40 -33
  67. data/app/views/hr_lite/admin/expense_categories/index.html.erb +1 -1
  68. data/app/views/hr_lite/admin/expenses/index.html.erb +7 -4
  69. data/app/views/hr_lite/admin/holidays/index.html.erb +7 -5
  70. data/app/views/hr_lite/admin/hr_requests/index.html.erb +3 -3
  71. data/app/views/hr_lite/admin/hr_requests/show.html.erb +5 -4
  72. data/app/views/hr_lite/admin/leave_balances/index.html.erb +6 -4
  73. data/app/views/hr_lite/admin/leave_requests/index.html.erb +1 -2
  74. data/app/views/hr_lite/admin/leave_requests/show.html.erb +6 -4
  75. data/app/views/hr_lite/admin/leave_types/edit.html.erb +4 -4
  76. data/app/views/hr_lite/admin/leave_types/index.html.erb +5 -5
  77. data/app/views/hr_lite/admin/loans/index.html.erb +1 -1
  78. data/app/views/hr_lite/admin/loans/new.html.erb +1 -1
  79. data/app/views/hr_lite/admin/loans/show.html.erb +8 -12
  80. data/app/views/hr_lite/admin/office_locations/edit.html.erb +4 -4
  81. data/app/views/hr_lite/admin/office_locations/index.html.erb +1 -1
  82. data/app/views/hr_lite/admin/overview/index.html.erb +10 -31
  83. data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +2 -1
  84. data/app/views/hr_lite/admin/payroll_runs/index.html.erb +3 -3
  85. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +15 -10
  86. data/app/views/hr_lite/admin/performance_plans/_form.html.erb +2 -1
  87. data/app/views/hr_lite/admin/policies/index.html.erb +2 -2
  88. data/app/views/hr_lite/admin/policies/new.html.erb +4 -3
  89. data/app/views/hr_lite/admin/regularization_requests/index.html.erb +3 -4
  90. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +5 -6
  91. data/app/views/hr_lite/admin/reports/index.html.erb +3 -6
  92. data/app/views/hr_lite/admin/reports/show.html.erb +16 -2
  93. data/app/views/hr_lite/admin/roles/_form.html.erb +8 -6
  94. data/app/views/hr_lite/admin/roles/edit.html.erb +4 -2
  95. data/app/views/hr_lite/admin/roles/index.html.erb +1 -1
  96. data/app/views/hr_lite/admin/salary_slips/show.html.erb +3 -2
  97. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +1 -0
  98. data/app/views/hr_lite/admin/settings/edit.html.erb +5 -1
  99. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +14 -10
  100. data/app/views/hr_lite/admin/statutory_rate_cards/index.html.erb +3 -1
  101. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +1 -1
  102. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +8 -4
  103. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +17 -16
  104. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +3 -5
  105. data/app/views/hr_lite/admin/work_locations/index.html.erb +1 -1
  106. data/app/views/hr_lite/admin/work_modes/index.html.erb +1 -1
  107. data/app/views/hr_lite/approvals/index.html.erb +5 -4
  108. data/app/views/hr_lite/attendance/_custom_status.html.erb +7 -9
  109. data/app/views/hr_lite/attendance/_month_grid.html.erb +9 -9
  110. data/app/views/hr_lite/attendance/_penalties.html.erb +2 -2
  111. data/app/views/hr_lite/attendance/_punch_card.html.erb +10 -4
  112. data/app/views/hr_lite/attendance/_slot.html.erb +9 -6
  113. data/app/views/hr_lite/attendance/_trail.html.erb +1 -1
  114. data/app/views/hr_lite/attendance/_work_mode.html.erb +1 -1
  115. data/app/views/hr_lite/attendance/show.html.erb +7 -5
  116. data/app/views/hr_lite/calendar/show.html.erb +2 -2
  117. data/app/views/hr_lite/comp_off_requests/index.html.erb +1 -1
  118. data/app/views/hr_lite/comp_off_requests/new.html.erb +1 -1
  119. data/app/views/hr_lite/documents/_upload_form.html.erb +1 -1
  120. data/app/views/hr_lite/employee_profiles/show.html.erb +4 -2
  121. data/app/views/hr_lite/home/index.html.erb +6 -5
  122. data/app/views/hr_lite/id_cards/show.html.erb +5 -1
  123. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +3 -2
  124. data/app/views/hr_lite/leave_requests/new.html.erb +17 -8
  125. data/app/views/hr_lite/leave_requests/show.html.erb +2 -0
  126. data/app/views/hr_lite/loans/index.html.erb +1 -1
  127. data/app/views/hr_lite/org/show.html.erb +1 -1
  128. data/app/views/hr_lite/regularization_requests/index.html.erb +7 -7
  129. data/app/views/hr_lite/regularization_requests/new.html.erb +7 -5
  130. data/app/views/hr_lite/resignations/show.html.erb +2 -1
  131. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +7 -7
  132. data/app/views/hr_lite/salary_slips/pdf.html.erb +2 -1
  133. data/app/views/hr_lite/tax/_computation.html.erb +1 -1
  134. data/app/views/hr_lite/tax_declarations/show.html.erb +4 -4
  135. data/app/views/hr_lite/team/show.html.erb +8 -7
  136. data/app/views/hr_lite/timelines/show.html.erb +1 -0
  137. data/app/views/hr_lite/wfh_requests/index.html.erb +3 -3
  138. data/app/views/hr_lite/work_time/_bars.html.erb +5 -4
  139. data/app/views/hr_lite/work_time/_donut.html.erb +1 -1
  140. data/app/views/hr_lite/work_time/_report.html.erb +14 -8
  141. data/app/views/layouts/hr_lite/application.html.erb +1 -0
  142. data/app/views/layouts/hr_lite/mailer.html.erb +10 -2
  143. data/app/views/layouts/hr_lite/mailer.text.erb +4 -1
  144. data/config/routes.rb +1 -1
  145. data/lib/hr_lite/amount_in_words.rb +7 -5
  146. data/lib/hr_lite/notifications.rb +5 -2
  147. data/lib/hr_lite/statutory_seeds.rb +1 -1
  148. data/lib/hr_lite/version.rb +1 -1
  149. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21dc6e39c0cac6008a3b99d4fe74a6d1b81a335e5fc58fe615f1cb4a98dfd32f
4
- data.tar.gz: 715135ae1cda08faafe28049aa51c0cac73ae4e0a6c45aeefc1889417bc5224c
3
+ metadata.gz: 901c6516adf77c589f4e570c477d7aa2a7b05f279e5ef9d205cf868796074939
4
+ data.tar.gz: d3a6e968a40f6e5fe9d8503a174f8ce004c18ee6cf25218372e21a78d5fecc76
5
5
  SHA512:
6
- metadata.gz: bfb1bf34635704206cc890a4acee68684c7033b3d5dbbda67fa1b4c44b3c377e05f2f74271a30ad7174cc221d0d7bbed4d6afb244133082d64945f3066c8018f
7
- data.tar.gz: 1f8e31b31cad07625f8540f4ed3f8e6dd3ada6e4415c91c96d34eb86d899a056d3ee9668b2cc78f71ee4d6367dc22d42de8641112b5e50f422a054ad302a6d81
6
+ metadata.gz: 43c25d542b2eacd2af96e5ff8b281f608fb202be4c1c8ecdd4790b440843fb79ceb32596e8d641bb72beffc8c606dd558d65cd0bba5fbc4a860a8a59e5f24790
7
+ data.tar.gz: 2e1af9569c33bce9ca0ea5ef790b855d397e6ebf6c88a656757001849f0d197ee8901c057d19de835d999d80005dab3fd6e505ef26829167d3be7f9a101c834c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.55.0] - 2026-10-17
11
+
12
+ The second pass of the HRMS audit of 0.54.0 covers bugs, security, performance and the remaining screen fixes (#115–#131). No migrations.
13
+
14
+ - **Payroll compute runs in a background job** (`HrLite::PayrollComputeJob`, default queue).
15
+ - The run page shows "Computing…" and refreshes until the run reaches review.
16
+ - A failed compute leaves the run as it was and adds a run warning, not an error page.
17
+ - Hosts need a running ActiveJob backend.
18
+ - **Money**
19
+ - A run computed before a leave decision, a day fix or a one-off item change now needs a recompute before it can be approved or published.
20
+ - "Record and verify" refuses blank verified amounts.
21
+ - Once an employee submits a tax declaration, they can no longer edit it.
22
+ - HR's verified amounts need a submitted declaration, and each change writes an audit row.
23
+ - Claims and help-desk tickets refuse decisions in the wrong state.
24
+ - "1,200" or "1,50,000" in a money field saves instead of failing.
25
+ - An employee can open only the current or the previous financial year's declaration.
26
+ - The tax panel labels its gross "before unpaid days".
27
+ - **Leave and attendance**
28
+ - Deciding a leave or a claim outside its approval flow settles the flow's other rows.
29
+ - The late ladder skips weekly offs, holidays and half-day leave.
30
+ - HR attendance fixes refuse future dates, times that fall on another day, and approved full-day leave.
31
+ - Optional holidays are no longer shaded as days off.
32
+ - A request warns when it and the other pending ones go past the balance.
33
+ - First-half and second-half leave on the same date no longer clash.
34
+ - The approver sees the balance as of the leave date.
35
+ - Inbox › All lists a flow leave once.
36
+ - The company-wide leave notice no longer names the leave type.
37
+ - Submit buttons lock after the first press.
38
+ - **Security and audit**
39
+ - HR cannot edit their own profile or salary, or offboard themselves. The pages return 404 and the buttons are hidden.
40
+ - Tax-proof links expire after 5 minutes and ID-card photo links after an hour.
41
+ - These now write audit rows: role permission changes, comp-off approvals, resignations, and payroll run create and delete.
42
+ - Audit rows redact date of birth and help-desk text.
43
+ - "Aadhaar" and "PAN" uploads reach the money tier whatever their case.
44
+ - Kudos mentions reach employees only.
45
+ - Offboarding removes the person's HR role assignments.
46
+ - **Inbox**
47
+ - Your own requests on a person's week open your own pages.
48
+ - Claim rows name the type, amount and date.
49
+ - Pending resignations are an Inbox queue and count in "N requests waiting".
50
+ - "One-off items" shows only to those who can run payroll.
51
+ - **Screens**
52
+ - Leave forms say what is wrong, and decision pages ask before acting.
53
+ - Payslips, tax working, tax states and loan facts read in words and whole days.
54
+ - Admin settings, roles (permission groups fold), policies, holidays and the audit trail fit a phone. Page views are hidden from the audit trail by default.
55
+ - "Fix a day" is the one name for attendance fixes.
56
+ - Compute leads a draft run, and the rate card in force is marked.
57
+ - Home shows this week's leave from today, and reports have month arrows.
58
+ - Person pages link back, and the employee form is grouped.
59
+ - People › "My team" is "Team board".
60
+ - Inbox › WFH puts Give WFH before a folded filter.
61
+ - Search has a visible Close.
62
+ - Button borders meet 3:1 contrast, and the sides clear the notch.
63
+ - List links are 44 px buttons, the help-desk pick starts blank, and work-time bars sit further apart.
64
+ - **Performance**
65
+ - Leave-balance grids, the report and the chips count each balance once.
66
+ - A leave notice reads every recipient's do-not-disturb in one query.
67
+ - A person's month, the work-time report and the payroll register each load in one query instead of one per day or slip.
68
+ - The overview drops an unused count.
69
+
70
+ ## [0.54.0] - 2026-10-17
71
+
72
+ Fixes from the HRMS UI/UX audit of 0.52.0. No migrations.
73
+
74
+ - Approvals: Home, the HR overview and the Inbox badge show one count. Decision lists leave out your own requests, which used to open a blank 404. "Review requests" opens Inbox › All, and each row there names the request ("Sick leave, 30 Oct (half day) · 0.5 days") and opens it. Home locations opens on Pending. Every reject reason is required before the form submits.
75
+ - Confirms before one-way actions: resigning, accepting a resignation, saving a PIP, giving someone a role (the picker now starts blank and filters by name), changing a role's permissions, publishing a policy ("Publish and notify 4 people by email?"), recomputing a run that has an approval, approving past blocking warnings, ending health cover, and accepting all tax claims (names the claims without proof).
76
+ - Money: payroll run totals no longer wrap mid-number on a phone. "Employer cost" reads "Employer contributions". The payslip spells the paise its figure shows. The earnings table stacks on phones. Tax-working amounts stay on one line. My loans totals active loans only. Leave chips show days pending.
77
+ - Phones: tab-row links, list Edit/Open links and the fix-a-punch links are 44px targets. Work-time chart labels are readable at any range. The tax-slab editor is a labelled three-column grid. The bulk holiday box has a label. Employee pickers on pay items, loans and benefits start blank, are required and filter by name.
78
+ - Attendance: the check-in card says "Check in by 10:05" and what lateness costs. A day counts its penalty minutes when deciding whether it was short, so the card no longer says "day done" under "short by 20m". The status picker offers word quick picks instead of emoji. Team attendance shows leave, holidays and weekly offs instead of "No punch".
79
+ - Tax and rate cards: "Record and verify" saves the amounts on screen before verifying. A new financial year defaults to 1 April. Shipped rate cards still count as verified, but the sign-off reads "Shipped defaults, not checked by a person".
80
+ - Admin: approvers can open a claim's receipt (permission-checked, audited, 5-minute link). Working-day settings have their own HR settings tab. New run shows only to those who can run payroll.
81
+ - Branding: HR emails show `company[:email_logo_url]` as an image and a "Questions:" line from `phone`, `email` and `website`. The slip PDF prints the same contact line, and its cache key is versioned so cached slips are re-rendered. Hosts pass the new keys in `config.company`; without `email_logo_url` an email has no logo image, as before.
82
+
10
83
  ## [0.53.0] - 2026-10-16
11
84
 
12
85
  - HR runs inside the host's iOS and Android staff apps: pages opened by a Hotwire Native shell load Turbo (vendored 8.0.23) with `turbo-visit-control: reload`, so every visit is a full page load and the engine's plain scripts run exactly as in a browser. Browsers still get no Turbo. `config.native_app = ->(request) { … }` lets the host say which requests are its app; the default matches the Hotwire Native user agent.
data/README.md CHANGED
@@ -132,9 +132,12 @@ HrLite.configure do |c|
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.
135
+ # brand, address and phone as plain text. Emails print email_logo_url (an
136
+ # absolute https URL: mail clients drop data: URIs) and, with the slip PDF,
137
+ # a contact line from phone, email and website.
136
138
  c.company = -> { { name: "Acme", brand: "acme.in", address: "Head office address",
137
- phone: "+91 00000 00000", logo_url: nil } }
139
+ phone: "+91 00000 00000", email: "hr@acme.in", website: "acme.in",
140
+ logo_url: nil, email_logo_url: "https://acme.in/logo.png" } }
138
141
 
139
142
  # Salary-slip PDFs: plug your renderer, or add wicked_pdf to the bundle and
140
143
  # the built-in renderer takes over. With neither, PDF export is disabled.
@@ -26,11 +26,15 @@
26
26
  return;
27
27
  }
28
28
 
29
- // Double-submit guard (data-disable-with is inert without Turbo/ujs). The
30
- // timeout lets the submitter's value post first; geo punch runs its own.
31
- if (!form.hasAttribute("data-hrl-geo-punch") && event.submitter) {
32
- var b = event.submitter;
33
- setTimeout(function () { b.disabled = true; b.setAttribute("aria-busy", "true"); }, 0);
29
+ // Double-submit guard (data-disable-with is inert without Turbo/ujs): every
30
+ // submit button of the form, Enter included. The timeout lets the
31
+ // submitter's value post first; geo punch runs its own.
32
+ if (!form.hasAttribute("data-hrl-geo-punch")) {
33
+ setTimeout(function () {
34
+ Array.prototype.forEach.call(form.elements, function (b) {
35
+ if (b.type === "submit") { b.disabled = true; b.setAttribute("aria-busy", "true"); }
36
+ });
37
+ }, 0);
34
38
  }
35
39
  },
36
40
  true // capture, so the check runs before any other submit handler
@@ -1,12 +1,19 @@
1
- // Ticks "Xh YYm left" on [data-hrl-countdown="<iso time>"]. Display only:
1
+ // Ticks "Xh YYm left" on [data-hrl-countdown="<iso time>"] (data-hrl-done when
2
+ // it runs out) and "Xh YYm so far" on [data-hrl-since]. Display only:
2
3
  // the server ends the mode itself, with or without this page open.
3
4
  (function () {
4
5
  "use strict";
6
+ function hm(min) { return Math.floor(min / 60) + "h " + String(min % 60).padStart(2, "0") + "m"; }
5
7
  function tick() {
6
8
  document.querySelectorAll("[data-hrl-countdown]").forEach(function (el) {
7
9
  var left = Math.max(0, Math.round((Date.parse(el.dataset.hrlCountdown) - Date.now()) / 60000));
8
- el.textContent = left === 0 ? "ended" : Math.floor(left / 60) + "h " + String(left % 60).padStart(2, "0") + "m left";
10
+ el.textContent = left === 0 ? (el.dataset.hrlDone || "ended") : hm(left) + " left";
11
+ });
12
+ document.querySelectorAll("[data-hrl-since]").forEach(function (el) {
13
+ el.textContent = hm(Math.max(0, Math.floor((Date.now() - Date.parse(el.dataset.hrlSince)) / 60000))) + " so far";
9
14
  });
10
15
  }
11
16
  document.addEventListener("DOMContentLoaded", function () { tick(); setInterval(tick, 30000); });
17
+ // A PWA brought back from the background repaints at once, not on the next tick.
18
+ document.addEventListener("visibilitychange", function () { if (!document.hidden) tick(); });
12
19
  })();
@@ -1,11 +1,11 @@
1
- // Emoji grid for the status form: a tap puts that emoji in the input.
1
+ // Quick picks for the status form: a tap puts that status in the text box.
2
2
  (function () {
3
3
  "use strict";
4
4
  document.addEventListener("click", function (e) {
5
- var b = e.target.closest && e.target.closest("[data-hrl-emoji]");
5
+ var b = e.target.closest && e.target.closest("[data-hrl-pick]");
6
6
  if (!b) return;
7
- var input = b.closest("form").querySelector("input[name=emoji]");
8
- input.value = b.dataset.hrlEmoji;
7
+ var input = b.closest("form").querySelector("input[name=text]");
8
+ input.value = b.dataset.hrlPick;
9
9
  input.focus();
10
10
  });
11
11
  })();
@@ -5,7 +5,7 @@
5
5
  (function () {
6
6
  var dialog = document.querySelector("[data-hrl-search]");
7
7
  if (!dialog || !dialog.showModal) return;
8
- var form = dialog.querySelector("form");
8
+ var form = dialog.querySelector("form[role=search]");
9
9
  var input = form.querySelector("input");
10
10
  var out = dialog.querySelector("[data-hrl-search-results]");
11
11
  var timer;
@@ -101,8 +101,9 @@ html:has(.hrl-body) { scroll-padding-top: 4rem; scroll-padding-bottom: calc(4.4r
101
101
  }
102
102
  .hrl-shell { display: flex; min-height: 100vh; }
103
103
  .hrl-content { flex: 1; min-width: 0; }
104
+ /* Landscape notch: never closer to the cutout than the gutter. */
104
105
  .hrl-main {
105
- padding: .85rem;
106
+ padding: .85rem max(.85rem, env(safe-area-inset-right)) .85rem max(.85rem, env(safe-area-inset-left));
106
107
  padding-bottom: calc(4.4rem + env(safe-area-inset-bottom));
107
108
  max-width: 68rem;
108
109
  margin: 0 auto;
@@ -124,7 +125,7 @@ html:has(.hrl-body) { scroll-padding-top: 4rem; scroll-padding-bottom: calc(4.4r
124
125
  .hrl-nav__link--active .hrl-nav__hint { color: inherit; }
125
126
  /* Tab row: the current place's tabs on one line; it scrolls sideways, never the page. */
126
127
  .hrl-tabrow { display: flex; gap: var(--hrl-gap-target); overflow-x: auto; margin: 0 0 var(--hrl-s-4); padding-bottom: var(--hrl-s-2); border-bottom: 1px solid var(--hrl-line); }
127
- .hrl-tabrow .hrl-nav__link { flex: 0 0 auto; white-space: nowrap; }
128
+ .hrl-tabrow .hrl-nav__link { flex: 0 0 auto; white-space: nowrap; min-width: var(--hrl-target); justify-content: center; }
128
129
  .hrl-side__divider { height: 1px; background: var(--hrl-line); margin: var(--hrl-s-2) var(--hrl-s-1); }
129
130
 
130
131
  /* Mobile app bar */
@@ -242,6 +243,7 @@ html:has(.hrl-body) { scroll-padding-top: 4rem; scroll-padding-bottom: calc(4.4r
242
243
  .hrl-card--empty { text-align: center; color: var(--hrl-muted); padding: 1.8rem 1rem; }
243
244
  .hrl-card--warn { background: var(--hrl-warn-bg); border-color: transparent; }
244
245
  .hrl-card > p:first-child { margin-top: 0; }
246
+ .hrl-card > hr { border: 0; border-top: 1px solid var(--hrl-line); margin: var(--hrl-s-4) 0; }
245
247
  /* Vertical rhythm: forms, buttons and disclosures stacked in a card never
246
248
  touch (only .hrl-field carried spacing, so a button above a form, or an
247
249
  open disclosure above the next control, sat flush). 12px >= the 8px
@@ -281,12 +283,12 @@ details > form > button[type=submit], details > form > .hrl-btn { margin-top: .2
281
283
  .hrl-btn {
282
284
  display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
283
285
  min-height: var(--hrl-target); padding: .55rem 1rem; border-radius: var(--hrl-radius-sm);
284
- border: 1px solid var(--hrl-line); background: var(--hrl-card); color: var(--hrl-ink);
286
+ border: 1px solid var(--hrl-field); background: var(--hrl-card); color: var(--hrl-ink);
285
287
  font-size: var(--hrl-t-body); font-weight: 600; cursor: pointer; text-decoration: none;
286
288
  max-width: 100%; overflow-wrap: anywhere; text-align: center;
287
289
  }
288
290
  .hrl-btn--primary { background: var(--hrl-accent); border-color: var(--hrl-accent); color: var(--hrl-accent-ink); }
289
- .hrl-btn--danger { color: var(--hrl-bad); border-color: var(--hrl-bad-bg); }
291
+ .hrl-btn--danger { color: var(--hrl-bad); border-color: var(--hrl-bad); }
290
292
  .hrl-btn--block { width: 100%; }
291
293
  .hrl-btn--big { min-height: 58px; font-size: var(--hrl-t-lead); }
292
294
  .hrl-btn[disabled] { opacity: .55; cursor: default; }
@@ -319,6 +321,7 @@ summary.hrl-btn::-webkit-details-marker { display: none; } /* Safari before 18.4
319
321
  .hrl-table td[data-label=""] > :is(a, button) + :is(a, button, form) { margin-left: var(--hrl-s-2); }
320
322
  .hrl-table td[data-label=""] > :is(a, button) + form { display: inline-block; vertical-align: top; } /* a button_to beside a link, not under it */
321
323
  .hrl-table td[data-label=""] > form + form { margin-top: var(--hrl-s-2); }
324
+ .hrl-table td form > :is(.hrl-input, select) + :is(.hrl-btn, input[type="submit"]) { margin-top: var(--hrl-s-2); } /* a note above its button, never touching */
322
325
  .hrl-table tr:has(.hrl-btn) > td, .hrl-table tr:has(.hrl-btn) td > .hrl-badge { vertical-align: middle; } /* text lines up with a 44px button */
323
326
  /* Stack on phones and on the narrow content column beside the 768-1023px rail. */
324
327
  @media (max-width: 640px), (min-width: 768px) and (max-width: 1023px) {
@@ -338,6 +341,7 @@ summary.hrl-btn::-webkit-details-marker { display: none; } /* Safari before 18.4
338
341
 
339
342
  /* Forms */
340
343
  .hrl-field { margin-bottom: .8rem; }
344
+ @media (min-width: 1024px) { .hrl-field { max-width: 40rem; } }
341
345
  .hrl-field label, .hrl-field legend { display: block; font-size: var(--hrl-t-small); font-weight: 600; margin-bottom: .3rem; }
342
346
  fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
343
347
  .hrl-hint { color: var(--hrl-muted); font-size: var(--hrl-t-small); margin-top: .25rem; }
@@ -366,7 +370,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
366
370
  .hrl-field--check label .hrl-hint { display: block; margin-top: 0; font-weight: 400; }
367
371
  /* Rails wraps an invalid label/field in this div; contents keeps the stack, the re-pointed token reddens the border. */
368
372
  .field_with_errors { display: contents; --hrl-field: var(--hrl-bad); }
369
- .field_with_errors label { color: var(--hrl-bad); }
373
+ .field_with_errors label, .hrl-hint--bad { color: var(--hrl-bad); }
370
374
  .hrl-errors { background: var(--hrl-bad-bg); color: var(--hrl-bad); border-radius: var(--hrl-radius-sm); padding: .7rem .9rem; margin-bottom: .85rem; font-size: var(--hrl-t-body); }
371
375
  .hrl-errors ul { margin: .3rem 0 0; padding-left: 1.1rem; }
372
376
  .hrl-form-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 1rem; }
@@ -381,7 +385,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
381
385
  min-width: var(--hrl-target); justify-content: center;
382
386
  }
383
387
  .hrl-chiprow input { position: absolute; opacity: 0; pointer-events: none; }
384
- .hrl-chiprow input:checked + span, .hrl-chiprow label:has(input:checked) { background: var(--hrl-info-bg); border-color: var(--hrl-accent); color: var(--hrl-accent); }
388
+ .hrl-chiprow input:checked + span, .hrl-chiprow label:has(input:checked), .hrl-btn[aria-current="true"] { background: var(--hrl-info-bg); border-color: var(--hrl-accent); color: var(--hrl-accent); }
385
389
  /* The radio is invisible, so its focus ring goes on the chip. */
386
390
  .hrl-chiprow label:has(input:focus-visible) { outline: var(--hrl-focus-w) solid var(--hrl-focus); outline-offset: var(--hrl-focus-offset); }
387
391
 
@@ -415,12 +419,14 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
415
419
  .hrl-card--private .hrl-badge--muted { background: var(--hrl-card); }
416
420
 
417
421
  /* Tax: the two regimes side by side, then the working as a plain table. */
422
+ .hrl-slabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--hrl-gap-target); align-items: end; }
418
423
  .hrl-tax-compare { display: grid; grid-template-columns: 1fr 1fr; gap: var(--hrl-s-3); margin: var(--hrl-s-4) 0 var(--hrl-s-2); }
419
424
  .hrl-tax-compare__col { border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius-sm); padding: var(--hrl-s-3); min-width: 0; }
420
425
  .hrl-tax-compare__col--chosen { border: 2px solid var(--hrl-accent); }
421
426
  .hrl-tax-compare__amount { font-size: var(--hrl-t-title); font-weight: 700; margin-top: var(--hrl-s-1); overflow-wrap: anywhere; }
422
427
  .hrl-tax-working { margin-top: var(--hrl-s-3); }
423
- .hrl-tax-working__table td { vertical-align: top; overflow-wrap: anywhere; }
428
+ .hrl-tax-working__table td { vertical-align: top; overflow-wrap: break-word; } /* anywhere let the table squeeze amounts mid-number */
429
+ .hrl-tax-working__table td.hrl-num { white-space: nowrap; }
424
430
  .hrl-tax-working__total td { font-weight: 700; border-top: 1px solid var(--hrl-line); }
425
431
  .hrl-tax-cap { display: block; width: 100%; height: 8px; margin-top: var(--hrl-s-1); accent-color: var(--hrl-accent); }
426
432
 
@@ -455,10 +461,10 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
455
461
  .hrl-tree__name, .hrl-tree__role { min-width: 0; overflow-wrap: anywhere; }
456
462
  .hrl-tree__name { font-weight: 700; font-size: var(--hrl-t-small); }
457
463
  a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--hrl-target); }
458
- .hrl-tree__role { color: var(--hrl-muted); font-size: var(--hrl-t-small); }
464
+ .hrl-tree__role, .hrl-tree__card .hrl-muted { color: var(--hrl-ink-2); font-size: var(--hrl-t-small); }
459
465
  .hrl-chainrow { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
460
466
  .hrl-chainrow__step { display: inline-flex; align-items: center; gap: .4rem; min-width: 0; }
461
- .hrl-chainrow .hrl-badge { white-space: normal; overflow-wrap: anywhere; }
467
+ .hrl-chainrow .hrl-badge, .hrl-badge--type { white-space: normal; overflow-wrap: anywhere; } /* long leave-balance chips wrap, not cut */
462
468
  .hrl-chainrow__arrow { color: var(--hrl-muted); }
463
469
 
464
470
  /* Definition list */
@@ -503,6 +509,7 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
503
509
  .hrl-mgrid__flag { position: absolute; top: 3px; right: 4px; width: 7px; height: 7px; border-radius: var(--hrl-radius-pill); background: var(--hrl-bad); }
504
510
  .hrl-legend { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .6rem; }
505
511
  .hrl-legend .hrl-mgrid__flag { position: static; display: inline-block; }
512
+ .hrl-legend + .hrl-mgrid { margin-top: .6rem; } /* the month key sits above its grid */
506
513
  @media (min-width: 768px) { .hrl-mgrid--cal .hrl-mgrid__day { min-height: 88px; } }
507
514
 
508
515
  /* Utility */
@@ -577,11 +584,12 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
577
584
  .hrl-idcard__foot b { font: 700 calc(13 * var(--u))/1 var(--hrl-card-display, var(--hrl-font)); color: #fff; }
578
585
  .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); }
579
586
  .hrl-idcard-actions { display: flex; flex-wrap: wrap; gap: .85rem; align-items: flex-start; justify-content: center; margin-top: 1.5rem; }
587
+ .hrl-idcard-help { flex-basis: 100%; justify-content: center; }
580
588
 
581
589
  /* Print: one CR80 face per page, nothing else. */
582
590
  @media print {
583
591
  @page { size: 54mm 85.6mm; margin: 0; }
584
- .hrl-side, .hrl-appbar, .hrl-tabbar, .hrl-flash, .hrl-page__head, .hrl-idcard__label, .hrl-idcard-actions { display: none !important; }
592
+ .hrl-side, .hrl-appbar, .hrl-tabbar, .hrl-flash, .hrl-page__head, .hrl-backlink, .hrl-idcard__label, .hrl-idcard-actions { display: none !important; }
585
593
  .hrl-body { background: #fff; }
586
594
  .hrl-main { padding: 0; margin: 0; }
587
595
  .hrl-idcard-faces { display: block; }
@@ -617,12 +625,15 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
617
625
  .hrl-fill--offline { fill: var(--hrl-mode-offline); } .hrl-stroke--offline { stroke: var(--hrl-mode-offline); } .hrl-fill-bg--offline { background: var(--hrl-mode-offline); }
618
626
  .hrl-chart-row { display: grid; gap: var(--hrl-gap-target); }
619
627
  @media (min-width: 481px) { .hrl-chart-row { grid-template-columns: 10rem 1fr; align-items: center; } }
628
+ .hrl-chart-row .hrl-num { white-space: nowrap; } /* "16h 13m" stays on one line at 360px */
620
629
  .hrl-donut { width: 10rem; max-width: 100%; transform: rotate(0deg); }
621
630
  .hrl-donut__track { stroke: var(--hrl-bg); }
631
+ .hrl-field input[type="color"] { width: 4.5rem; padding: .2rem; }
622
632
  .hrl-swatch { display: inline-block; width: .7rem; height: .7rem; border-radius: var(--hrl-radius-pill); vertical-align: middle; }
623
633
  .hrl-chart-scroll { overflow-x: auto; }
624
- .hrl-bars { width: 100%; min-width: 16rem; height: 10rem; }
625
- .hrl-bars__label { font-size: 5px; fill: var(--hrl-muted); }
634
+ /* Wide enough per bar that the height (10rem) sets the scale, so labels render ~12.6px and long ranges scroll. */
635
+ .hrl-bars { width: 100%; min-width: max(16rem, calc(var(--hrl-bars-n, 0) * 1.75rem)); height: 10rem; }
636
+ .hrl-bars__label { font-size: 9px; fill: var(--hrl-muted); }
626
637
  .hrl-daystrip { width: 100%; height: 2rem; background: var(--hrl-bg); border-radius: var(--hrl-radius-sm); }
627
638
  .hrl-timeline__auto { stroke: var(--hrl-card); stroke-width: .3; stroke-dasharray: .6 .4; }
628
639
  .hrl-timeline__axis { display: flex; justify-content: space-between; }
@@ -630,8 +641,6 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
630
641
  .hrl-chart-tip { position: absolute; z-index: 30; max-width: 16rem; padding: .4rem .6rem; border-radius: var(--hrl-radius-sm); background: var(--hrl-ink); color: var(--hrl-card); font-size: var(--hrl-t-small); pointer-events: none; }
631
642
  [data-hrl-tip]:focus { outline: 2px solid var(--hrl-accent); outline-offset: 1px; }
632
643
  .hrl-custom-status { display: inline-flex; gap: .3rem; align-items: center; overflow-wrap: anywhere; }
633
- .hrl-emoji-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(2.75rem, 1fr)); gap: var(--hrl-gap-target); margin-top: var(--hrl-gap-target); }
634
- .hrl-emoji { min-height: var(--hrl-target); border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius-sm); background: var(--hrl-card); font-size: 1.25rem; cursor: pointer; }
635
644
  /* Today's check-ins: one initials badge per person; hover/tap shows times. */
636
645
  .hrl-checkins + .hrl-checkins { margin-top: .6rem; }
637
646
  .hrl-checkins__people { display: flex; flex-wrap: wrap; gap: var(--hrl-gap-target); margin: .4rem 0 0; padding: 0; list-style: none; }
@@ -641,5 +650,6 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
641
650
 
642
651
  /* Search palette (search.js): one box for screens and people, ⌘K anywhere. */
643
652
  .hrl-search { width: min(36rem, calc(100% - 2rem)); margin: 10vh auto auto; padding: var(--hrl-s-4); border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius); background: var(--hrl-card); color: var(--hrl-ink); }
653
+ .hrl-search__close { display: flex; justify-content: flex-end; margin: 0 0 var(--hrl-s-2); }
644
654
  .hrl-search::backdrop { background: rgb(15 23 42 / .4); }
645
655
  .hrl-search__list { list-style: none; margin: var(--hrl-s-1) 0 0; padding: 0; display: grid; gap: var(--hrl-s-2); }
@@ -11,6 +11,8 @@ module HrLite
11
11
  @employees = @employees.select { |user| visible.include?(user.id) } if visible
12
12
  @records = AttendanceRecord.for_date(@date).where(user_id: @employees.map(&:id)).index_by(&:user_id)
13
13
  @flagged_count = @records.values.count(&:flagged?)
14
+ # Leave, holidays and weekly offs are not "No punch": the team board's day status says which.
15
+ @days = TeamDay.new(date: @date).rows.index_by { |row| row.user.id }
14
16
  @penalty_minutes = AttendancePenalty.held_minutes(@employees.map(&:id))
15
17
  # "How many people punched where": off-site punches count as one place.
16
18
  @offices = OfficeLocation.active.to_a
@@ -102,6 +104,12 @@ module HrLite
102
104
  alert: "A check-in time is required when a check-out is set."
103
105
  end
104
106
 
107
+ # Full-day leave outranks any punch, so the fix would change nothing anyone reads.
108
+ if LeaveRequest.active_on(date).where(user_id: @employee.id, half_day: false).exists?
109
+ return redirect_to admin_attendance_path(@employee.id, date: date, month: date.strftime("%Y-%m")),
110
+ alert: "The day is covered by approved leave — cancel the leave first."
111
+ end
112
+
105
113
  record.assign_attributes(attrs)
106
114
  record.clear_check_out if params[:reset] == "check_out"
107
115
  record.half_day_part = attrs[:half_day_part].presence if attrs.key?(:half_day_part)
@@ -207,16 +215,17 @@ module HrLite
207
215
  # whose own page this viewer may open for this person.
208
216
  def summary_requests(user, range)
209
217
  id, from, to = user.id, range.first, range.last
218
+ own = id == hr_current_user.id # nobody decides their own request: open the employee's own pages
210
219
  day = ->(d) { d.strftime("%a %-d %b") }
211
220
  {
212
221
  "leave.view" => -> {
213
222
  LeaveRequest.where(user_id: id).overlapping_range(from, to).includes(:leave_type)
214
- .map { |r| [ r.leave_type.name, r.date_range_label, r.status, admin_leave_request_path(r) ] } +
215
- CompOffRequest.where(user_id: id, date_worked: range).map { |r| [ "Comp-off", day.(r.date_worked), r.status, admin_comp_off_request_path(r) ] }
223
+ .map { |r| [ r.leave_type.name, r.date_range_label, r.status, own ? leave_request_path(r) : admin_leave_request_path(r) ] } +
224
+ CompOffRequest.where(user_id: id, date_worked: range).map { |r| [ "Comp-off", day.(r.date_worked), r.status, own ? comp_off_requests_path : admin_comp_off_request_path(r) ] }
216
225
  },
217
226
  "attendance.view" => -> {
218
227
  WfhRequest.where(user_id: id).overlapping(from, to).map { |r| [ "WFH", r.dates_label, r.status, admin_wfh_request_path(r) ] } +
219
- RegularizationRequest.where(user_id: id, date: range).map { |r| [ "Attendance fix", day.(r.date), r.status, admin_regularization_request_path(r) ] }
228
+ RegularizationRequest.where(user_id: id, date: range).map { |r| [ "Attendance fix", day.(r.date), r.status, own ? regularization_requests_path : admin_regularization_request_path(r) ] }
220
229
  },
221
230
  "expense.approve" => -> {
222
231
  Expense.where(user_id: id, spent_on: range).includes(:category)
@@ -5,6 +5,7 @@ module HrLite
5
5
  # Appraisal and promotion rows carry review text in plain columns:
6
6
  # ordinary leadership governs people and policy, not pay.
7
7
  scope = hr_superadmin? ? AuditLog.recent : AuditLog.recent.outside_money_tier
8
+ scope = scope.where.not("action LIKE ?", "%.viewed") unless params[:views] # page views drown the changes
8
9
  @audit_logs = paginate(scope.includes(:actor))
9
10
  end
10
11
  end
@@ -4,7 +4,7 @@ module HrLite
4
4
  def index
5
5
  @status = params[:status].presence_in(CompOffRequest::STATUSES) || "pending"
6
6
  @requests = paginate(hr_scope(CompOffRequest.includes(:user).where(status: @status).recent_first,
7
- "leave.view"))
7
+ "leave.view").where.not(user_id: hr_current_user.id))
8
8
  end
9
9
 
10
10
  def show
@@ -11,7 +11,7 @@ module HrLite
11
11
  @category = params[:category].presence
12
12
  scope = hr_scope(Document.includes(:user).recent_first, "document.view")
13
13
  scope = scope.where(category: @category) if @category
14
- @pending = scope.where(verification: "pending").limit(50).to_a
14
+ @pending = scope.where(verification: "pending").where.not(user_id: hr_current_user.id).limit(50).to_a
15
15
  @documents = paginate(scope)
16
16
  @categories = Document.distinct.order(:category).pluck(:category)
17
17
  end
@@ -72,11 +72,11 @@ module HrLite
72
72
  end
73
73
 
74
74
  def edit
75
- @profile = EmployeeProfile.find(params[:id])
75
+ @profile = others_profile
76
76
  end
77
77
 
78
78
  def update
79
- @profile = EmployeeProfile.find(params[:id])
79
+ @profile = others_profile
80
80
  if @profile.update(update_profile_params)
81
81
  redirect_to admin_employee_path(@profile), notice: "Employee profile updated."
82
82
  else
@@ -87,12 +87,16 @@ module HrLite
87
87
  # Exit: stamps the date (attendance/payroll clip on it) and revokes
88
88
  # access via the host hook. Audited via the profile update.
89
89
  def offboard
90
- @profile = EmployeeProfile.find(params[:id])
90
+ @profile = others_profile
91
91
  # `.to_date` on a raw param raises Date::Error and 500s; the
92
92
  # controller's own parser exists for exactly this.
93
93
  exit_date = params[:date_of_exit].present? ? parse_date_param(params[:date_of_exit]) : Date.current
94
94
 
95
- @profile.update!(date_of_exit: exit_date)
95
+ ActiveRecord::Base.transaction do
96
+ @profile.update!(date_of_exit: exit_date)
97
+ # HR, Finance, Leadership go with the job; Employee stays so the person still opens their slips.
98
+ RoleAssignment.for_user(@profile.user).joins(:role).where.not(hr_lite_roles: { name: Role::EMPLOYEE }).destroy_all
99
+ end
96
100
  # The last day has a list too — and it is the one that matters most,
97
101
  # since an unreturned laptop and un-revoked access both outlive the
98
102
  # employment.
@@ -110,6 +114,9 @@ module HrLite
110
114
 
111
115
  private
112
116
 
117
+ # Nobody edits or offboards their own record here (auto-approve would publish it); someone else does.
118
+ def others_profile = EmployeeProfile.find(params[:id]).tap { |p| raise ActiveRecord::RecordNotFound if p.user_id == hr_current_user.id }
119
+
113
120
  def profile_params
114
121
  params.require(:employee_profile).permit(
115
122
  :user_id, :manager_id, :designation, :date_of_birth, :date_of_joining,
@@ -8,13 +8,15 @@ module HrLite
8
8
  def index
9
9
  @status = params[:status].presence_in(Expense::STATUSES) || "submitted"
10
10
  scope = Expense.where(status: @status).includes(:user, :category).recent_first
11
- @expenses = paginate(hr_scope(scope, "expense.approve"))
11
+ @expenses = paginate(hr_scope(scope, "expense.approve").where.not(user_id: hr_current_user.id))
12
12
  end
13
13
 
14
14
  def approve
15
15
  expense = decidable.find(params[:id])
16
16
  expense.approve!(actor: hr_current_user, note: params[:decision_note].presence)
17
17
  redirect_to admin_expenses_path, notice: "Claim approved."
18
+ rescue ActiveRecord::RecordInvalid
19
+ redirect_to admin_expenses_path, alert: "Only a submitted claim can be approved."
18
20
  end
19
21
 
20
22
  def reject
@@ -24,6 +26,8 @@ module HrLite
24
26
 
25
27
  expense.reject!(actor: hr_current_user, note: note)
26
28
  redirect_to admin_expenses_path, notice: "Claim rejected."
29
+ rescue ActiveRecord::RecordInvalid
30
+ redirect_to admin_expenses_path, alert: "Only a submitted claim can be rejected."
27
31
  end
28
32
 
29
33
  def reimburse
@@ -35,6 +39,18 @@ module HrLite
35
39
  redirect_to admin_expenses_path, alert: "Only an approved claim can be reimbursed."
36
40
  end
37
41
 
42
+ # The receipt the employee had to upload, for the person deciding; same rules as a document download.
43
+ def receipt
44
+ expense = decidable.find(params[:id])
45
+ raise ActiveRecord::RecordNotFound unless expense.receipt.attached?
46
+
47
+ AuditLog.record!(action: "expense.receipt_viewed", subject: expense, actor: hr_current_user,
48
+ changes: { "owner" => HrLite.display_name(expense.user) })
49
+ redirect_to Rails.application.routes.url_helpers.rails_blob_path(
50
+ expense.receipt, disposition: "inline", only_path: true, expires_in: 5.minutes
51
+ ), allow_other_host: false
52
+ end
53
+
38
54
  private
39
55
 
40
56
  def require_deciding! = hr_require_permission!("expense.approve", scope: :team)
@@ -20,6 +20,8 @@ module HrLite
20
20
  assignee = HrLite.user_klass.find(params[:assignee_id])
21
21
  request.assign!(actor: hr_current_user, assignee: assignee)
22
22
  redirect_to admin_hr_request_path(request), notice: "Assigned to #{HrLite.display_name(assignee)}."
23
+ rescue ActiveRecord::RecordInvalid
24
+ redirect_to admin_hr_request_path(request), alert: "This request is already settled."
23
25
  end
24
26
 
25
27
  def resolve
@@ -28,6 +30,8 @@ module HrLite
28
30
  redirect_to admin_hr_requests_path, notice: "Answered."
29
31
  rescue ArgumentError
30
32
  redirect_to admin_hr_request_path(request), alert: "Write an answer before resolving."
33
+ rescue ActiveRecord::RecordInvalid
34
+ redirect_to admin_hr_request_path(request), alert: "This request is already settled."
31
35
  end
32
36
 
33
37
  private
@@ -8,7 +8,7 @@ module HrLite
8
8
  # the LIST as well as the decision matters: an index that shows a row
9
9
  # the member action then refuses is a worse screen than one that never
10
10
  # showed it.
11
- @requests = paginate(hr_scope(scope.where(status: @status), "leave.view"))
11
+ @requests = paginate(hr_scope(scope.where(status: @status), "leave.view").where.not(user_id: hr_current_user.id))
12
12
  end
13
13
 
14
14
  def show
@@ -24,7 +24,10 @@ module HrLite
24
24
  end
25
25
 
26
26
  def create
27
- profile = recordable.find_by(employee_code: params[:employee_code].to_s.strip)
27
+ typed = params[:employee_code].to_s.strip
28
+ # The code, else a typed name — only when exactly one person has it.
29
+ profile = recordable.find_by(employee_code: typed) ||
30
+ recordable.select { |p| HrLite.display_name(p.user).casecmp?(typed) }.then { |m| m.first if m.one? }
28
31
  @request = LeaveRequest.new(params.require(:leave_request).permit(:leave_type_id, :start_date, :end_date, :half_day, :half_day_part, :reason)
29
32
  .merge(user_id: profile&.user_id, created_by_id: hr_current_user.id))
30
33
  recorded = profile && LeaveRequest.transaction do
@@ -18,7 +18,7 @@ module HrLite
18
18
  month = parse_month_param(params.dig(:payroll_run, :period_month))
19
19
  @run = PayrollRun.new(period_month: month, created_by_id: hr_current_user.id,
20
20
  notes: params.dig(:payroll_run, :notes))
21
- if @run.save
21
+ if PayrollRun.transaction { @run.save && audit!("payroll.created", @run) }
22
22
  redirect_to admin_payroll_run_path(@run), notice: "Run created — compute when ready."
23
23
  else
24
24
  render :new, status: :unprocessable_entity
@@ -32,7 +32,7 @@ module HrLite
32
32
 
33
33
  def destroy
34
34
  run = PayrollRun.find(params[:id])
35
- if run.destroy
35
+ if PayrollRun.transaction { run.destroy && audit!("payroll.deleted", run) }
36
36
  redirect_to admin_payroll_runs_path, notice: "Draft run deleted.", status: :see_other
37
37
  else
38
38
  redirect_to admin_payroll_run_path(run), alert: run.errors.full_messages.to_sentence,
@@ -41,7 +41,7 @@ module HrLite
41
41
  end
42
42
 
43
43
  def compute
44
- transition { |run| run.compute!(actor: hr_current_user) && "Computed — review the slips." }
44
+ transition { |run| run.compute_later!(actor: hr_current_user) && "Compute started." }
45
45
  end
46
46
 
47
47
  def approve
@@ -77,6 +77,8 @@ module HrLite
77
77
 
78
78
  private
79
79
 
80
+ def audit!(action, run) = AuditLog.record!(action: action, subject: run, actor: hr_current_user, changes: { "period" => run.label })
81
+
80
82
  def require_approver_or_money!
81
83
  return if hr_can?("payroll.manage", scope: :all)
82
84
 
@@ -99,8 +101,10 @@ module HrLite
99
101
  CSV.generate do |csv|
100
102
  csv << [ "Code", "Name", "Days", "LOP", "Gross", "PF", "ESI", "PT", "TDS",
101
103
  "Net pay", "Bank", "Account", "IFSC" ]
102
- run.salary_slips.includes(:user).each do |slip|
103
- profile = slip.user_profile
104
+ slips = run.salary_slips.includes(:user).to_a
105
+ profiles = EmployeeProfile.where(user_id: slips.map(&:user_id)).index_by(&:user_id) # one query, not one a slip
106
+ slips.each do |slip|
107
+ profile = profiles[slip.user_id]
104
108
  deductions = slip.deductions_rows.index_by { |row| row["code"] }
105
109
  csv << [
106
110
  profile&.employee_code, HrLite.display_name(slip.user),
@@ -4,7 +4,7 @@ module HrLite
4
4
  def index
5
5
  @status = params[:status].presence_in(RegularizationRequest::STATUSES) || "pending"
6
6
  @requests = paginate(hr_scope(RegularizationRequest.includes(:user).where(status: @status).recent_first,
7
- "attendance.view"))
7
+ "attendance.view").where.not(user_id: hr_current_user.id))
8
8
  end
9
9
 
10
10
  def show
@@ -109,9 +109,10 @@ module HrLite
109
109
  visible_users.flat_map do |user|
110
110
  types.map do |type|
111
111
  balance = LeaveBalance.for(user, type, year)
112
+ entitled = balance.entitled
113
+ used = balance.used # each once: both query
112
114
  { "Employee" => HrLite.display_name(user), "Type" => type.code,
113
- "Entitled" => balance.entitled.to_f, "Used" => balance.used.to_f,
114
- "Available" => balance.available.to_f }
115
+ "Entitled" => entitled.to_f, "Used" => used.to_f, "Available" => (entitled - used).to_f }
115
116
  end
116
117
  end
117
118
  end
@@ -38,8 +38,10 @@ module HrLite
38
38
 
39
39
  private
40
40
 
41
+ # Nobody sets their own pay.
41
42
  def set_profile
42
43
  @profile = EmployeeProfile.find(params[:employee_id])
44
+ raise ActiveRecord::RecordNotFound if @profile.user_id == hr_current_user.id
43
45
  end
44
46
 
45
47
  def structure_params