hr_lite 0.53.0 → 0.54.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +5 -2
  4. data/app/assets/javascripts/hr_lite/emoji_pick.js +4 -4
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +7 -6
  6. data/app/controllers/hr_lite/admin/attendances_controller.rb +2 -0
  7. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +1 -1
  8. data/app/controllers/hr_lite/admin/documents_controller.rb +1 -1
  9. data/app/controllers/hr_lite/admin/expenses_controller.rb +13 -1
  10. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +1 -1
  11. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +1 -1
  12. data/app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb +2 -1
  13. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +2 -1
  14. data/app/controllers/hr_lite/admin/work_locations_controller.rb +1 -1
  15. data/app/controllers/hr_lite/home_controller.rb +0 -9
  16. data/app/controllers/hr_lite/loans_controller.rb +1 -1
  17. data/app/helpers/hr_lite/application_helper.rb +21 -2
  18. data/app/models/hr_lite/attendance_record.rb +7 -4
  19. data/app/models/hr_lite/salary_slip.rb +2 -1
  20. data/app/models/hr_lite/statutory_rate_card_record.rb +6 -0
  21. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -4
  22. data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
  23. data/app/views/hr_lite/admin/benefits/index.html.erb +10 -4
  24. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +1 -1
  25. data/app/views/hr_lite/admin/documents/index.html.erb +2 -2
  26. data/app/views/hr_lite/admin/employees/show.html.erb +2 -1
  27. data/app/views/hr_lite/admin/expense_categories/index.html.erb +1 -1
  28. data/app/views/hr_lite/admin/expenses/index.html.erb +4 -2
  29. data/app/views/hr_lite/admin/holidays/index.html.erb +2 -1
  30. data/app/views/hr_lite/admin/hr_requests/index.html.erb +2 -2
  31. data/app/views/hr_lite/admin/hr_requests/show.html.erb +1 -1
  32. data/app/views/hr_lite/admin/leave_requests/show.html.erb +1 -1
  33. data/app/views/hr_lite/admin/leave_types/index.html.erb +2 -2
  34. data/app/views/hr_lite/admin/loans/new.html.erb +1 -1
  35. data/app/views/hr_lite/admin/office_locations/index.html.erb +1 -1
  36. data/app/views/hr_lite/admin/overview/index.html.erb +10 -31
  37. data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +2 -1
  38. data/app/views/hr_lite/admin/payroll_runs/index.html.erb +1 -1
  39. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +6 -4
  40. data/app/views/hr_lite/admin/performance_plans/_form.html.erb +2 -1
  41. data/app/views/hr_lite/admin/policies/index.html.erb +1 -1
  42. data/app/views/hr_lite/admin/policies/new.html.erb +2 -1
  43. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +1 -1
  44. data/app/views/hr_lite/admin/roles/_form.html.erb +2 -1
  45. data/app/views/hr_lite/admin/roles/edit.html.erb +4 -2
  46. data/app/views/hr_lite/admin/roles/index.html.erb +1 -1
  47. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +11 -7
  48. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +6 -4
  49. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +1 -1
  50. data/app/views/hr_lite/approvals/index.html.erb +4 -3
  51. data/app/views/hr_lite/attendance/_custom_status.html.erb +7 -9
  52. data/app/views/hr_lite/attendance/_punch_card.html.erb +8 -0
  53. data/app/views/hr_lite/attendance/show.html.erb +5 -3
  54. data/app/views/hr_lite/home/index.html.erb +4 -3
  55. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +2 -1
  56. data/app/views/hr_lite/loans/index.html.erb +1 -1
  57. data/app/views/hr_lite/resignations/show.html.erb +2 -1
  58. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -5
  59. data/app/views/hr_lite/salary_slips/pdf.html.erb +1 -0
  60. data/app/views/hr_lite/work_time/_bars.html.erb +4 -3
  61. data/app/views/layouts/hr_lite/mailer.html.erb +10 -2
  62. data/app/views/layouts/hr_lite/mailer.text.erb +4 -1
  63. data/config/routes.rb +1 -1
  64. data/lib/hr_lite/amount_in_words.rb +7 -5
  65. data/lib/hr_lite/statutory_seeds.rb +1 -1
  66. data/lib/hr_lite/version.rb +1 -1
  67. metadata +1 -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: 2914dd05ed8eb6c4e9974e4f4d84ee8149f6158e976ee6f7ec07958b8046a894
4
+ data.tar.gz: 36bc28076ab2d1d16a6ac20df8171c28ae50b317081ec3e2a6b205cf32591d05
5
5
  SHA512:
