hr_lite 0.54.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/app/assets/javascripts/hr_lite/confirm.js +9 -5
  4. data/app/assets/javascripts/hr_lite/countdown.js +9 -2
  5. data/app/assets/javascripts/hr_lite/search.js +1 -1
  6. data/app/assets/stylesheets/hr_lite/hr_lite.css +18 -9
  7. data/app/controllers/hr_lite/admin/attendances_controller.rb +10 -3
  8. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +1 -0
  9. data/app/controllers/hr_lite/admin/employees_controller.rb +11 -4
  10. data/app/controllers/hr_lite/admin/expenses_controller.rb +4 -0
  11. data/app/controllers/hr_lite/admin/hr_requests_controller.rb +4 -0
  12. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +4 -1
  13. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +9 -5
  14. data/app/controllers/hr_lite/admin/reports_controller.rb +3 -2
  15. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +2 -0
  16. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +13 -3
  17. data/app/controllers/hr_lite/approvals_controller.rb +1 -0
  18. data/app/controllers/hr_lite/home_controller.rb +2 -2
  19. data/app/controllers/hr_lite/regularization_requests_controller.rb +4 -4
  20. data/app/controllers/hr_lite/tax_declarations_controller.rb +9 -5
  21. data/app/helpers/hr_lite/application_helper.rb +14 -8
  22. data/app/jobs/hr_lite/attendance_close_job.rb +1 -1
  23. data/app/jobs/hr_lite/payroll_compute_job.rb +12 -0
  24. data/app/models/concerns/hr_lite/audited.rb +3 -1
  25. data/app/models/concerns/hr_lite/encrypted_money.rb +8 -5
  26. data/app/models/hr_lite/attendance_record.rb +14 -0
  27. data/app/models/hr_lite/comp_off_request.rb +2 -0
  28. data/app/models/hr_lite/document.rb +1 -1
  29. data/app/models/hr_lite/expense.rb +3 -0
  30. data/app/models/hr_lite/hr_request.rb +2 -0
  31. data/app/models/hr_lite/kudo.rb +2 -2
  32. data/app/models/hr_lite/leave_request.rb +25 -6
  33. data/app/models/hr_lite/payroll_line_item.rb +8 -0
  34. data/app/models/hr_lite/payroll_run.rb +15 -6
  35. data/app/models/hr_lite/regularization_request.rb +2 -2
  36. data/app/models/hr_lite/resignation.rb +5 -1
  37. data/app/models/hr_lite/role.rb +5 -0
  38. data/app/models/hr_lite/salary_slip.rb +1 -1
  39. data/app/models/hr_lite/tax_declaration.rb +5 -0
  40. data/app/services/hr_lite/attendance_puncher.rb +4 -0
  41. data/app/services/hr_lite/overview_query.rb +0 -12
  42. data/app/services/hr_lite/work_status.rb +5 -2
  43. data/app/views/hr_lite/admin/assets/index.html.erb +9 -2
  44. data/app/views/hr_lite/admin/attendances/show.html.erb +3 -1
  45. data/app/views/hr_lite/admin/attendances/summary.html.erb +5 -2
  46. data/app/views/hr_lite/admin/audit_logs/index.html.erb +6 -4
  47. data/app/views/hr_lite/admin/ca_reviews/index.html.erb +3 -3
  48. data/app/views/hr_lite/admin/checklists/index.html.erb +3 -3
  49. data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +1 -2
  50. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +5 -5
  51. data/app/views/hr_lite/admin/designation_changes/new.html.erb +4 -3
  52. data/app/views/hr_lite/admin/documents/index.html.erb +8 -3
  53. data/app/views/hr_lite/admin/documents/show.html.erb +1 -0
  54. data/app/views/hr_lite/admin/employees/_form.html.erb +12 -5
  55. data/app/views/hr_lite/admin/employees/show.html.erb +38 -32
  56. data/app/views/hr_lite/admin/expenses/index.html.erb +3 -2
  57. data/app/views/hr_lite/admin/holidays/index.html.erb +5 -4
  58. data/app/views/hr_lite/admin/hr_requests/index.html.erb +1 -1
  59. data/app/views/hr_lite/admin/hr_requests/show.html.erb +4 -3
  60. data/app/views/hr_lite/admin/leave_balances/index.html.erb +6 -4
  61. data/app/views/hr_lite/admin/leave_requests/index.html.erb +1 -2
  62. data/app/views/hr_lite/admin/leave_requests/show.html.erb +5 -3
  63. data/app/views/hr_lite/admin/leave_types/edit.html.erb +4 -4
  64. data/app/views/hr_lite/admin/leave_types/index.html.erb +3 -3
  65. data/app/views/hr_lite/admin/loans/index.html.erb +1 -1
  66. data/app/views/hr_lite/admin/loans/show.html.erb +8 -12
  67. data/app/views/hr_lite/admin/office_locations/edit.html.erb +4 -4
  68. data/app/views/hr_lite/admin/payroll_runs/index.html.erb +2 -2
  69. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +10 -7
  70. data/app/views/hr_lite/admin/policies/index.html.erb +1 -1
  71. data/app/views/hr_lite/admin/policies/new.html.erb +2 -2
  72. data/app/views/hr_lite/admin/regularization_requests/index.html.erb +3 -4
  73. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +4 -5
  74. data/app/views/hr_lite/admin/reports/index.html.erb +3 -6
  75. data/app/views/hr_lite/admin/reports/show.html.erb +16 -2
  76. data/app/views/hr_lite/admin/roles/_form.html.erb +6 -5
  77. data/app/views/hr_lite/admin/salary_slips/show.html.erb +3 -2
  78. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +1 -0
  79. data/app/views/hr_lite/admin/settings/edit.html.erb +5 -1
  80. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +3 -3
  81. data/app/views/hr_lite/admin/statutory_rate_cards/index.html.erb +3 -1
  82. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +1 -1
  83. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +2 -0
  84. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +17 -16
  85. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +2 -4
  86. data/app/views/hr_lite/admin/work_locations/index.html.erb +1 -1
  87. data/app/views/hr_lite/admin/work_modes/index.html.erb +1 -1
  88. data/app/views/hr_lite/approvals/index.html.erb +1 -1
  89. data/app/views/hr_lite/attendance/_month_grid.html.erb +9 -9
  90. data/app/views/hr_lite/attendance/_penalties.html.erb +2 -2
  91. data/app/views/hr_lite/attendance/_punch_card.html.erb +2 -4
  92. data/app/views/hr_lite/attendance/_slot.html.erb +9 -6
  93. data/app/views/hr_lite/attendance/_trail.html.erb +1 -1
  94. data/app/views/hr_lite/attendance/_work_mode.html.erb +1 -1
  95. data/app/views/hr_lite/attendance/show.html.erb +4 -4
  96. data/app/views/hr_lite/calendar/show.html.erb +2 -2
  97. data/app/views/hr_lite/comp_off_requests/index.html.erb +1 -1
  98. data/app/views/hr_lite/comp_off_requests/new.html.erb +1 -1
  99. data/app/views/hr_lite/documents/_upload_form.html.erb +1 -1
  100. data/app/views/hr_lite/employee_profiles/show.html.erb +4 -2
  101. data/app/views/hr_lite/home/index.html.erb +2 -2
  102. data/app/views/hr_lite/id_cards/show.html.erb +5 -1
  103. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +2 -2
  104. data/app/views/hr_lite/leave_requests/new.html.erb +17 -8
  105. data/app/views/hr_lite/leave_requests/show.html.erb +2 -0
  106. data/app/views/hr_lite/org/show.html.erb +1 -1
  107. data/app/views/hr_lite/regularization_requests/index.html.erb +7 -7
  108. data/app/views/hr_lite/regularization_requests/new.html.erb +7 -5
  109. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +2 -2
  110. data/app/views/hr_lite/salary_slips/pdf.html.erb +1 -1
  111. data/app/views/hr_lite/tax/_computation.html.erb +1 -1
  112. data/app/views/hr_lite/tax_declarations/show.html.erb +4 -4
  113. data/app/views/hr_lite/team/show.html.erb +8 -7
  114. data/app/views/hr_lite/timelines/show.html.erb +1 -0
  115. data/app/views/hr_lite/wfh_requests/index.html.erb +3 -3
  116. data/app/views/hr_lite/work_time/_bars.html.erb +1 -1
  117. data/app/views/hr_lite/work_time/_donut.html.erb +1 -1
  118. data/app/views/hr_lite/work_time/_report.html.erb +14 -8
  119. data/app/views/layouts/hr_lite/application.html.erb +1 -0
  120. data/lib/hr_lite/notifications.rb +5 -2
  121. data/lib/hr_lite/version.rb +1 -1
  122. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2914dd05ed8eb6c4e9974e4f4d84ee8149f6158e976ee6f7ec07958b8046a894
