hr_lite 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 901c6516adf77c589f4e570c477d7aa2a7b05f279e5ef9d205cf868796074939
4
- data.tar.gz: d3a6e968a40f6e5fe9d8503a174f8ce004c18ee6cf25218372e21a78d5fecc76
3
+ metadata.gz: d9104f47987b7a8a7e613fd6d07ba269de6b1f2af1ecdd39eed2684b7a62f891
4
+ data.tar.gz: 3a73e730997fefd65b794b47dc14d0c1d836ac60484ee32c5256f601d4cd8043
5
5
  SHA512:
6
- metadata.gz: 43c25d542b2eacd2af96e5ff8b281f608fb202be4c1c8ecdd4790b440843fb79ceb32596e8d641bb72beffc8c606dd558d65cd0bba5fbc4a860a8a59e5f24790
7
- data.tar.gz: 2e1af9569c33bce9ca0ea5ef790b855d397e6ebf6c88a656757001849f0d197ee8901c057d19de835d999d80005dab3fd6e505ef26829167d3be7f9a101c834c
6
+ metadata.gz: 89aae488789fee0bf918a04c4570ed9186860be2acadbca4647d314bd41a1d6008709d10bd341a1636639534086bac276e335e50b2753b1b02ff49f51e8cb241
7
+ data.tar.gz: b152d8230fcccf6c6eb7dbcfbfc5152258d125fa3083a3dad7c439b1188059d01166f0e8ada8d7a5c25900f2223dbb5f192a8d4a502fee126ff4e8b210ae6dd4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.57.0] - 2026-10-19
11
+
12
+ No migration.
13
+
14
+ - **Why a day is Absent.** A refused check-in or check-out is now saved with its reason, and the person's attendance summary lists it under that day, for example "Check-in tried 10:09 — refused: You are not within the office location…". When the phone holds a tap because location is blocked or not found, the browser tells the server, and the day shows "Location not picked up on the phone (denied)". A past Absent day with no tries says "No check-in tried". Days before the first refusal was ever saved show neither line, because there is no record to go on. The rows are `attendance.punch_refused` entries in the audit trail.
15
+
16
+ ## [0.56.0] - 2026-10-17
17
+
18
+ Owner decisions on the HRMS audit (#133–#138). **Two migrations**: the host runs `bin/rails db:migrate` and refreshes `db/schema.rb`.
19
+
20
+ - **Claims paid with payroll** (FB-5). "Pay with payroll" books an approved claim to a month. The claim is added to that month's slip as a reimbursement line. It is outside gross, so PF, ESI, PT and TDS do not change. Publishing the run marks the claim reimbursed. Booking or moving a claim after compute blocks the run until it is recomputed. A month whose run is finalized or published is refused. Claims marked reimbursed before this release are not picked up again.
21
+ - **Loan instalments never take more than the month's pay** (FB-4). The deduction is capped at what is left after every other deduction. Only the amount taken is booked, the rest stays owed, and the run shows a warning naming the person.
22
+ - **Re-enrol in a benefit after cover ends** (FB-17). The unique index on enrolments now covers only open rows (`ended_on IS NULL`), and the validation matches it. A new index on `work_mode_events.attendance_record_id` (PF-14).
23
+ - **Super Admin may change their own record** (SE-1). The edit is audited. HR and Leadership still cannot edit their own pay, profile or exit.
24
+ - **Menu** (NV-2): Tax checks, Team loans and Team benefits move from Pay to Admin.
25
+ - **Phone**: the Team loans and person-documents "Open" links are 44 px buttons.
26
+
10
27
  ## [0.55.0] - 2026-10-17
11
28
 
12
29
  The second pass of the HRMS audit of 0.54.0 covers bugs, security, performance and the remaining screen fixes (#115–#131). No migrations.
@@ -88,10 +88,17 @@
88
88
  form.submit();
89
89
  }
90
90
 
91
- function hold(headline) {
91
+ function hold(headline, status) {
92
92
  locating = false;
93
93
  if (button) { button.disabled = false; button.removeAttribute("aria-busy"); }
94
94
  showHelp(label, headline);
95
+ // Tell the server about the tap, so HR sees why the day has no check-in.
96
+ if (/\/check_(in|out)$/.test(form.action) && navigator.sendBeacon) {
97
+ var data = new FormData(form);
98
+ data.set("held", "1");
99
+ data.set("geo_status", status);
100
+ navigator.sendBeacon(form.action, data);
101
+ }
95
102
  }
96
103
 
97
104
  form.addEventListener("submit", function (event) {
@@ -102,7 +109,7 @@
102
109
  if (label) { label.hidden = false; label.classList.replace("hrl-errors", "hrl-muted"); label.textContent = "Getting location…"; }
103
110
 
104
111
  if (!navigator.geolocation) {
105
- if (required) hold("This browser cannot share your location. Open this page in Chrome or Safari.");
112
+ if (required) hold("This browser cannot share your location. Open this page in Chrome or Safari.", "unavailable");
106
113
  else send("unavailable");
107
114
  return;
108
115
  }
@@ -117,8 +124,8 @@
117
124
  function (err) {
118
125
  // Browsers ask only once; a past "Don't allow" denies silently forever.
119
126
  // Hold the punch so it is never filed (and flagged) without GPS.
120
- if (err && err.code === 1) { hold("Location is blocked. To allow it:"); return; }
121
- if (required) { hold("Couldn't get your location. Check that location is on:"); return; }
127
+ if (err && err.code === 1) { hold("Location is blocked. To allow it:", "denied"); return; }
128
+ if (required) { hold("Couldn't get your location. Check that location is on:", err && err.code === 3 ? "timeout" : "unavailable"); return; }
122
129
  send("timeout");
123
130
  },
124
131
  { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
@@ -141,6 +141,10 @@ module HrLite
141
141
  range = @period.from..@period.to
142
142
  @days = DayStatus.new(user: @employee, range: range)
143
143
  @punches = AttendancePunch.where(user_id: @employee.id, date: range).order(:at, :id).group_by(&:date)
144
+ refused = AuditLog.where(action: "attendance.punch_refused")
145
+ @refused = refused.where(subject_type: HrLite.user_klass.name, subject_id: @employee.id, created_at: range.first.beginning_of_day..range.last.end_of_day)
146
+ .order(:created_at).group_by { |log| log.created_at.to_date }
147
+ @tracked_from = refused.minimum(:created_at)&.to_date # before this, a day with no tries proves nothing
144
148
  @penalties = AttendancePenalty.active.where(user_id: @employee.id, date: range).group(:date).sum(:minutes)
145
149
  @balances = LeaveType.active.where(paid: true).map { |type| LeaveBalance.for(@employee, type, LeaveYear.current_key) }
146
150
  @requests = summary_requests(@employee, range)
@@ -114,8 +114,8 @@ module HrLite
114
114
 
115
115
  private
116
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 }
117
+ # Nobody but Super Admin edits or offboards their own record here (auto-approve would publish it).
118
+ def others_profile = EmployeeProfile.find(params[:id]).tap { |p| raise ActiveRecord::RecordNotFound if p.user_id == hr_current_user.id && !hr_superadmin? }
119
119
 
120
120
  def profile_params
121
121
  params.require(:employee_profile).permit(
@@ -34,9 +34,9 @@ module HrLite
34
34
  expense = decidable.find(params[:id])
35
35
  month = parse_month_param(params[:period_month])
36
36
  expense.reimburse!(actor: hr_current_user, period_month: month)
37
- redirect_to admin_expenses_path, notice: "Marked reimbursed with #{month.strftime('%B %Y')}."
37
+ redirect_to admin_expenses_path, notice: "Will be paid with #{month.strftime('%B %Y')} payroll."
38
38
  rescue ActiveRecord::RecordInvalid
39
- redirect_to admin_expenses_path, alert: "Only an approved claim can be reimbursed."
39
+ redirect_to admin_expenses_path, alert: "Only an approved claim can go on payroll, and only on a month not yet paid."
40
40
  end
41
41
 
42
42
  # The receipt the employee had to upload, for the person deciding; same rules as a document download.
@@ -38,10 +38,10 @@ module HrLite
38
38
 
39
39
  private
40
40
 
41
- # Nobody sets their own pay.
41
+ # Nobody but Super Admin sets their own pay (the owner has nobody above them).
42
42
  def set_profile
43
43
  @profile = EmployeeProfile.find(params[:employee_id])
44
- raise ActiveRecord::RecordNotFound if @profile.user_id == hr_current_user.id
44
+ raise ActiveRecord::RecordNotFound if @profile.user_id == hr_current_user.id && !hr_superadmin?
45
45
  end
46
46
 
47
47
  def structure_params
@@ -51,6 +51,13 @@ module HrLite
51
51
  private
52
52
 
53
53
  def punch(kind)
54
+ # The browser held the tap (location blocked or not found): log it, file nothing.
55
+ if params[:held]
56
+ geo = AttendancePuncher::GEO_STATUSES.include?(params[:geo_status]) ? params[:geo_status] : "unavailable"
57
+ log_refused(kind, "Location not picked up on the phone (#{geo})")
58
+ return head(:no_content)
59
+ end
60
+
54
61
  field = params.fetch(:field, {}).permit(:mode, :purpose, :place).to_h
55
62
  result = AttendancePuncher.call(user: hr_current_user, kind: kind, field: field, travel: params[:place] == "travel",
56
63
  save_home: params[:save_home] == "1" || params[:place] == "home", **punch_params.to_h.symbolize_keys)
@@ -58,11 +65,18 @@ module HrLite
58
65
  time = Time.current.strftime("%H:%M")
59
66
  redirect_to attendance_path, notice: "#{kind == :check_in ? 'Checked in' : 'Checked out'} at #{time}."
60
67
  else
68
+ log_refused(kind, result.error)
61
69
  flash[:punch_offer] = result.offer.to_s if result.offer
62
70
  redirect_to attendance_path, alert: result.error
63
71
  end
64
72
  end
65
73
 
74
+ # The person's summary lists these per day, so HR can see why a day is Absent.
75
+ def log_refused(kind, error)
76
+ AuditLog.record!(action: "attendance.punch_refused", subject: hr_current_user, actor: hr_current_user,
77
+ changes: { "kind" => kind.to_s, "error" => error })
78
+ end
79
+
66
80
  def punch_params
67
81
  params.permit(:lat, :lng, :accuracy_m, :geo_status)
68
82
  end
@@ -64,11 +64,7 @@ module HrLite
64
64
  { label: "Benefits", path: :benefits_path, match: [ "/benefits" ], if: -> { hr_can?("benefit.view") } },
65
65
  # HR approves payroll without running it; Super Admin runs it. One page.
66
66
  { label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips", "/admin/payroll_line_items" ],
67
- if: -> { hr_superadmin? || (hr_can?("payroll.approve", scope: :all) && instance_exec(&MANAGE_OR_CLAIMS)) } },
68
- { label: "Tax checks", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ],
69
- if: -> { hr_superadmin? || (hr_can?("tax.view", scope: :all) && instance_exec(&MANAGE_OR_CLAIMS)) } },
70
- { label: "Team loans", path: :admin_loans_path, match: [ "/admin/loans" ], if: SUPER },
71
- { label: "Team benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ], if: MANAGE }
67
+ if: -> { hr_superadmin? || (hr_can?("payroll.approve", scope: :all) && instance_exec(&MANAGE_OR_CLAIMS)) } }
72
68
  ] },
73
69
  { label: "People", match: [ "/people" ], tabs: [
74
70
  { label: "Team board", path: :team_path, match: [ "/team" ] },
@@ -99,7 +95,11 @@ module HrLite
99
95
  { label: "Statutory rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ], if: SUPER },
100
96
  { 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) } } },
101
97
  { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ], if: -> { hr_can?("asset.manage", scope: :all) } },
102
- { label: "Audit trail", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ], if: LEAD }
98
+ { label: "Audit trail", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ], if: LEAD },
99
+ { label: "Tax checks", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ],
100
+ if: -> { hr_superadmin? || (hr_can?("tax.view", scope: :all) && instance_exec(&MANAGE_OR_CLAIMS)) } },
101
+ { label: "Team loans", path: :admin_loans_path, match: [ "/admin/loans" ], if: SUPER },
102
+ { label: "Team benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ], if: MANAGE }
103
103
  ] }