6
- metadata.gz: bfb1bf34635704206cc890a4acee68684c7033b3d5dbbda67fa1b4c44b3c377e05f2f74271a30ad7174cc221d0d7bbed4d6afb244133082d64945f3066c8018f
7
- data.tar.gz: 1f8e31b31cad07625f8540f4ed3f8e6dd3ada6e4415c91c96d34eb86d899a056d3ee9668b2cc78f71ee4d6367dc22d42de8641112b5e50f422a054ad302a6d81
6
+ metadata.gz: 1235ee079d20011f1d966c605814f028ece64a6d148c6ad8e28be4b1a9db7cb1b9ac98ded5b1f7c87a6c10536c7d524788b0f87096be4146f2fbe2458b08d0c4
7
+ data.tar.gz: a770905bf3daeab581c3edc9a3fb7d4902824d3c47a491e5af540123df3c6aaad711d9d6de88b6a0055acc9432750dd9802ce3c0426c74b1070293d81e24a835
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.54.0] - 2026-10-17
11
+
12
+ Fixes from the HRMS UI/UX audit of 0.52.0. No migrations.
13
+
14
+ - 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.
15
+ - 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).
16
+ - 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.
17
+ - 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.
18
+ - 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".
19
+ - 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".
20
+ - 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.
21
+ - 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.
22
+
10
23
  ## [0.53.0] - 2026-10-16
11
24
 
12
25
  - 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.
@@ -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
  })();
@@ -124,7 +124,7 @@ html:has(.hrl-body) { scroll-padding-top: 4rem; scroll-padding-bottom: calc(4.4r
124
124
  .hrl-nav__link--active .hrl-nav__hint { color: inherit; }
125
125
  /* Tab row: the current place's tabs on one line; it scrolls sideways, never the page. */
126
126
  .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; }
127
+ .hrl-tabrow .hrl-nav__link { flex: 0 0 auto; white-space: nowrap; min-width: var(--hrl-target); justify-content: center; }
128
128
  .hrl-side__divider { height: 1px; background: var(--hrl-line); margin: var(--hrl-s-2) var(--hrl-s-1); }
129
129
 
130
130
  /* Mobile app bar */
@@ -415,12 +415,14 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
415
415
  .hrl-card--private .hrl-badge--muted { background: var(--hrl-card); }
416
416
 
417
417
  /* Tax: the two regimes side by side, then the working as a plain table. */
418
+ .hrl-slabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--hrl-gap-target); align-items: end; }
418
419
  .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
420
  .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
421
  .hrl-tax-compare__col--chosen { border: 2px solid var(--hrl-accent); }
421
422
  .hrl-tax-compare__amount { font-size: var(--hrl-t-title); font-weight: 700; margin-top: var(--hrl-s-1); overflow-wrap: anywhere; }
422
423
  .hrl-tax-working { margin-top: var(--hrl-s-3); }
423
- .hrl-tax-working__table td { vertical-align: top; overflow-wrap: anywhere; }
424
+ .hrl-tax-working__table td { vertical-align: top; overflow-wrap: break-word; } /* anywhere let the table squeeze amounts mid-number */
425
+ .hrl-tax-working__table td.hrl-num { white-space: nowrap; }
424
426
  .hrl-tax-working__total td { font-weight: 700; border-top: 1px solid var(--hrl-line); }
425
427
  .hrl-tax-cap { display: block; width: 100%; height: 8px; margin-top: var(--hrl-s-1); accent-color: var(--hrl-accent); }
426
428
 
@@ -621,8 +623,9 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
621
623
  .hrl-donut__track { stroke: var(--hrl-bg); }
622
624
  .hrl-swatch { display: inline-block; width: .7rem; height: .7rem; border-radius: var(--hrl-radius-pill); vertical-align: middle; }
623
625
  .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); }
626
+ /* 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; }
628
+ .hrl-bars__label { font-size: 9px; fill: var(--hrl-muted); }
626
629
  .hrl-daystrip { width: 100%; height: 2rem; background: var(--hrl-bg); border-radius: var(--hrl-radius-sm); }
627
630
  .hrl-timeline__auto { stroke: var(--hrl-card); stroke-width: .3; stroke-dasharray: .6 .4; }
628
631
  .hrl-timeline__axis { display: flex; justify-content: space-between; }
@@ -630,8 +633,6 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
630
633
  .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
634
  [data-hrl-tip]:focus { outline: 2px solid var(--hrl-accent); outline-offset: 1px; }
632
635
  .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
636
  /* Today's check-ins: one initials badge per person; hover/tap shows times. */
636
637
  .hrl-checkins + .hrl-checkins { margin-top: .6rem; }