4
- data.tar.gz: 36bc28076ab2d1d16a6ac20df8171c28ae50b317081ec3e2a6b205cf32591d05
3
+ metadata.gz: 901c6516adf77c589f4e570c477d7aa2a7b05f279e5ef9d205cf868796074939
4
+ data.tar.gz: d3a6e968a40f6e5fe9d8503a174f8ce004c18ee6cf25218372e21a78d5fecc76
5
5
  SHA512:
6
- metadata.gz: 1235ee079d20011f1d966c605814f028ece64a6d148c6ad8e28be4b1a9db7cb1b9ac98ded5b1f7c87a6c10536c7d524788b0f87096be4146f2fbe2458b08d0c4
7
- data.tar.gz: a770905bf3daeab581c3edc9a3fb7d4902824d3c47a491e5af540123df3c6aaad711d9d6de88b6a0055acc9432750dd9802ce3c0426c74b1070293d81e24a835
6
+ metadata.gz: 43c25d542b2eacd2af96e5ff8b281f608fb202be4c1c8ecdd4790b440843fb79ceb32596e8d641bb72beffc8c606dd558d65cd0bba5fbc4a860a8a59e5f24790
7
+ data.tar.gz: 2e1af9569c33bce9ca0ea5ef790b855d397e6ebf6c88a656757001849f0d197ee8901c057d19de835d999d80005dab3fd6e505ef26829167d3be7f9a101c834c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,66 @@ 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
+
10
70
  ## [0.54.0] - 2026-10-17