104
104
  ].freeze
105
105
 
@@ -9,7 +9,9 @@ module HrLite
9
9
  belongs_to :user, class_name: HrLite.config.user_class
10
10
 
11
11
  validates :enrolled_on, presence: true
12
- validates :user_id, uniqueness: { scope: :benefit_id, message: "is already enrolled" }
12
+ # Mirrors the partial unique index: ended cover is history, not a clash.
13
+ validates :user_id, uniqueness: { scope: :benefit_id, conditions: -> { where(ended_on: nil) },
14
+ message: "is already enrolled" }, unless: :ended_on?
13
15
  validates :dependants, numericality: { greater_than_or_equal_to: 0 }
14
16
  validate :ended_after_it_started
15
17
 
@@ -53,21 +53,26 @@ module HrLite
53
53
  settle!("rejected", actor, note)
54
54
  end
55
55
 
56
- # Paid out with a payroll month. Kept separate from `approved` because
57
- # agreeing to pay somebody and actually paying them are different days,
58
- # and the person waiting cares about the second one.
56
+ # Booked onto a payroll month: that month's slip pays it, and publishing
57
+ # the run marks it reimbursed. Agreeing to pay somebody and actually
58
+ # paying them are different days, and the person waiting cares about the second.
59
59
  def reimburse!(actor:, period_month:)