637
638
  .hrl-checkins__people { display: flex; flex-wrap: wrap; gap: var(--hrl-gap-target); margin: .4rem 0 0; padding: 0; list-style: none; }
@@ -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
@@ -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
@@ -8,7 +8,7 @@ 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
@@ -35,6 +35,18 @@ module HrLite
35
35
  redirect_to admin_expenses_path, alert: "Only an approved claim can be reimbursed."
36
36
  end
37
37
 
38
+ # The receipt the employee had to upload, for the person deciding; same rules as a document download.
39
+ def receipt
40
+ expense = decidable.find(params[:id])
41
+ raise ActiveRecord::RecordNotFound unless expense.receipt.attached?
42
+
43
+ AuditLog.record!(action: "expense.receipt_viewed", subject: expense, actor: hr_current_user,
44
+ changes: { "owner" => HrLite.display_name(expense.user) })
45
+ redirect_to Rails.application.routes.url_helpers.rails_blob_path(
46
+ expense.receipt, disposition: "inline", only_path: true, expires_in: 5.minutes
47
+ ), allow_other_host: false
48
+ end
49
+
38
50
  private
39
51
 
40
52
  def require_deciding! = hr_require_permission!("expense.approve", scope: :team)
@@ -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
@@ -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
@@ -52,7 +52,8 @@ module HrLite
52
52
  def next_financial_year(newest)
53
53
  return FinancialYear.start_for(Date.current) if newest.nil?
54
54
 
55
- [ newest.effective_from.next_year, FinancialYear.start_for(Date.current) ].max
55
+ # A mid-year card (1 Oct) must not push the next one to 1 Oct: years start on 1 April.
56
+ [ FinancialYear.start_for(newest.effective_from.next_year), FinancialYear.start_for(Date.current) ].max
56
57
  end
57
58
 
58
59
  # The figures arrive as flat strings from number fields and are stored
@@ -11,7 +11,7 @@ module HrLite
11
11
  @status = params[:status].presence_in(TaxDeclaration::STATUSES) || "submitted"
12
12
  @declarations = paginate(
13
13
  hr_scope(TaxDeclaration.includes(:user, :tax_declaration_items), "tax.view")
14
- .where(status: @status).order(created_at: :desc)
14
+ .where(status: @status).where.not(user_id: hr_current_user.id).order(created_at: :desc)
15
15
  )
16
16
  end
17
17
 
@@ -69,6 +69,7 @@ module HrLite
69
69
  # Recording what the proof supported is a separate act from deciding
70
70
  # — an amount can be corrected while the decision is still pending.
71
71
  if @declaration.update(verification_params)
72
+ return verify if params[:verify] # "Record and verify": the typed amounts are saved first
72
73
  redirect_to admin_tax_declaration_path(@declaration), notice: "Amounts recorded."
73
74
  else
74
75
  @tax = TaxComputation.new(user: @declaration.user, declaration: @declaration)
@@ -5,7 +5,7 @@ module HrLite
5
5
  # Coordinates are only shown here, inside attendance.view reach.
6
6
  class WorkLocationsController < BaseController
7
7
  def index
8
- @status = params[:status].presence_in(WorkLocation::STATUSES) || "approved"
8
+ @status = params[:status].presence_in(WorkLocation::STATUSES) || "pending"
9
9
  @locations = paginate(hr_scope(WorkLocation.where(status: @status), "attendance.view")
10
10
  .includes(:user).order(created_at: :desc))
11
11
  end
@@ -8,7 +8,6 @@ module HrLite
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
- @queue = queue_count
12
11
  @mine_pending = [ LeaveRequest.includes(:leave_type), RegularizationRequest, CompOffRequest ]
13
12
  .flat_map { |scope| scope.pending.where(user_id: hr_current_user.id).order(created_at: :desc).limit(5) }
14
13
  .max_by(5, &:created_at)
@@ -16,14 +15,6 @@ module HrLite
16
15
 
17
16
  private
18
17
 
19
- # Requests with no routed flow are decided straight off the HR queues, scoped as those queues decide them.
20
- def queue_count
21
- leave = ApprovalFlow.for(LeaveRequest.name) ? 0 : decidable(LeaveRequest, "leave.approve")
22
- leave + decidable(CompOffRequest, "leave.approve") + decidable(RegularizationRequest, "attendance.manage")
23
- end
24
-
25
- def decidable(model, key) = hr_scope(model.pending, key).where.not(user_id: hr_current_user.id).count
26
-
27
18
  # Last week's days that still need a fix (absent, no check-out, flagged, short), minus those with a pending ticket.