11
71
 
12
72
  Fixes from the HRMS UI/UX audit of 0.52.0. No migrations.
@@ -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
  })();
@@ -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;
@@ -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
 
@@ -457,10 +461,10 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
457
461
  .hrl-tree__name, .hrl-tree__role { min-width: 0; overflow-wrap: anywhere; }
458
462
  .hrl-tree__name { font-weight: 700; font-size: var(--hrl-t-small); }
459
463
  a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--hrl-target); }
460
- .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); }
461
465
  .hrl-chainrow { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
462
466
  .hrl-chainrow__step { display: inline-flex; align-items: center; gap: .4rem; min-width: 0; }
463
- .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 */
464
468
  .hrl-chainrow__arrow { color: var(--hrl-muted); }
465
469
 
466
470
  /* Definition list */
@@ -505,6 +509,7 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
505
509
  .hrl-mgrid__flag { position: absolute; top: 3px; right: 4px; width: 7px; height: 7px; border-radius: var(--hrl-radius-pill); background: var(--hrl-bad); }
506
510
  .hrl-legend { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .6rem; }
507
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 */
508
513
  @media (min-width: 768px) { .hrl-mgrid--cal .hrl-mgrid__day { min-height: 88px; } }
509
514
 
510
515
  /* Utility */
@@ -579,11 +584,12 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
579
584
  .hrl-idcard__foot b { font: 700 calc(13 * var(--u))/1 var(--hrl-card-display, var(--hrl-font)); color: #fff; }
580
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); }
581
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; }
582
588
 