60
+ months = [ reimbursed_in, period_month.beginning_of_month ].compact.uniq # moving it touches both runs
60
61
  raise ActiveRecord::RecordInvalid.new(self), "not approved" unless approved?
62
+ raise ActiveRecord::RecordInvalid.new(self), "month already paid" if PayrollRun.exists?(period_month: months, status: %w[finalized published])
61
63
 
62
64
  transaction do
63
- update!(status: "reimbursed", reimbursed_in: period_month.beginning_of_month)
65
+ update!(reimbursed_in: months.last)
64
66
  AuditLog.record!(action: "expense.reimbursed", subject: self, actor: actor,
65
67
  changes: { "employee" => HrLite.display_name(user),
66
68
  "period" => period_month.strftime("%B %Y") })
67
69
  end
70
+ PayrollRun.where(period_month: months, status: "review").find_each do |run|
71
+ run.update!(warnings: run.warnings + [ "#{HrLite.display_name(user)}'s #{category.name} claim changed after compute — recompute" ])
72
+ end
68
73
  Notifications.publish(
69
74
  "expense.reimbursed",
70
- title: "Your #{category.name} claim was reimbursed with #{period_month.strftime('%B %Y')} payroll",
75
+ title: "Your #{category.name} claim will be paid with #{period_month.strftime('%B %Y')} payroll",
71
76
  path: "/expenses", bell_to: [ user ], email_to: [ user ]
72
77
  )