28
19
  def attention_days
29
20
  range = (Date.current - 7)..(Date.current - 1)
@@ -4,7 +4,7 @@ module HrLite
4
4
  class LoansController < ApplicationController
5
5
  def index
6
6
  @loans = Loan.where(user_id: hr_current_user.id).order(starts_on: :desc)
7
- @outstanding = @loans.sum(BigDecimal(0), &:outstanding)
7
+ @outstanding = @loans.active.sum(BigDecimal(0), &:outstanding)
8
8
  @next_month = Date.current.next_month.beginning_of_month
9
9
  end
10
10
  end
@@ -44,7 +44,7 @@ module HrLite
44
44
  { label: "Attendance fixes", path: :admin_regularization_requests_path, match: [ "/admin/regularization_requests" ], if: OPS,
45
45
  queue: -> { hrl_queue(HrLite::RegularizationRequest.pending, "attendance.manage") } },
46
46
  { label: "WFH", path: :admin_wfh_requests_path, match: [ "/admin/wfh_requests" ], if: OPS,
47
- queue: -> { hrl_queue(HrLite::WfhRequest.pending, "attendance.manage") } },
47
+ queue: -> { hrl_queue(HrLite::WfhRequest.pending.overlapping(Date.current, Date.current + HrLite::WfhRequest::MAX_DAYS_AHEAD), "attendance.manage") } },
48
48
  { label: "Home locations", path: :admin_work_locations_path, match: [ "/admin/work_locations" ], if: OPS,
49
49
  queue: -> { hrl_queue(HrLite::WorkLocation.pending, "attendance.manage") } },
50
50
  { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ], if: -> { hr_can?("expense.approve", scope: :team) },
@@ -91,7 +91,8 @@ module HrLite
91
91
  { label: "Manage policies", path: :admin_policies_path, match: [ "/admin/policies" ], if: -> { hr_can?("policy.manage", scope: :all) } }
92
92
  ] },