583
589
  /* Print: one CR80 face per page, nothing else. */
584
590
  @media print {
585
591
  @page { size: 54mm 85.6mm; margin: 0; }
586
- .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; }
587
593
  .hrl-body { background: #fff; }
588
594
  .hrl-main { padding: 0; margin: 0; }
589
595
  .hrl-idcard-faces { display: block; }
@@ -619,12 +625,14 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
619
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); }
620
626
  .hrl-chart-row { display: grid; gap: var(--hrl-gap-target); }
621
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 */
622
629
  .hrl-donut { width: 10rem; max-width: 100%; transform: rotate(0deg); }
623
630
  .hrl-donut__track { stroke: var(--hrl-bg); }
631
+ .hrl-field input[type="color"] { width: 4.5rem; padding: .2rem; }
624
632
  .hrl-swatch { display: inline-block; width: .7rem; height: .7rem; border-radius: var(--hrl-radius-pill); vertical-align: middle; }
625
633
  .hrl-chart-scroll { overflow-x: auto; }
626
634
  /* Wide enough per bar that the height (10rem) sets the scale, so labels render ~12.6px and long ranges scroll. */
627
- .hrl-bars { width: 100%; min-width: max(16rem, calc(var(--hrl-bars-n, 0) * 1.4rem)); height: 10rem; }
635
+ .hrl-bars { width: 100%; min-width: max(16rem, calc(var(--hrl-bars-n, 0) * 1.75rem)); height: 10rem; }
628
636
  .hrl-bars__label { font-size: 9px; fill: var(--hrl-muted); }
629
637
  .hrl-daystrip { width: 100%; height: 2rem; background: var(--hrl-bg); border-radius: var(--hrl-radius-sm); }
630
638
  .hrl-timeline__auto { stroke: var(--hrl-card); stroke-width: .3; stroke-dasharray: .6 .4; }
@@ -642,5 +650,6 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
642
650
 
643
651
  /* Search palette (search.js): one box for screens and people, ⌘K anywhere. */
644
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); }
645
654
  .hrl-search::backdrop { background: rgb(15 23 42 / .4); }
646
655
  .hrl-search__list { list-style: none; margin: var(--hrl-s-1) 0 0; padding: 0; display: grid; gap: var(--hrl-s-2); }
@@ -104,6 +104,12 @@ module HrLite
104
104
  alert: "A check-in time is required when a check-out is set."
105
105
  end
106
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
+
107
113
  record.assign_attributes(attrs)
108
114
  record.clear_check_out if params[:reset] == "check_out"
109
115
  record.half_day_part = attrs[:half_day_part].presence if attrs.key?(:half_day_part)
@@ -209,16 +215,17 @@ module HrLite
209
215
  # whose own page this viewer may open for this person.
210
216
  def summary_requests(user, range)
211
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
212
219
  day = ->(d) { d.strftime("%a %-d %b") }
213
220
  {
214
221
  "leave.view" => -> {
215
222
  LeaveRequest.where(user_id: id).overlapping_range(from, to).includes(:leave_type)
216
- .map { |r| [ r.leave_type.name, r.date_range_label, r.status, admin_leave_request_path(r) ] } +
217
- 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) ] }
218
225
  },
219
226
  "attendance.view" => -> {
220
227
  WfhRequest.where(user_id: id).overlapping(from, to).map { |r| [ "WFH", r.dates_label, r.status, admin_wfh_request_path(r) ] } +
221
- 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) ] }
222
229
  },