73
78
  true
@@ -137,6 +137,9 @@ module HrLite
137
137
  transaction do
138
138
  update!(status: "published", published_at: Time.current, published_by_id: actor&.id)
139
139
  audit!("payroll.published", actor, "slips" => salary_slips.count)
140
+ # The claims these slips carry are paid now.
141
+ Expense.awaiting_reimbursement.where(reimbursed_in: period_month, user_id: salary_slips.select(:user_id))
142
+ .update_all(status: "reimbursed", updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
140
143
  end
141
144
 
142
145
  # config.slip_release_day holds the employee notice until the slip opens
@@ -50,6 +50,9 @@ module HrLite
50
50
  end
51
51
 
52
52
  slip.assign_attributes(attributes)
53
+ if slip.deductions_rows.any? { |row| row.dig("meta", "carried_forward") }
54
+ warnings << "#{HrLite.display_name(user)}'s loan instalment cut (pay too low); the rest is carried forward on the loan"
55
+ end
53
56
  slip.save!
54
57
  keep_ids << slip.id
55
58
  end
@@ -71,10 +71,8 @@ module HrLite
71
71
  deductions << { code: "pt", label: "Professional tax", amount: pt } if pt.positive?
72
72
 
73
73
  deductions.concat(extra_deductions)
74
- loan = loan_instalment
75
- deductions << loan if loan
76
74
 
77
- fy = SalarySlip.fy_to_date(@user, @run.period_month)
75
+ fy =SalarySlip.fy_to_date(@user, @run.period_month)
78
76
  tds = Calculators::Tds.call(
79
77
  regime: tax_regime,
80
78
  structure_monthly_gross: @structure.monthly_gross,
@@ -90,9 +88,16 @@ module HrLite
90
88
  rates: @rates[:income_tax],
91
89
  override: @tds_override
92
90
  )
91
+ loan = loan_instalment(gross_earned - deductions.sum(BigDecimal(0)) { |row| row[:amount] } - tds.monthly)
92
+ deductions << loan if loan
93
93
  deductions << { code: "tds", label: "Income tax (TDS)", amount: tds.monthly } if tds.monthly.positive?
94
94
 
95
95
  total_deductions = deductions.sum(BigDecimal(0)) { |row| row[:amount] }
96
+ # Claims booked onto this month: paid back with the salary but not
97
+ # salary, so outside gross and every PF, ESI, PT and TDS base.
98
+ claims = Expense.awaiting_reimbursement.where(user_id: @user.id, reimbursed_in: @run.period_month).includes(:category).map do |claim|
99
+ { code: "reimbursement", label: "#{claim.category.name} claim (reimbursement, outside gross)", amount: claim.amount }
100
+ end
96
101
 
97
102
  {
98
103
  period_month: @run.period_month,
@@ -104,7 +109,7 @@ module HrLite
104
109
  lop_days: Money.d(summary[:lop_days]),
105
110
  lop_override: @lop_override,
106
111
  tds_override: @tds_override,
107
- earnings: serialize_rows(earnings),
112
+ earnings: serialize_rows(earnings + claims),
108
113
  deductions: serialize_rows(deductions),
109
114
  employer_costs: employer_costs.transform_values { |v| v.to_s("F") }.to_json,
110
115
  tax_details: tds.details.to_json,
@@ -113,7 +118,7 @@ module HrLite
113
118
  # A heavy-LOP month can leave TDS (projected from the full structure)
114
119
  # larger than what was actually earned. Nobody is paid a negative
115
120
  # salary; PayrollRunProcessor warns when this floor bites.
116
- net_pay: [ gross_earned - total_deductions, BigDecimal(0) ].max,
121
+ net_pay: [ gross_earned + claims.sum(BigDecimal(0)) { |row| row[:amount] } - total_deductions, BigDecimal(0) ].max,
117
122
  computed_at: Time.current
118
123
  }
119
124
  end
@@ -172,13 +177,16 @@ module HrLite
172
177
  # The month's loan instalment, as one deduction line. Computed here and
173
178
  # BOOKED only when the run is finalized — a draft is recomputed freely,
174
179
  # and booking at compute would take a repayment on every pass.
175
- def loan_instalment
176
- total = Loan.active.where(user_id: @user.id).sum(BigDecimal(0)) do |loan|
180
+ # Never more than `room` (pay left after every other deduction); the rest stays owed.
181
+ def loan_instalment(room)
182
+ due = Loan.active.where(user_id: @user.id).sum(BigDecimal(0)) do |loan|
177
183
  loan.instalment_for(@run.period_month)
178
184
  end
179
- return nil unless total.positive?
185
+ return nil unless due.positive?
180
186
 
181
- { code: "loan_repayment", label: "Loan repayment", amount: total }
187
+ amount = room.clamp(BigDecimal(0), due)
188
+ row = { code: "loan_repayment", label: "Loan repayment", amount: amount }
189
+ amount < due ? row.merge(meta: { carried_forward: (due - amount).to_s("F") }) : row
182
190
  end
183
191
 
184
192
  # Months left in the Indian FY (Apr–Mar) including the run month itself:
@@ -45,6 +45,12 @@
45
45
  <% if punches.size > 2 %><div>All punches: <%= punches.join(" · ") %></div><% end %>
46
46
  <% if (minutes = @penalties[date]) %><div><span class="hrl-badge hrl-badge--warn">+<%= minutes %> min</span> penalty</div><% end %>
47
47
  <% if rec&.flagged? %><div>Flagged: <%= rec.flag_note %></div><% end %>
48
+ <% @refused[date].to_a.each do |log| %>
49
+ <div><%= log.audited_changes["kind"] == "check_out" ? "Check-out" : "Check-in" %> tried <%= log.created_at.strftime("%H:%M") %> — refused: <%= log.audited_changes["error"] %></div>
50
+ <% end %>
51
+ <% if day.kind == :absent && !not_in_yet && @refused[date].nil? && @tracked_from && date >= @tracked_from %>
52
+ <div class="hrl-muted">No check-in tried</div>
53
+ <% end %>
48
54
  </div>
49
55
  <% if fix && date <= Date.current %>
50
56
  <a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@employee.id, date: date, month: date.strftime("%Y-%m"), anchor: "regularize") %>">Fix<span class="hrl-sr-only"> <%= date.strftime("%-d %b") %></span></a>