93
93
  { label: "Admin", divider: true, tabs: [
94
- { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting", "/admin/expense_categories" ], if: LEAD },
94
+ { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/expense_categories" ], if: LEAD },
95
+ { label: "HR settings", path: :edit_admin_setting_path, match: [ "/admin/setting" ], if: LEAD },
95
96
  { label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ], if: -> { hr_can?("role.manage", scope: :all) } },
96
97
  { label: "Statutory rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ], if: SUPER },
97
98
  { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ], if: -> { hr_can?("expense.approve", scope: :all) || %w[leave.view attendance.view profile.view].any? { |key| hr_can?(key, scope: :team) } } },
@@ -151,6 +152,24 @@ module HrLite
151
152
  # What a queue page lets this viewer decide: their reach, never their own requests.
152
153
  def hrl_queue(relation, key) = hr_access.scope_relation(relation, key).where.not(user_id: hr_current_user.id)
153
154
 
155
+ # The Inbox badge's number; Home and the overview board show the same one.
156
+ def hrl_inbox_count
157
+ inbox = hrl_sections.find { |s| s[:label] == "Inbox" }
158
+ inbox ? hrl_waiting(inbox).to_i : 0
159
+ end
160
+
161
+ # One queued request in words, and where it is decided.
162
+ def hrl_inbox_item(tab, record)
163
+ case record
164
+ when HrLite::LeaveRequest
165
+ [ "#{record.leave_type.name}, #{record.date_range_label} · #{pluralize(hrl_days(record.days_count), 'day')}", hr_lite.admin_leave_request_path(record) ]
166
+ when HrLite::CompOffRequest then [ "Comp-off for #{record.date_worked.strftime('%a %-d %b')}", hr_lite.admin_comp_off_request_path(record) ]
167
+ when HrLite::RegularizationRequest then [ "Attendance fix, #{record.date.strftime('%a %-d %b')}", hr_lite.admin_regularization_request_path(record) ]
168
+ when HrLite::WfhRequest then [ "WFH, #{record.dates_label}", hr_lite.admin_wfh_request_path(record) ]
169
+ else [ tab[:label], hr_lite.public_send(tab[:path]) ]
170
+ end
171
+ end
172
+
154
173
  # Everything waiting in this viewer's queues, oldest first, as [tab, record].
155
174
  def hrl_inbox_rows(limit = 50)
156
175
  inbox = hrl_sections.find { |s| s[:label] == "Inbox" } or return []
@@ -57,9 +57,11 @@ module HrLite
57
57
  EmployeeProfile.day_minutes_for(user_id, on: date) unless HrLite.config.work_hours.nil? || date.saturday?
58
58
  end
59
59
 
60
- # Hours worked, not the clock: an early start counts.
60
+ # Hours worked, not the clock: an early start counts. The day's penalty
61
+ # minutes count too, the same rule as required_check_out_at, so the card
62
+ # never says "day done" above "short by 20m".
61
63
  def short?
62
- worked_under?(required_minutes)
64
+ worked_under?(required_minutes && required_minutes + penalty_minutes)
63
65
  end
64
66
 
65
67
  def below_half_day?
@@ -70,10 +72,11 @@ module HrLite
70
72
  def required_check_out_at
71
73
  return unless HrLite.config.attendance_slots && check_in_at
72
74
 
73
- extra = AttendancePenalty.active.where(user_id: user_id, date: date).sum(:minutes)
74
- check_in_at + (EmployeeProfile.day_minutes_for(user_id, on: date) + extra).minutes
75
+ check_in_at + (EmployeeProfile.day_minutes_for(user_id, on: date) + penalty_minutes).minutes
75
76
  end
76
77
 
78
+ def penalty_minutes = AttendancePenalty.active.where(user_id: user_id, date: date).sum(:minutes)
79
+
77
80
  def worked_under?(minutes) = !!(minutes && worked_duration && worked_duration < minutes.minutes)
78
81
 
79
82
  # Check in again after checking out: once a day, inside the hours HR sets in
@@ -61,8 +61,9 @@ module HrLite
61
61
  row ? Money.d(row["amount"]) : BigDecimal(0)
62
62
  end
63
63
 
64
+ # Bump the version when the PDF template changes, or the old layout serves until the cache expires.
64
65
  def pdf_cache_key
65
- [ "hr_lite_slip", id, updated_at.to_i, payroll_run.status,
66
+ [ "hr_lite_slip_v2", id, updated_at.to_i, payroll_run.status,
66
67
  user_profile&.updated_at.to_i ].join("/")
67
68
  end
68
69
 
@@ -7,6 +7,9 @@ module HrLite
7
7
 
8
8
  self.table_name = "hr_lite_statutory_rate_cards"
9
9
 
10
+ # Shipped cards count as verified so payroll can auto-approve, but no person checked them.
11
+ SHIPPED_SIGN_OFF = "Shipped defaults, not checked by a person".freeze
12
+
10
13
  validates :effective_from, presence: true, uniqueness: true
11
14
  validate :effective_from_opens_a_financial_year
12
15
  validate :carries_every_figure_payroll_reads
@@ -22,6 +25,9 @@ module HrLite
22
25
 
23
26
  def verified? = verified_by.present? && verified_on.present?
24
27
 
28
+ # Cards seeded before this read "hr_lite 0.52.0 (official source …)": a version is not a sign-off.
29
+ def verified_by = super&.start_with?("hr_lite ") ? SHIPPED_SIGN_OFF : super
30
+
25
31
  # The shape SlipBuilder and the calculators expect: symbol keys and
26
32
  # BigDecimal everywhere, because JSON gives back strings and floats and
27
33
  # a float has no business in a PF calculation.
@@ -15,10 +15,8 @@
15
15
 
16
16
  <div class="hrl-kpis">
17
17
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @records.values.count { |r| r.check_in_at } %></div><div class="hrl-kpi__label">Checked in</div></div>
18
- <%# Derived from the same predicate as "Checked in". Counting rows instead
19
- dropped a record with no check-in out of BOTH tiles, so the numbers
20
- quietly stopped adding up to the headcount. %>
21
- <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @employees.size - @records.values.count { |r| r.check_in_at } %></div><div class="hrl-kpi__label">No punch</div></div>
18
+ <%# Expected in and not checked in: people on leave, a holiday or a weekly off are not counted. %>
19
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @employees.count { |u| @days[u.id]&.kind == :absent } %></div><div class="hrl-kpi__label">No punch</div></div>
22
20
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @flagged_count %></div><div class="hrl-kpi__label">Flagged</div></div>
23
21
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @records.values.count { |r| r.check_in_at && r.check_out_at.nil? } %></div><div class="hrl-kpi__label">Still in</div></div>
24
22
  </div>
@@ -71,6 +69,8 @@
71
69
  <span class="hrl-badge hrl-badge--bad">Flagged</span> <span class="hrl-small"><%= record.flag_note %></span>
72
70
  <% elsif record&.check_in_at %>
73
71
  <span class="hrl-badge hrl-badge--ok"><%= record.status.humanize %><%= " (#{record.half_day_part} half)" if record.half_day_part %></span>
72
+ <% elsif @days[employee.id] %>
73
+ <%= hrl_team_status(@days[employee.id]) %>
74
74
  <% else %>
75
75
  <span class="hrl-badge hrl-badge--muted">No punch</span>
76
76
  <% end %>
@@ -80,7 +80,7 @@
80
80
  </div>
81
81
  <div class="hrl-field">
82
82
  <%= f.label :regularization_note, "Why is this being fixed? (required)" %>
83
- <%= f.text_area :regularization_note %>
83
+ <%= f.text_area :regularization_note, required: true %>
84
84
  </div>
85
85
  <div class="hrl-form-actions">
86
86
  <%= f.submit "Save fix", class: "hrl-btn hrl-btn--primary" %>
@@ -20,7 +20,8 @@
20
20
  <%= form_with url: hr_lite.enrol_admin_benefit_path(benefit), method: :post, local: true do %>
21
21
  <div class="hrl-field">
22
22
  <%= label_tag "enrol_user_#{benefit.id}", "Employee" %>
23
- <%= select_tag :user_id, options_for_select(people), id: "enrol_user_#{benefit.id}" %>
23
+ <%= select_tag :user_id, options_for_select(people), id: "enrol_user_#{benefit.id}", include_blank: "Choose…", required: true,
24
+ data: { hrl_filter: "Type a name to filter" } %>
24
25
  </div>
25
26
  <div class="hrl-field">
26
27
  <%= label_tag "enrol_on_#{benefit.id}", "Covered from" %>
@@ -49,10 +50,15 @@
49
50
  <td data-label="Since"><%= enrolment.enrolled_on.strftime("%d %b %Y") %></td>
50
51
  <td data-label="Dependants" class="hrl-num"><%= enrolment.dependants %></td>
51
52
  <td data-label="">
52
- <%= form_with url: hr_lite.unenrol_admin_benefit_path(enrolment.benefit), method: :post do %>
53
+ <% if enrolment.ended_on %>
54
+ <p class="hrl-small">Cover ends <%= enrolment.ended_on.strftime("%d %b %Y") %></p>
55
+ <% end %>
56
+ <%= form_with url: hr_lite.unenrol_admin_benefit_path(enrolment.benefit), method: :post, class: "hrl-row",
57
+ data: { turbo_confirm: "End #{enrolment.benefit.name} for #{hr_display_name(enrolment.user)} on the date picked?" } do %>
53
58
  <%= hidden_field_tag :user_id, enrolment.user_id %>
54
- <%= date_field_tag :ended_on, Date.current, class: "hrl-input", "aria-label": "Last day of cover" %>
55
- <%= submit_tag "End cover", class: "hrl-btn" %>
59
+ <%= label_tag "ended-on-#{enrolment.id}", "Last day of cover", class: "hrl-small" %>
60
+ <%= date_field_tag :ended_on, enrolment.ended_on || Date.current, id: "ended-on-#{enrolment.id}", class: "hrl-input" %>
61
+ <%= submit_tag enrolment.ended_on ? "Change end" : "End cover", class: "hrl-btn" %>
56
62
  <% end %>
57
63
  </td>
58
64
  </tr>
@@ -51,7 +51,7 @@
51
51
  <%= form_with url: hr_lite.reject_admin_comp_off_request_path(@request), method: :post, local: true do |f| %>
52
52
  <div class="hrl-field">
53
53
  <%= f.label :decision_note, "Reason (required)", for: "reject_note" %>
54
- <%= f.text_area :decision_note, id: "reject_note" %>
54
+ <%= f.text_area :decision_note, id: "reject_note", required: true %>
55
55
  </div>
56
56
  <div class="hrl-form-actions">
57
57
  <%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger" %>
@@ -35,7 +35,7 @@
35
35
  </td>
36
36
  <td data-label="File">
37
37
  <% if doc.readable_by?(hr_current_user) && doc.file.attached? %>
38
- <a class="hrl-small" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
38
+ <a class="hrl-btn" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
39
39
  <% else %>
40
40
  <span class="hrl-small hrl-muted">Not yours to open</span>
41
41
  <% end %>
@@ -44,7 +44,7 @@
44
44
  <%= button_to "Verify", hr_lite.verify_admin_document_path(doc),
45
45
  method: :post, class: "hrl-btn hrl-btn--primary" %>
46
46
  <%= form_with url: hr_lite.reject_admin_document_path(doc), method: :post, local: true do %>
47
- <%= text_field_tag :note, nil, placeholder: "What is wrong with it", class: "hrl-input" %>
47
+ <%= text_field_tag :note, nil, placeholder: "What is wrong with it", class: "hrl-input", required: true %>
48
48
  <%= submit_tag "Reject", class: "hrl-btn" %>
49
49
  <% end %>
50
50
  </td>
@@ -30,7 +30,8 @@
30
30
  <dt>Proposed last day</dt><dd><%= @pending_resignation.proposed_last_day.strftime("%d %B %Y") %></dd>
31
31
  <% if @pending_resignation.reason.present? %><dt>Reason</dt><dd><%= @pending_resignation.reason %></dd><% end %>
32
32
  </dl>
33
- <%= form_with url: hr_lite.accept_admin_resignation_path(@pending_resignation), method: :post, local: true do %>
33
+ <%= form_with url: hr_lite.accept_admin_resignation_path(@pending_resignation), method: :post, local: true,
34
+ data: { turbo_confirm: "Accept #{hr_display_name(@profile.user)}'s resignation? Payroll and attendance end on the last day you set." } do %>
34
35
  <div class="hrl-field">
35
36
  <label for="res_last_day">Confirm last working day</label>
36
37
  <input type="date" id="res_last_day" name="last_day" class="hrl-input"
@@ -18,7 +18,7 @@
18
18
  <td data-label="Monthly cap" class="hrl-num"><%= category.uncapped? ? "No cap" : hrl_money(category.monthly_cap) %></td>
19
19
  <td data-label="Receipt"><%= category.receipt_required ? "Required" : "Optional" %></td>
20
20
  <td data-label="Active"><%= category.active ? "Yes" : "No" %></td>
21
- <td data-label=""><a class="hrl-small" href="<%= hr_lite.edit_admin_expense_category_path(category) %>">Edit</a></td>
21
+ <td data-label=""><a class="hrl-btn" href="<%= hr_lite.edit_admin_expense_category_path(category) %>">Edit</a></td>
22
22
  </tr>
23
23
  <% end %>
24
24
  </tbody>
@@ -26,6 +26,7 @@
26
26
  <div class="hrl-small hrl-muted">
27
27
  <%= expense.description %> · <%= expense.spent_on.strftime("%d %b %Y") %>
28
28
  </div>
29
+ <% if expense.receipt.attached? %><a class="hrl-card__link" href="<%= hr_lite.receipt_admin_expense_path(expense) %>">Open receipt</a><% end %>
29
30
  </td>
30
31
  <td data-label="Amount" class="hrl-num"><%= hrl_money(expense.amount) %></td>
31
32
  <td data-label="">
@@ -33,8 +34,9 @@
33
34
  <%= form_with url: hr_lite.approve_admin_expense_path(expense), method: :post do %>
34
35
  <%= submit_tag "Approve", class: "hrl-btn hrl-btn--primary" %>
35
36
  <% end %>
36
- <%= form_with url: hr_lite.reject_admin_expense_path(expense), method: :post do %>
37
- <%= text_field_tag :decision_note, nil, placeholder: "Why not", class: "hrl-input" %>
37
+ <%= form_with url: hr_lite.reject_admin_expense_path(expense), method: :post, class: "hrl-row hrl-mt" do %>
38
+ <%= label_tag "reject-expense-#{expense.id}", "Reason to reject", class: "hrl-sr-only" %>
39
+ <%= text_field_tag :decision_note, nil, id: "reject-expense-#{expense.id}", placeholder: "Reason to reject", required: true, class: "hrl-input" %>
38
40
  <%= submit_tag "Reject", class: "hrl-btn" %>
39
41
  <% end %>
40
42
  <% elsif expense.approved? %>
@@ -58,7 +58,8 @@
58
58
  <%= select_tag :office_location_id, options_for_select(@offices), include_blank: "All offices", id: "bulk-office", data: { hrl_filter: ("Type an office" if @offices.size > 5) } %></div>
59
59
  <% end %>
60
60
  <div class="hrl-field">
61
- <textarea name="lines" class="hrl-input" rows="6" placeholder="2026-11-09, Diwali&#10;2026-03-04, Holi"></textarea>
61
+ <label for="bulk-lines">One holiday a line: date, name</label>
62
+ <textarea name="lines" id="bulk-lines" class="hrl-input" rows="6" placeholder="2026-11-09, Diwali&#10;2026-03-04, Holi"></textarea>
62
63
  </div>
63
64
  <div class="hrl-form-actions">
64
65
  <button type="submit" class="hrl-btn hrl-btn--primary">Add all</button>
@@ -21,7 +21,7 @@
21
21
  <% @requests.each do |request| %>
22
22
  <tr>
23
23
  <td data-label="Request">
24
- <a href="<%= hr_lite.admin_hr_request_path(request) %>"><%= request.subject %></a>
24
+ <a class="hrl-card__link" href="<%= hr_lite.admin_hr_request_path(request) %>"><%= request.subject %></a>
25
25
  <div class="hrl-small hrl-muted"><%= request.category_label %></div>
26
26
  </td>
27
27
  <td data-label="Who"><%= hr_display_name(request.user) %></td>
@@ -29,7 +29,7 @@
29
29
  <%= request.assigned_to ? hr_display_name(request.assigned_to) : "—" %>
30
30
  </td>
31
31
  <td data-label="">
32
- <a class="hrl-small" href="<%= hr_lite.admin_hr_request_path(request) %>">Open</a>
32
+ <a class="hrl-btn" href="<%= hr_lite.admin_hr_request_path(request) %>">Open</a>
33
33
  </td>
34
34
  </tr>
35
35
  <% end %>
@@ -21,7 +21,7 @@
21
21
  <%= form_with url: hr_lite.resolve_admin_hr_request_path(@request), method: :post do %>
22
22
  <div class="hrl-field">
23
23
  <%= label_tag :resolution, "What you did" %>
24
- <%= text_area_tag :resolution, nil, rows: 4 %>
24
+ <%= text_area_tag :resolution, nil, rows: 4, required: true %>
25
25
  </div>
26
26
  <div class="hrl-form-actions">
27
27
  <%= submit_tag "Send answer", class: "hrl-btn hrl-btn--primary" %>
@@ -76,7 +76,7 @@
76
76
  <%= form_with url: hr_lite.reject_admin_leave_request_path(@request), method: :post, local: true do %>
77
77
  <div class="hrl-field">
78
78
  <label for="reject_note">Reason (required)</label>
79
- <textarea name="decision_note" id="reject_note" class="hrl-input"></textarea>
79
+ <textarea name="decision_note" id="reject_note" class="hrl-input" required></textarea>
80
80
  </div>
81
81
  <div class="hrl-form-actions">
82
82
  <button class="hrl-btn hrl-btn--danger" type="submit">Reject</button>
@@ -23,7 +23,7 @@
23
23
  <td data-label="Carry cap" class="hrl-num"><%= type.carry_forward_cap.to_f %></td>
24
24
  <td data-label="Paid"><%= type.paid ? "Yes" : "No" %></td>
25
25
  <td data-label="">
26
- <a class="hrl-small" href="<%= hr_lite.edit_admin_leave_type_path(type) %>">Edit</a>
26
+ <a class="hrl-btn" href="<%= hr_lite.edit_admin_leave_type_path(type) %>">Edit</a>
27
27
  </td>
28
28
  </tr>
29
29
  <% end %>
@@ -36,5 +36,5 @@
36
36
  <a class="hrl-btn" href="<%= hr_lite.admin_office_locations_path %>">Office locations</a>
37
37
  <a class="hrl-btn" href="<%= hr_lite.admin_holidays_path %>">Holidays</a>
38
38
  <a class="hrl-btn" href="<%= hr_lite.admin_expense_categories_path %>">Expense categories</a>
39
- <a class="hrl-btn" href="<%= hr_lite.edit_admin_setting_path %>">Weekend policy</a>
39
+ <a class="hrl-btn" href="<%= hr_lite.edit_admin_setting_path %>">HR settings</a>
40
40
  </div>
@@ -13,7 +13,7 @@
13
13
  <%= f.label :user_id, "Employee" %>
14
14
  <%= f.select :user_id, options_for_select(HrLite.employees.map { |u| [ hr_display_name(u), u.id ] },
15
15
  @loan.user_id),
16
- {}, data: { hrl_filter: "Type a name to filter" } %>
16
+ { include_blank: "Choose…" }, required: true, data: { hrl_filter: "Type a name to filter" } %>
17
17
  </div>
18
18
  <div class="hrl-field">
19
19
  <%= f.label :principal, "Amount lent (₹)" %>
@@ -18,7 +18,7 @@
18
18
  <td data-label="Coordinates"><%= office.lat %>, <%= office.lng %></td>
19
19
  <td data-label="Radius" class="hrl-num"><%= office.radius_m %> m</td>
20
20
  <td data-label="Active"><%= office.active ? "Yes" : "No" %></td>
21
- <td data-label=""><a class="hrl-small" href="<%= hr_lite.edit_admin_office_location_path(office) %>">Edit</a></td>
21
+ <td data-label=""><a class="hrl-btn" href="<%= hr_lite.edit_admin_office_location_path(office) %>">Edit</a></td>
22
22
  </tr>
23
23
  <% end %>
24
24
  </tbody>