223
230
  "expense.approve" => -> {
224
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
@@ -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,
@@ -15,6 +15,8 @@ module HrLite
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
@@ -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
@@ -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),
@@ -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
@@ -45,7 +45,7 @@ module HrLite
45
45
 
46
46
  AuditLog.record!(action: "tax_proof.downloaded", subject: declaration, actor: hr_current_user,
47
47
  changes: { "file" => file.filename.to_s, "owner" => HrLite.display_name(declaration.user) })
48
- redirect_to Rails.application.routes.url_helpers.rails_blob_path(file, disposition: "attachment", only_path: true),
48
+ redirect_to Rails.application.routes.url_helpers.rails_blob_path(file, disposition: "attachment", only_path: true, expires_in: 5.minutes),
49
49
  allow_other_host: false
50
50
  end
51
51
 
@@ -57,6 +57,8 @@ module HrLite
57
57
  declaration.tax_declaration_items.each do |item|
58
58
  item.update!(verified_amount: item.declared_amount) if item.verified_amount.nil?
59
59
  end
60
+ AuditLog.record!(action: "tax_declaration.accepted_all", subject: declaration, actor: hr_current_user,
61
+ changes: { "owner" => HrLite.display_name(declaration.user) })
60
62
  declaration.verify!(actor: hr_current_user, note: "Proof accepted")
61
63
  end
62
64
  redirect_to admin_tax_declarations_path, notice: "Accepted and verified — payroll uses these amounts now."
@@ -66,9 +68,16 @@ module HrLite
66
68
 
67
69
  def update
68
70
  @declaration = find_manageable
71
+ return redirect_to admin_tax_declaration_path(@declaration), alert: "Only a submitted declaration can be checked." unless @declaration.submitted?
72
+
69
73
  # Recording what the proof supported is a separate act from deciding
70
74
  # — an amount can be corrected while the decision is still pending.
71
- if @declaration.update(verification_params)
75
+ recorded = TaxDeclaration.transaction do
76
+ @declaration.update(verification_params) &&
77
+ AuditLog.record!(action: "tax_declaration.amounts_recorded", subject: @declaration, actor: hr_current_user,
78
+ changes: { "owner" => HrLite.display_name(@declaration.user) })
79
+ end
80
+ if recorded
72
81
  return verify if params[:verify] # "Record and verify": the typed amounts are saved first
73
82
  redirect_to admin_tax_declaration_path(@declaration), notice: "Amounts recorded."
74
83
  else
@@ -82,7 +91,8 @@ module HrLite
82
91
  declaration.verify!(actor: hr_current_user, note: params[:note])
83
92
  redirect_to admin_tax_declarations_path, notice: "Verified — payroll will use the checked amounts."
84
93
  rescue ActiveRecord::RecordInvalid
85
- redirect_to admin_tax_declaration_path(declaration), alert: "Only a submitted declaration can be verified."
94
+ redirect_to admin_tax_declaration_path(declaration),
95
+ alert: declaration.errors.full_messages.first || "Only a submitted declaration can be verified."
86
96
  end
87
97
 
88
98
  def reject
@@ -9,6 +9,7 @@ module HrLite
9
9
  class ApprovalsController < ApplicationController
10
10
  def index
11
11
  @approvals = paginate(Approval.pending_for(hr_current_user).includes(:step, :subject).order(:created_at))
12
+ @in_flow = Approval.pending_for(hr_current_user).pluck(:subject_type, :subject_id) # their queue rows would repeat them
12
13
  @delegations = ApprovalDelegation.live_on(Date.current)
13
14
  .where(from_user_id: hr_current_user.id)
14
15
  .includes(:to_user)
@@ -3,12 +3,12 @@ module HrLite
3
3
  def index
4
4
  @latest_kudos = Kudo.recent.includes(:giver, kudo_mentions: :user).limit(3)
5
5
  @awards = Award.current