@@ -32,7 +32,7 @@
32
32
  </td>
33
33
  <td data-label="">
34
34
  <% if doc.readable_by?(hr_current_user) && doc.file.attached? %>
35
- <a class="hrl-small" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
35
+ <a class="hrl-btn" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
36
36
  <% end %>
37
37
  </td>
38
38
  </tr>
@@ -1,7 +1,7 @@
1
1
  <% content_for(:page_title) { @profile.employee_code } %>
2
2
  <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @profile.user } %>
3
3
  <% content_for(:back, "Employees") %>
4
- <% own = @profile.user_id == hr_current_user.id # your own pay and record are for someone else to change %>
4
+ <% own = @profile.user_id == hr_current_user.id && !hr_superadmin? # your own pay and record are for someone else to change, unless you are Super Admin %>
5
5
  <div class="hrl-page__head">
6
6
  <h1 class="hrl-page__title"><%= hr_display_name(@profile.user) %> <span class="hrl-muted">(<%= @profile.employee_code %>)</span></h1>
7
7
  <div class="hrl-page__actions">
@@ -42,8 +42,8 @@
42
42
  <% elsif expense.approved? %>
43
43
  <%= form_with url: hr_lite.reimburse_admin_expense_path(expense), method: :post do %>
44
44
  <%= label_tag "reimburse-month-#{expense.id}", "Paid with the payroll of", class: "hrl-sr-only" %>