6
- @out_week = LeaveRequest.approved.overlapping_range(Date.current.beginning_of_week, Date.current.end_of_week)
6
+ @out_week = LeaveRequest.approved.overlapping_range(Date.current, Date.current.end_of_week)
7
7
  .includes(:user).where(user_id: HrLite.active_employees.map(&:id))
8
8
  .sort_by { |leave| hr_display_name(leave.user).downcase }
9
9
  @attention = attention_days
10
10
  @waiting = Approval.pending_for(hr_current_user).count
11
- @mine_pending = [ LeaveRequest.includes(:leave_type), RegularizationRequest, CompOffRequest ]
11
+ @mine_pending = [ LeaveRequest.includes(:leave_type), RegularizationRequest, CompOffRequest, WfhRequest ]
12
12
  .flat_map { |scope| scope.pending.where(user_id: hr_current_user.id).order(created_at: :desc).limit(5) }
13
13
  .max_by(5, &:created_at)
14
14
  end
@@ -22,19 +22,19 @@ module HrLite
22
22
  def cancel
23
23
  request = RegularizationRequest.where(user_id: hr_current_user.id).find(params[:id])
24
24
  request.cancel!(actor: hr_current_user)
25
- redirect_to regularization_requests_path, notice: "Ticket cancelled."
25
+ redirect_to regularization_requests_path, notice: "Day fix cancelled."
26
26
  rescue ActiveRecord::RecordInvalid
27
- redirect_to regularization_requests_path, alert: "Only pending tickets can be cancelled."
27
+ redirect_to regularization_requests_path, alert: "Only pending day fixes can be cancelled."
28
28
  end
29
29
 
30
30
  private
31
31
 
32
32
  def create_notice
33
33
  rule = HrLite.config.self_regularization
34
- return "Ticket raised — an admin will review it." unless rule
34
+ return "Sent for approval — your manager or HR will review it." unless rule
35
35
  return "Fixed. #{helpers.pluralize(@request.self_fixes_left, 'self-fix')} left this week." if @request.approved?
36
36
 
37
- "Sent to HR as a ticket — you can fix only a missed punch or short day on a working day in the last #{rule[:within_days]} days yourself, " \
37
+ "Sent for approval — you can fix only a missed punch or short day on a working day in the last #{rule[:within_days]} days yourself, " \
38
38
  "#{rule[:per_week]} times a week."
39
39
  end
40
40
 
@@ -28,10 +28,11 @@ module HrLite
28
28
  TaxDeclaration.new(user_id: hr_current_user.id,
29
29
  financial_year: financial_year, regime: employee_regime)
30
30
  # A verified declaration is what payroll is deducting against; changing
31
- # it would silently re-open a decision HR has already made.
32
- if @declaration.verified?
31
+ # it would silently re-open a decision HR has already made. A submitted
32
+ # one is what HR is checking, so it cannot shift under them either.
33
+ if @declaration.verified? || @declaration.submitted?
33
34
  return redirect_to tax_declaration_path,
34
- alert: "This year's declaration is verified. Ask HR to reopen it."
35
+ alert: "This year's declaration is #{@declaration.status}. Ask HR to reopen it."
35
36
  end
36
37
 
37
38
  if @declaration.update(declaration_params)
@@ -59,10 +60,13 @@ module HrLite
59
60
 
60
61
  private
61
62
 
63
+ # This year or last (late proof); any other year falls back to this one.
62
64
  def financial_year
63
- FinancialYear.start_for(params[:financial_year].present? ? Date.parse(params[:financial_year]) : Date.current)
65
+ current = FinancialYear.start_for(Date.current)
66
+ asked = FinancialYear.start_for(Date.parse(params[:financial_year].to_s))
67
+ asked.between?(current.prev_year, current) ? asked : current
64
68
  rescue Date::Error
65
- FinancialYear.start_for(Date.current)
69
+ current
66
70
  end
67
71
 
68
72
  def current_declaration