45
- <%= month_field_tag :period_month, Date.current.strftime("%Y-%m"), id: "reimburse-month-#{expense.id}", required: true, class: "hrl-input" %>
46
- <%= submit_tag "Mark reimbursed", class: "hrl-btn" %>
45
+ <%= month_field_tag :period_month, (expense.reimbursed_in || Date.current).strftime("%Y-%m"), id: "reimburse-month-#{expense.id}", required: true, class: "hrl-input" %>
46
+ <%= submit_tag "Pay with payroll", class: "hrl-btn" %>
47
47
  <% end %>
48
48
  <% end %>
49
49
  </td>
@@ -31,7 +31,7 @@
31
31
  <td data-label="Instalment" class="hrl-num"><%= hrl_money(loan.monthly_instalment) %></td>
32
32
  <td data-label="Outstanding" class="hrl-num"><%= hrl_money(loan.outstanding) %></td>
33
33
  <td data-label="">
34
- <a class="hrl-small" href="<%= hr_lite.admin_loan_path(loan) %>">Open</a>
34
+ <a class="hrl-btn" href="<%= hr_lite.admin_loan_path(loan) %>">Open</a>
35
35
  </td>
36
36
  </tr>
37
37
  <% end %>
@@ -0,0 +1,8 @@
1
+ class MakeHrLiteBenefitEnrolmentsUniqueWhileOpen < ActiveRecord::Migration[8.1]
2
+ # One open enrolment per person per plan; ended ones stay as history.
3
+ def change
4
+ remove_index :hr_lite_benefit_enrolments, %i[benefit_id user_id], unique: true, if_exists: true
5
+ add_index :hr_lite_benefit_enrolments, %i[benefit_id user_id], unique: true, where: "ended_on IS NULL",
6
+ name: "index_hr_lite_benefit_enrolments_one_open", if_not_exists: true
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ class AddAttendanceRecordIdIndexToHrLiteWorkModeEvents < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_index :hr_lite_work_mode_events, :attendance_record_id, if_not_exists: true
4
+ end
5
+ end
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.55.0"
2
+ VERSION = "0.57.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.55.0
4
+ version: 0.57.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -505,6 +505,8 @@ files:
505
505
  - db/migrate/20261016090000_add_penalty_leave_type_to_hr_lite_settings.rb
506
506
  - db/migrate/20261016091000_create_hr_lite_penalty_removals.rb
507
507
  - db/migrate/20261016100000_add_hours_allowance_repeat_to_hr_lite_employee_profiles.rb
508
+ - db/migrate/20261017161441_make_hr_lite_benefit_enrolments_unique_while_open.rb
509
+ - db/migrate/20261017161443_add_attendance_record_id_index_to_hr_lite_work_mode_events.rb
508
510
  - lib/generators/hr_lite/install/install_generator.rb
509
511
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
510
512
  - lib/generators/hr_lite/install/templates/initializer.rb