hr_lite 0.18.0 → 0.20.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: c73e1512e67fbae1abb7b12f6134564974819e2055c937e3fabcb294a196bea9
4
- data.tar.gz: 93bd305c7aa148e6e2f034f9355cc15078ddf8252cf0af92f938da136b945ac1
3
+ metadata.gz: 308cf87cc748758a71ff2bafc7cc301e7ff1fa3f0efc0e0b222e7e4e82e30161
4
+ data.tar.gz: 8541bec2dbd4b8c6c1b895065a9580f65de22ce01d128c86c4bc52455aa2bdc0
5
5
  SHA512:
6
- metadata.gz: f4c2331de1a65bbfb074277c9a309b1276c213a55747fd8f8446c5d54c72a41edfb3087a950bfd19aff5fc00ed2b96130e20d200583e9e8970dc0edd10750806
7
- data.tar.gz: 25103c4830b09f09e13996557e926dfb4bde694fb0cb60a8f10d8adb355ba4f25d553b226ec38f039df395a339a832c4822ecd2a4aaa5ebbf5b6f111a957e88c
6
+ metadata.gz: 7b6788e0da35e5695257ebf95e2a25b152a8c42d9659d449197995061b57220d34ae6503986f6aa80497cd55dc74e44d0ea2247126cf48924cead29d2c2f40f8
7
+ data.tar.gz: 06d8209caa183f2092611e9d7613b12a7d1cb45e3333a9ad0b0d1027e4f5ed48e6845e9b6766e6ed8f1352ca537c8921bf85e5e23af9b49876b75021273f645b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.0] - 2026-10-01
11
+
12
+ Salary as CTC, and who is out today. No migration.
13
+
14
+ ### Added
15
+
16
+ - **Fill from CTC.** The salary structure form takes an annual CTC, and
17
+ "Fill from CTC" splits it: Basic is half the monthly CTC, HRA is 40% of
18
+ Basic, the employer's PF and ESI are paid out of the CTC, and Special
19
+ allowance takes the rest, rounding included. Nothing is saved until Save,
20
+ which stores the lines shown; the CTC itself is never stored. A CTC too low
21
+ for the split is refused. The form shows the monthly gross and CTC a
22
+ structure works out to, and prefills the CTC on edit. Both use the rate
23
+ card of the month the structure takes effect, and the typed CTC never
24
+ reaches the audit log.
25
+ - **`SalaryStructure#breakup`** gives one month of a structure: earnings, the
26
+ employee's PF, ESI and professional tax, the employer's PF and ESI, gross,
27
+ CTC and in-hand, from the existing statutory calculators. ESI is decided on
28
+ the salary that opened the ESIC period, as payroll does, and the yearly
29
+ professional tax is summed month by month so a February top-up counts once.
30
+ - **My salary.** The salary slips page opens with the employee's current
31
+ structure as a Monthly | Yearly table, a structure HR has saved for a later
32
+ month ("New salary from …"), and earlier ones with their yearly CTC. Income
33
+ tax stays on the slips. Slip release day does not hide the card.
34
+ - **Out today** on Home: everyone on approved leave today, by name, with the
35
+ leave's dates (`01 Oct – 05 Oct`, or `01 Oct (half day)`), and a link to the
36
+ Team board. The leave type is not shown, and staff who have left are not
37
+ listed.
38
+ - **Team board** shows each leave's dates next to "On leave", and hovering a
39
+ name on the board or on Out today reads "On leave: 30 Sep – 03 Oct".
40
+
41
+ ### Fixed
42
+
43
+ - Form action buttons wrap on a narrow phone, so a third button no longer
44
+ pushes the salary structure form sideways.
45
+
46
+ ### Upgrading
47
+
48
+ No migration, no config.
49
+
50
+ ## [0.19.0] - 2026-09-30
51
+
52
+ Each night closes the day. A missed check-out becomes a half day, and
53
+ employees can fix a recent missed punch themselves. No migration.
54
+
55
+ ### Added
56
+
57
+ - **`HrLite::AttendanceCloseJob`**, scheduled by the host each night (a run
58
+ before noon closes the previous day, so retries are safe). On a working
59
+ day it checks out every punch still open at the day's end (23:59), marks the
60
+ day `half_day` (payroll pays
61
+ half and counts half as loss of pay), and emails the employee. Anyone who
62
+ never checked in is emailed that the day is absent; no record is written.
63
+ Weekends, holidays and approved leave (full or half day) are skipped. Run it
64
+ once a day: a re-run emails the no-shows again.
65
+ - **Self-fix.** With `config.self_regularization = { within_days:, per_week: }`,
66
+ a ticket that fills a missed punch on a working day, today or up to
67
+ `within_days` back, is approved as soon as the employee submits it, for up to
68
+ `per_week` days in a Monday–Sunday week. The punch is written the way an HR
69
+ approval writes it, with the usual audit row, and nobody is notified. Older
70
+ days, off days, fixes past the weekly limit, a change to a real punch, and a
71
+ day HR already regularized or rejected go to HR as ordinary tickets; the
72
+ flash says which happened. A check-out-only ticket for a day with no check-in
73
+ stays pending for HR.
74
+ `nil` (default) keeps HR as the only one who fixes punches. The ticket form
75
+ states the rule when it is on.
76
+ - The Leaves page links "Record leave for someone" for anyone who can approve
77
+ leave.
78
+ - The record-leave form says why somebody may be missing from the employee
79
+ list (no HR profile yet). Leadership gets a link to set one up.
80
+
81
+ ### Changed
82
+
83
+ - Approving a regularization ticket on a day the close job marked half day
84
+ sets it back to present. A half day HR set on purpose stays.
85
+ - Approving a ticket for a month whose payroll run is in review adds a warning
86
+ to that run, so the slip is recomputed before it is finalized.
87
+ - The admin overview and daily digest still count a day the close job checked
88
+ out as a missing checkout.
89
+
90
+ ### Fixed
91
+
92
+ - The sidebar, and the phone's More sheet, open scrolled to the current screen
93
+ instead of the top. A Manage item such as Approvals is no longer below the
94
+ fold.
95
+ - Page action buttons wrap on a narrow phone instead of running off the screen.
96
+
97
+ ### Upgrading
98
+
99
+ Schedule `HrLite::AttendanceCloseJob` daily at `55 23 * * * Asia/Kolkata` in
100
+ your job scheduler (a run before noon closes the previous day). To let employees fix their own recent misses, set
101
+ `c.self_regularization = { within_days: 2, per_week: 2 }` in the initializer.
102
+ No migration.
103
+
10
104
  ## [0.18.0] - 2026-09-29
11
105
 
12
106
  Salary follows leave. One migration (`paid_days` on leave requests, nullable;
data/README.md CHANGED
@@ -142,6 +142,9 @@ HrLite.configure do |c|
142
142
  PdfRenderer.render(template: template, assigns: assigns, cache_key: cache_key)
143
143
  }
144
144
  c.slip_release_day = 10 # employees see a slip from the 10th of the next month; nil (default) = on publish
145
+ # Self-fix: employees correct their own missed punch within 2 days, at most
146
+ # 2 a week. nil (default) = only HR fixes it, via a regularization ticket.
147
+ c.self_regularization = { within_days: 2, per_week: 2 }
145
148
 
146
149
  # Mirror promotions into your own user model (optional).
147
150
  c.on_designation_change = ->(user, designation) { user.update!(designation: designation) }
@@ -267,6 +270,16 @@ approved; regularization tickets (`/regularization_requests`) let someone who
267
270
  forgot to punch propose the real times for admin approval, which writes them
268
271
  onto the attendance record with a full audit trail.
269
272
 
273
+ With `config.self_regularization = { within_days: 2, per_week: 2 }`, a ticket that
274
+ fills a missed punch on a working day, today or up to 2 days back, is approved on
275
+ submit, by the employee, at most twice per Mon–Sun week. No approver is notified;
276
+ the audit row is the trail. Older days, off days, the third fix of a week, a change
277
+ to a real punch, a day HR already regularized or rejected, or a fix that cannot
278
+ apply (no check-in that day) stay ordinary tickets for HR.
279
+
280
+ Home has an "Out today" card: everyone on approved leave today, with the leave's
281
+ dates, so the team knows who is away and until when. The leave type is not shown.
282
+
270
283
  ### Holidays and calendar
271
284
 
272
285
  `bin/rails hr_lite:seed` inserts the three fixed-date national holidays
@@ -296,6 +309,12 @@ WickedPdf path if `wicked_pdf` is in the bundle. The exact math, rounding rules
296
309
  and what is deliberately not modelled are in `docs/PAYROLL.md` — read it with
297
310
  your accountant before the first run.
298
311
 
312
+ HR can enter a structure as an annual CTC: "Fill from CTC" sets Basic to half the
313
+ monthly CTC and HRA to 40% of Basic, pays the employer's PF and ESI out of the CTC,
314
+ and puts the rest in Special allowance. Employees see their own breakup, monthly
315
+ and yearly, at the top of `/salary_slips`, along with any revision HR has saved
316
+ for a later month.
317
+
299
318
  ### Kudos and @mentions
300
319
 
301
320
  The kudos wall (`/kudos`) lets staff thank each other with @mentions. The picker
@@ -353,6 +372,7 @@ whatever you run). Each is idempotent and sends nothing on a quiet day.
353
372
  | `HrLite::DailyDigestJob` | each morning | Leadership digest: who is out today, pending approvals, flagged punches, missing checkouts |
354
373
  | `HrLite::PayrollAutoDraftJob` | monthly, on the 1st | Draft and compute the previous month's payroll from attendance, then notify leadership for review (publishing stays a human action) |
355
374
  | `HrLite::LeaveYearRolloverJob` | leave year's first day (Jan 1, or Jul 1 for a July–June year) | Materialize carry-forward into the new year's balances |
375
+ | `HrLite::AttendanceCloseJob` | each night, `55 23 * * * Asia/Kolkata` | Close the day: an open punch is checked out at 23:59 and marked half day; a run before noon (a retry, or a morning schedule that lets night shifts check out first) closes the previous day instead; nobody checked in stays absent. Each is emailed how to fix it. Skips weekends, holidays and approved leave. Run it once a day — a re-run tells the no-shows again. |
356
376
 
357
377
  ## Rake tasks
358
378
 
@@ -0,0 +1,21 @@
1
+ // Keeps the current screen in view. The rail and the phone's More sheet both
2
+ // scroll, and a Manage item sits below the fold. scrollIntoView is avoided on
3
+ // purpose: it would scroll the page as well as the menu.
4
+ (function () {
5
+ function centre(panel) {
6
+ var link = panel && panel.querySelector(".hrl-nav__link--active");
7
+ if (!link) return;
8
+ var box = panel.getBoundingClientRect();
9
+ var item = link.getBoundingClientRect();
10
+ panel.scrollTop += item.top - box.top - (box.height - item.height) / 2;
11
+ }
12
+
13
+ centre(document.querySelector(".hrl-side"));
14
+
15
+ var more = document.querySelector(".hrl-tabbar__more");
16
+ if (more) {
17
+ more.addEventListener("toggle", function () {
18
+ if (more.open) centre(more.querySelector(".hrl-sheet"));
19
+ });
20
+ }
21
+ })();
@@ -143,7 +143,7 @@
143
143
  .hrl-page__head { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; margin: .25rem 0 .9rem; }
144
144
  .hrl-page__title { font-size: 1.25rem; font-weight: 700; margin: 0; }
145
145
  .hrl-page__sub { color: var(--hrl-muted); font-size: .86rem; margin: .15rem 0 0; width: 100%; }
146
- .hrl-page__actions { margin-left: auto; display: flex; gap: .5rem; }
146
+ .hrl-page__actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: .5rem; }
147
147
 
148
148
  /* Cards */
149
149
  .hrl-card {
@@ -195,6 +195,7 @@
195
195
  .hrl-table th, .hrl-table td { text-align: left; padding: .6rem .7rem; border-bottom: 1px solid var(--hrl-line); vertical-align: top; }
196
196
  .hrl-table th { color: var(--hrl-muted); font-size: .76rem; text-transform: uppercase; letter-spacing: .03em; }
197
197
  .hrl-table td.hrl-num, .hrl-table th.hrl-num { text-align: right; font-variant-numeric: tabular-nums; }
198
+ .hrl-table--tight th, .hrl-table--tight td { padding: .5rem .35rem; }
198
199
  @media (max-width: 640px) {
199
200
  .hrl-table--stack thead { display: none; }
200
201
  .hrl-table--stack tr { display: block; border-bottom: 6px solid var(--hrl-bg); }
@@ -227,7 +228,7 @@
227
228
  .hrl-field--check label { margin-bottom: 0; font-weight: 600; }
228
229
  .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: .86rem; }
229
230
  .hrl-errors ul { margin: .3rem 0 0; padding-left: 1.1rem; }
230
- .hrl-form-actions { display: flex; gap: .6rem; margin-top: 1rem; }
231
+ .hrl-form-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 1rem; }
231
232
  .hrl-form-actions .hrl-btn { flex: 1; }
232
233
  @media (min-width: 700px) { .hrl-form-actions .hrl-btn { flex: 0 0 auto; min-width: 9rem; } }
233
234
 
@@ -12,6 +12,8 @@ module HrLite
12
12
  @structure = SalaryStructure.new(structure_params.merge(
13
13
  user_id: @profile.user_id, created_by_id: hr_current_user.id
14
14
  ))
15
+ return fill_from_ctc(:new) if params[:fill_from_ctc]
16
+
15
17
  if @structure.save
16
18
  redirect_to admin_employee_path(@profile), notice: "Salary structure saved."
17
19
  else
@@ -25,7 +27,10 @@ module HrLite
25
27
 
26
28
  def update
27
29
  @structure = SalaryStructure.where(user_id: @profile.user_id).find(params[:id])
28
- if @structure.update(structure_params)
30
+ @structure.assign_attributes(structure_params)
31
+ return fill_from_ctc(:edit) if params[:fill_from_ctc]
32
+
33
+ if @structure.save
29
34
  redirect_to admin_employee_path(@profile), notice: "Salary structure updated."
30
35
  else
31
36
  render :edit, status: :unprocessable_entity
@@ -34,13 +39,22 @@ module HrLite
34
39
 
35
40
  private
36
41
 
42
+ # Shows the split for a check; only the Save button writes it.
43
+ def fill_from_ctc(view)
44
+ @structure.fill_from_ctc
45
+ return render(view, status: :unprocessable_entity) unless @structure.valid?
46
+
47
+ flash.now[:notice] = "Filled from CTC — check the lines, then Save."
48
+ render view
49
+ end
50
+
37
51
  def set_profile
38
52
  @profile = EmployeeProfile.find(params[:employee_id])
39
53
  end
40
54
 
41
55
  def structure_params
42
56
  params.require(:salary_structure).permit(
43
- :effective_from, :basic, :hra, :special_allowance, :other_earnings,
57
+ :annual_ctc, :effective_from, :basic, :hra, :special_allowance, :other_earnings,
44
58
  :pf_applicable, :pf_on_full_basic, :esi_applicable, :pt_state, :notes
45
59
  )
46
60
  end
@@ -2,6 +2,9 @@ module HrLite
2
2
  class HomeController < ApplicationController
3
3
  def index
4
4
  @latest_kudos = Kudo.recent.includes(:giver, kudo_mentions: :user).limit(3)
5
+ @out_today = LeaveRequest.active_on(Date.current).includes(:user)
6
+ .where(user_id: HrLite.active_employees.map(&:id))
7
+ .sort_by { |leave| hr_display_name(leave.user).downcase }
5
8
  end
6
9
  end
7
10
  end
@@ -13,7 +13,7 @@ module HrLite
13
13
  def create
14
14
  @request = RegularizationRequest.new(request_params.merge(user_id: hr_current_user.id))
15
15
  if @request.save
16
- redirect_to regularization_requests_path, notice: "Ticket raised — an admin will review it."
16
+ redirect_to regularization_requests_path, notice: create_notice
17
17
  else
18
18
  render :new, status: :unprocessable_entity
19
19
  end
@@ -29,6 +29,15 @@ module HrLite
29
29
 
30
30
  private
31
31
 
32
+ def create_notice
33
+ rule = HrLite.config.self_regularization
34
+ return "Ticket raised — an admin will review it." unless rule
35
+ return "Fixed. #{helpers.pluralize(@request.self_fixes_left, 'self-fix')} left this week." if @request.approved?
36
+
37
+ "Sent to HR as a ticket — you can fix only a missed punch on a working day in the last #{rule[:within_days]} days yourself, " \
38
+ "#{rule[:per_week]} times a week."
39
+ end
40
+
32
41
  def request_params
33
42
  params.require(:regularization_request).permit(:date, :check_in_at, :check_out_at, :reason)
34
43
  end
@@ -4,6 +4,11 @@ module HrLite
4
4
  class SalarySlipsController < ApplicationController
5
5
  def index
6
6
  @slips = paginate(own_released.recent_first)
7
+ # Structures show as soon as HR saves them; slip_release_day gates slips only.
8
+ @upcoming, started = SalaryStructure.where(user_id: hr_current_user.id).order(:effective_from)
9
+ .partition { |s| s.effective_from > Date.current }
10
+ @structure = started.pop
11
+ @past = started.reverse
7
12
  end
8
13
 
9
14
  def show
@@ -131,6 +131,9 @@ module HrLite
131
131
  HrLite::Money.format(amount)
132
132
  end
133
133
 
134
+ # Whole rupees, for salary figures that are planned rather than paid.
135
+ def hrl_rupees(amount) = hrl_money(amount.round).delete_suffix(".00")
136
+
134
137
  # Indian-system amount in words for the slip footer. Delegates to the
135
138
  # PORO so host-rendered templates (config.render_pdf) work too.
136
139
  def hrl_amount_in_words(amount)
@@ -149,7 +152,7 @@ module HrLite
149
152
  case row.kind
150
153
  when :holiday then hrl_status_badge("Holiday", "hrl-badge--muted", worked_hint(row))
151
154
  when :weekend then hrl_status_badge("Weekend", "hrl-badge--muted", worked_hint(row))
152
- when :leave then hrl_status_badge("On leave (#{row.leave.leave_type.code})", "hrl-badge--warn")
155
+ when :leave then hrl_status_badge("On leave (#{row.leave.leave_type.code})", "hrl-badge--warn", row.leave.date_range_label)
153
156
  when :half_day_leave then hrl_status_badge("Half-day leave (#{row.leave.leave_type.code})", "hrl-badge--warn", worked_hint(row))
154
157
  when :upcoming then hrl_status_badge("\u2014", "hrl-badge--muted")
155
158
  when :absent
@@ -0,0 +1,42 @@
1
+ module HrLite
2
+ # Closes the working day that ended last: a run at 23:55 closes today, and a
3
+ # retry or late run after midnight (or a morning schedule) closes yesterday.
4
+ # A punch left open is checked out at the day's end and paid as a half day.
5
+ # Nobody punched in already reads as absent (DayStatus), so they are only
6
+ # told. Leave days are left alone.
7
+ class AttendanceCloseJob < ApplicationJob
8
+ def perform(date: (Time.current - 12.hours).to_date)
9
+ return unless WorkingCalendar.new(date..date).working_day?(date)
10
+
11
+ on_leave = LeaveRequest.active_on(date).pluck(:user_id)
12
+ records = AttendanceRecord.for_date(date).index_by(&:user_id)
13
+ day = date.strftime("%d %b")
14
+ HrLite.active_employees(on: date).each do |user|
15
+ next if on_leave.include?(user.id)
16
+
17
+ record = records[user.id]
18
+ if record.nil? || record.check_in_at.nil?
19
+ tell(user, date, "attendance.missed_check_in", "You didn't check in on #{day} — marked absent")
20
+ elsif record.check_out_at.nil?
21
+ record.update!(check_out_at: date.end_of_day, status: "half_day",
22
+ regularization_note: AttendanceRecord::AUTO_CHECKOUT_NOTE)
23
+ tell(user, date, "attendance.missed_check_out", "You didn't check out on #{day} — marked half day")
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def tell(user, date, event, title)
31
+ rule = HrLite.config.self_regularization
32
+ body = if rule
33
+ "Fix it yourself by #{(date + rule[:within_days]).strftime('%d %b')} (up to #{rule[:per_week]} a week), " \
34
+ "or raise a ticket for HR."
35
+ else
36
+ "Raise a regularization ticket to get it fixed."
37
+ end
38
+ Notifications.publish(event, title: title, body: body, path: "/regularization_requests/new?date=#{date.iso8601}",
39
+ bell_to: [ user ], email_to: [ user ])
40
+ end
41
+ end
42
+ end
@@ -46,7 +46,8 @@ module HrLite
46
46
  when "destroy"
47
47
  { "_destroyed" => hr_lite_audit_label }
48
48
  else
49
- saved_changes.except(*SKIPPED_ATTRIBUTES).to_h do |attr, (from, to)|
49
+ # Columns only: a virtual attribute (a typed annual CTC) is form input, not a stored change.
50
+ saved_changes.slice(*self.class.column_names).except(*SKIPPED_ATTRIBUTES).to_h do |attr, (from, to)|
50
51
  if encrypted.include?(attr)
51
52
  [ attr, REDACTED ]
52
53
  else
@@ -1,6 +1,7 @@
1
1
  module HrLite
2
2
  class AttendanceRecord < ApplicationRecord
3
3
  STATUSES = %w[present half_day].freeze
4
+ AUTO_CHECKOUT_NOTE = "Auto check-out: no check-out punched".freeze
4
5
 
5
6
  belongs_to :user, class_name: HrLite.config.user_class
6
7
  belongs_to :regularized_by, class_name: HrLite.config.user_class, optional: true
@@ -21,7 +21,7 @@ module HrLite
21
21
  validate :no_approved_leave_conflict
22
22
  end
23
23
 
24
- after_create :notify_requested
24
+ after_create :self_fix_or_notify
25
25
 
26
26
  scope :pending, -> { where(status: "pending") }
27
27
  scope :recent_first, -> { order(date: :desc, id: :desc) }
@@ -39,6 +39,27 @@ module HrLite
39
39
  AttendanceRecord.find_by(user_id: user_id, date: date)
40
40
  end
41
41
 
42
+ # config.self_regularization: a missed punch on a recent working day, under
43
+ # the weekly cap, is fixed by the employee without HR. A real punch, or a
44
+ # day HR already decided or rejected, stays HR's call.
45
+ def self_fixable?
46
+ rule = HrLite.config.self_regularization
47
+ return false unless rule && date >= Date.current - rule[:within_days] && WorkingCalendar.new(date..date).working_day?(date)
48
+
49
+ day = punch
50
+ fills_gap = (check_in_at.nil? || !day&.check_in_at) &&
51
+ (check_out_at.nil? || !day&.check_out_at || day.regularization_note == AttendanceRecord::AUTO_CHECKOUT_NOTE)
52
+ fills_gap && !day&.regularized? && !self.class.exists?(user_id: user_id, date: date, status: "rejected") &&
53
+ self_fixes_left.positive?
54
+ end
55
+
56
+ # Self-fixes the user still has in this ticket's Mon–Sun week.
57
+ def self_fixes_left
58
+ week = date.beginning_of_week(:monday)..date.end_of_week(:monday)
59
+ used = self.class.where(user_id: user_id, decided_by_id: user_id, status: "approved", date: week).count
60
+ HrLite.config.self_regularization[:per_week] - used
61
+ end
62
+
42
63
  # --- transitions -------------------------------------------------------
43
64
 
44
65
  # Applies the proposed times to the day's record (creating it if the
@@ -47,7 +68,7 @@ module HrLite
47
68
  # A merge that would produce a nonsense record (checkout before the
48
69
  # existing check-in, or a checkout with no check-in at all) raises
49
70
  # InvalidMerge with the real story so the admin knows what to fix.
50
- def approve!(actor:, note: nil)
71
+ def approve!(actor:, note: nil, notify: true)
51
72
  transition!("approved", actor, note) do
52
73
  # Re-checked at approval, not only at create: leave can be approved for
53
74
  # that day while the ticket waits in the queue, and DayStatus ranks
@@ -64,7 +85,8 @@ module HrLite
64
85
  raise InvalidMerge, "the day has no check-in — the ticket needs a check-in time too"
65
86
  end
66
87
 
67
- record.status = "present" if record.status.blank?
88
+ # Lifts only the close job's half day; one HR set on purpose stays.
89
+ record.status = "present" if record.status.blank? || record.regularization_note == AttendanceRecord::AUTO_CHECKOUT_NOTE
68
90
  record.regularized_by_id = actor.id
69
91
  record.regularized_at = Time.current
70
92
  record.regularization_note = "Ticket ##{id}: #{reason}"
@@ -75,7 +97,12 @@ module HrLite
75
97
  action: "regularize", subject: record, actor: actor,
76
98
  changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
77
99
  )
100
+ # A slip computed before this fix still counts the old day; a recompute clears the note.
101
+ run = PayrollRun.find_by(period_month: date.beginning_of_month, status: "review")
102
+ run&.update!(warnings: run.warnings + [ "#{HrLite.display_name(user)}'s attendance on #{date.strftime('%d %b')} " \
103
+ "was fixed after compute — recompute" ])
78
104
  end
105
+ return true unless notify # self-fix: the flash and the audit row are the trail
79
106
 
80
107
  Notifications.publish(
81
108
  "regularization.approved",
@@ -174,6 +201,16 @@ module HrLite
174
201
  errors.add(:date, "is covered by your approved leave — cancel the leave first") if leave.exists?
175
202
  end
176
203
 
204
+ # A self-fix approve! can't merge (e.g. no check-in yet) -> an ordinary ticket.
205
+ def self_fix_or_notify
206
+ user.lock! if HrLite.config.self_regularization # one user's tickets at once can't both spend the last fix
207
+ return approve!(actor: user, note: "Self-fixed", notify: false) if self_fixable?
208
+
209
+ notify_requested
210
+ rescue InvalidMerge
211
+ notify_requested
212
+ end
213
+
177
214
  def notify_requested
178
215
  Notifications.publish(
179
216
  "regularization.requested",
@@ -11,12 +11,15 @@ module HrLite
11
11
  belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
12
12
 
13
13
  encrypted_money :basic, :hra, :special_allowance, :other_earnings
14
+ # Typed on the form to fill the lines; CTC is always derived, never stored.
15
+ attribute :annual_ctc, :decimal
14
16
 
15
17
  validates :effective_from, presence: true, uniqueness: { scope: :user_id }
16
18
  validates :basic, presence: true
17
19
  validate :basic_positive
18
20
  validate :effective_from_is_first_of_month
19
21
  validates :pt_state, presence: true
22
+ validate { errors.add(:base, "CTC is too low for this split") if special_allowance&.negative? }
20
23
 
21
24
  def self.effective_for(user, period_month)
22
25
  where(user_id: user.id)
@@ -33,6 +36,60 @@ module HrLite
33
36
  monthly_gross * 12
34
37
  end
35
38
 
39
+ # A month of this structure: earnings, the employee's statutory
40
+ # deductions and the employer's contributions, via the payroll calculators.
41
+ def breakup(on: Date.current)
42
+ month = on.beginning_of_month
43
+ rates = StatutoryRateCard.for(month)
44
+ gross = monthly_gross
45
+ pf = Calculators::Pf.call(basic_earned: basic, on_full_basic: pf_on_full_basic, rates: rates[:pf]) if pf_applicable
46
+ esi = Calculators::Esi.call(monthly_gross: esi_reference_gross(month), gross_earned: gross, applicable: esi_applicable, rates: rates[:esi])
47
+ # Not flat: some states top up February, so the year is summed month by month.
48
+ pts = (0..11).map { |i| Calculators::ProfessionalTax.call(state: pt_state, gross_earned: gross, period_month: month >> i, rates: rates[:pt]) }
49
+ earnings, deductions, employer = [
50
+ { "Basic" => basic, "HRA" => hra, "Special allowance" => special_allowance, "Other" => other_earnings },
51
+ { "PF" => pf&.employee, "ESI" => esi.employee, "Professional tax" => pts.first },
52
+ { "PF" => pf && (pf.employer_eps + pf.employer_epf), "ESI" => esi.employer }
53
+ ].map { |rows| rows.select { |_, amount| Money.d(amount).positive? } }
54
+ monthly_ctc = gross + employer.values.sum(BigDecimal(0))
55
+ in_hand = gross - deductions.values.sum(BigDecimal(0))
56
+ { earnings: earnings, deductions: deductions, employer: employer,
57
+ monthly_gross: gross, monthly_ctc: monthly_ctc, annual_ctc: monthly_ctc * 12, in_hand: in_hand,
58
+ yearly: { "Professional tax" => pts.sum, "In-hand before tax" => (in_hand + pts.first) * 12 - pts.sum } }
59
+ end
60
+
61
+ # ESIC contribution periods run April–September and October–March.
62
+ # Eligibility is fixed for the whole period, so it is decided on the
63
+ # salary in force on its first day — re-deciding it every month dropped
64
+ # someone out of ESI the moment a mid-period raise crossed the ceiling.
65
+ def esi_reference_gross(month)
66
+ start = if month.month.between?(4, 9)
67
+ Date.new(month.year, 4, 1)
68
+ elsif month.month >= 10
69
+ Date.new(month.year, 10, 1)
70
+ else
71
+ Date.new(month.year - 1, 10, 1)
72
+ end
73
+
74
+ # Started mid-period: the structure in force when it opened decides (a mid-period joiner has none).
75
+ earlier = self.class.effective_for(user, start) if effective_from&.after?(start)
76
+ (earlier || self).monthly_gross
77
+ end
78
+
79
+ # Owner's split of annual_ctc: Basic 50% of the monthly CTC, HRA 40% of
80
+ # Basic, employer PF and ESI paid out of the CTC; Special takes the rest.
81
+ def fill_from_ctc(on: effective_from || Date.current)
82
+ monthly_ctc = Money.round_rupee(Money.d(annual_ctc) / 12)
83
+ self.basic = Money.round_rupee(monthly_ctc / 2)
84
+ self.hra = Money.round_rupee(basic * BigDecimal("0.4"))
85
+ self.special_allowance = 0
86
+ rest = monthly_ctc - Money.d(breakup(on: on).dig(:employer, "PF"))
87
+ # Employer ESI is a share of the gross beside it: gross + ESI = rest.
88
+ gross = (rest / (1 + StatutoryRateCard.for(on.beginning_of_month)[:esi][:employer_rate])).floor
89
+ self.special_allowance = gross - basic - hra - Money.d(other_earnings)
90
+ self.special_allowance += rest - gross unless breakup(on: on)[:employer].key?("ESI")
91
+ end
92
+
36
93
  private
37
94
 
38
95
  def basic_positive
@@ -30,8 +30,11 @@ module HrLite
30
30
  AttendanceRecord.for_date(@date).flagged.includes(:user)
31
31
  end
32
32
 
33
+ # AttendanceCloseJob fills check_out_at, so the days it closed count too.
33
34
  def missing_checkout_yesterday
34
- AttendanceRecord.for_date(@date - 1).missing_checkout.includes(:user)
35
+ yesterday = AttendanceRecord.for_date(@date - 1)
36
+ yesterday.missing_checkout.or(yesterday.where(regularization_note: AttendanceRecord::AUTO_CHECKOUT_NOTE))
37
+ .includes(:user)
35
38
  end
36
39
 
37
40
  def kpis
@@ -59,7 +59,7 @@ module HrLite
59
59
  )
60
60
  end
61
61
 
62
- esi = Calculators::Esi.call(monthly_gross: esi_reference_gross, gross_earned: esi_gross,
62
+ esi = Calculators::Esi.call(monthly_gross: @structure.esi_reference_gross(@run.period_month), gross_earned: esi_gross,
63
63
  applicable: @structure.esi_applicable, rates: @rates[:esi])
64
64
  if esi.applicable?
65
65
  deductions << { code: "esi_employee", label: "ESI", amount: esi.employee }
@@ -197,25 +197,6 @@ module HrLite
197
197
  (to.year * 12 + to.month) - (from.year * 12 + from.month) + 1
198
198
  end
199
199
 
200
- # ESIC contribution periods run April–September and October–March.
201
- # Eligibility is fixed for the whole period, so it is decided on the
202
- # salary in force on its first day — re-deciding it every month dropped
203
- # someone out of ESI the moment a mid-period raise crossed the ceiling.
204
- def esi_reference_gross
205
- month = @run.period_month
206
- start = if month.month.between?(4, 9)
207
- Date.new(month.year, 4, 1)
208
- elsif month.month >= 10
209
- Date.new(month.year, 10, 1)
210
- else
211
- Date.new(month.year - 1, 10, 1)
212
- end
213
-
214
- # No structure that far back (a mid-period joiner) — their own is the
215
- # only salary this period has ever had.
216
- (SalaryStructure.effective_for(@user, start) || @structure).monthly_gross
217
- end
218
-
219
200
  def serialize_rows(rows)
220
201
  rows.map { |row|
221
202
  row.transform_values { |value| value.is_a?(BigDecimal) ? value.to_s("F") : value }
@@ -9,12 +9,19 @@
9
9
  <%= render "hr_lite/shared/form_errors", record: @request %>
10
10
  <div class="hrl-field">
11
11
  <%= label_tag :employee_code, "Employee (type a name or code)" %>
12
- <%= text_field_tag :employee_code, params[:employee_code], list: "hrl-employees", required: true, autocomplete: "off" %>
12
+ <%= text_field_tag :employee_code, params[:employee_code], list: "hrl-employees", required: true, autocomplete: "off", placeholder: "Start typing a name or code…" %>
13
13
  <datalist id="hrl-employees">
14
14
  <% @people.each do |p| %>
15
15
  <option value="<%= p.employee_code %>"><%= hr_display_name(p.user) %></option>
16
16
  <% end %>
17
17
  </datalist>
18
+ <p class="hrl-small hrl-muted">Someone missing? Only people with an HR profile are listed.
19
+ <% if hr_leadership? %>
20
+ <a href="<%= hr_lite.admin_employees_path %>">Set up their profile</a>
21
+ <% else %>
22
+ Ask leadership to set up their HR profile.
23
+ <% end %>
24
+ </p>
18
25
  </div>
19
26
  <div class="hrl-field">
20
27
  <%= f.label :leave_type_id, "Type" %>
@@ -1,6 +1,8 @@
1
1
  <section class="hrl-card">
2
2
  <%= form_with model: structure, url: url, method: method, scope: :salary_structure, local: true do |f| %>
3
3
  <%= render "hr_lite/shared/form_errors", record: structure %>
4
+ <% breakup = structure.breakup(on: structure.effective_from || Date.current) if structure.basic %>
5
+ <div class="hrl-field"><%= f.label :annual_ctc, "Annual CTC (₹ a year)" %><%= f.number_field :annual_ctc, step: 1, value: (structure.annual_ctc || breakup&.dig(:annual_ctc))&.round %></div>
4
6
  <div class="hrl-field">
5
7
  <%= f.label :effective_from, "Effective from (1st of month)" %>
6
8
  <%= f.date_field :effective_from %>
@@ -21,8 +23,10 @@
21
23
  <%= f.select :pt_state, pt_states.map { |s| [ s.humanize, s ] } %>
22
24
  </div>
23
25
  <div class="hrl-field"><%= f.label :notes %><%= f.text_area :notes %></div>
26
+ <% if breakup %><p class="hrl-muted hrl-small">Monthly gross <%= hrl_money(breakup[:monthly_gross]) %> · CTC <%= hrl_money(breakup[:monthly_ctc]) %> a month</p><% end %>
24
27
  <div class="hrl-form-actions">
25
28
  <%= f.submit "Save structure", class: "hrl-btn hrl-btn--primary" %>
29
+ <%= f.submit "Fill from CTC", name: "fill_from_ctc", class: "hrl-btn" %>
26
30
  <a class="hrl-btn" href="<%= hr_lite.admin_employee_path(@profile) %>">Cancel</a>
27
31
  </div>
28
32
  <% end %>
@@ -7,6 +7,27 @@
7
7
  <%= render "hr_lite/leave_requests/balance_chips" %>
8
8
  <%= render "hr_lite/holidays/next_holiday" %>
9
9
 
10
+ <section class="hrl-card">
11
+ <h2 class="hrl-card__title">Out today</h2>
12
+ <% if @out_today.any? %>
13
+ <%# Name and dates only — the leave type (sick leave) is the employee's own business. %>
14
+ <div class="hrl-feed">
15
+ <% @out_today.first(10).each do |leave| %>
16
+ <div class="hrl-feed__item">
17
+ <div class="hrl-feed__body"><%= tag.strong hr_display_name(leave.user), title: "On leave: #{leave.date_range_label}" %>
18
+ <div class="hrl-feed__meta"><%= leave.date_range_label %></div></div>
19
+ </div>
20
+ <% end %>
21
+ </div>
22
+ <% if @out_today.size > 10 %>
23
+ <p class="hrl-muted hrl-small">+<%= @out_today.size - 10 %> more</p>
24
+ <% end %>
25
+ <% else %>
26
+ <p class="hrl-muted hrl-small">Everyone's in today.</p>
27
+ <% end %>
28
+ <a class="hrl-small" href="<%= hr_lite.team_path %>">Team board</a>
29
+ </section>
30
+
10
31
  <section class="hrl-card">
11
32
  <h2 class="hrl-card__title">Latest kudos</h2>
12
33
  <% if @latest_kudos.any? %>
@@ -4,6 +4,9 @@
4
4
  <div class="hrl-page__actions">
5
5
  <a class="hrl-btn" href="<%= hr_lite.leave_balances_path %>">Balances</a>
6
6
  <a class="hrl-btn" href="<%= hr_lite.comp_off_requests_path %>">Comp-off</a>
7
+ <% if hr_can?("leave.approve", scope: :team) %>
8
+ <a class="hrl-btn" href="<%= hr_lite.new_admin_leave_request_path %>">Record leave for someone</a>
9
+ <% end %>
7
10
  <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_leave_request_path %>">Apply</a>
8
11
  </div>
9
12
  </div>
@@ -6,6 +6,9 @@
6
6
  <section class="hrl-card">
7
7
  <%= form_with model: @request, url: hr_lite.regularization_requests_path, scope: :regularization_request, local: true do |f| %>
8
8
  <%= render "hr_lite/shared/form_errors", record: @request %>
9
+ <% if (rule = HrLite.config.self_regularization) %>
10
+ <p class="hrl-small hrl-muted">A missed punch on a working day from today back to <%= rule[:within_days] %> days ago is fixed as soon as you submit, up to <%= rule[:per_week] %> times a week. Anything else goes to HR as a ticket.</p>
11
+ <% end %>
9
12
  <div class="hrl-field">
10
13
  <%= f.label :date, "Which day needs fixing?" %>
11
14
  <%= f.date_field :date, value: @request.date&.strftime("%Y-%m-%d"), max: Date.current %>
@@ -3,6 +3,53 @@
3
3
  <h1 class="hrl-page__title">My salary slips</h1>
4
4
  </div>
5
5
 
6
+ <section class="hrl-card">
7
+ <h2 class="hrl-card__title">My salary</h2>
8
+ <% if (breakup = @structure&.breakup) %>
9
+ <p class="hrl-small hrl-muted">Current salary from <%= @structure.effective_from.strftime("%d %b %Y") %></p>
10
+ <div class="hrl-table-wrap">
11
+ <%# Three narrow columns fit a 320px phone; stacking repeated the headings on every line. %>
12
+ <table class="hrl-table hrl-table--tight hrl-small">
13
+ <thead><tr><th>Component</th><th class="hrl-num">Monthly</th><th class="hrl-num">Yearly</th></tr></thead>
14
+ <tbody>
15
+ <% { "Earnings" => breakup[:earnings].merge("Gross" => breakup[:monthly_gross]),
16
+ "Deductions" => breakup[:deductions].merge("In-hand before tax" => breakup[:in_hand]),
17
+ "Employer contributions" => breakup[:employer].merge("CTC" => breakup[:monthly_ctc]) }.each do |section, lines| %>
18
+ <tr><th colspan="3"><%= section %></th></tr>
19
+ <% lines.each do |label, amount| %>
20
+ <% weight = label == lines.keys.last ? :strong : :span %>
21
+ <tr>
22
+ <td data-label="Component"><%= content_tag(weight, label) %></td>
23
+ <td data-label="Monthly" class="hrl-num"><%= content_tag(weight, hrl_rupees(amount)) %></td>
24
+ <td data-label="Yearly" class="hrl-num"><%= content_tag(weight, hrl_rupees(breakup[:yearly].fetch(label, amount * 12))) %></td>
25
+ </tr>
26
+ <% end %>
27
+ <% end %>
28
+ </tbody>
29
+ </table>
30
+ </div>
31
+ <p class="hrl-small hrl-muted">Income tax (TDS) depends on your declarations and is shown on each slip.</p>
32
+ <% end %>
33
+ <% @upcoming.each do |structure| %>
34
+ <% upcoming = structure.breakup(on: structure.effective_from) %>
35
+ <h3 class="hrl-card__title">New salary from <%= structure.effective_from.strftime("%d %b %Y") %></h3>
36
+ <dl class="hrl-deflist">
37
+ <dt>CTC</dt><dd><%= hrl_rupees(upcoming[:annual_ctc]) %> a year</dd>
38
+ <dt>Gross</dt><dd><%= hrl_rupees(upcoming[:monthly_gross]) %> a month</dd>
39
+ <dt>In-hand before tax</dt><dd><%= hrl_rupees(upcoming[:in_hand]) %> a month</dd>
40
+ </dl>
41
+ <% end %>
42
+ <% if @structure.nil? && @upcoming.empty? %>
43
+ <p class="hrl-card--empty">Your salary structure hasn't been set up yet.</p>
44
+ <% end %>
45
+ <% if @past.any? %>
46
+ <h3 class="hrl-card__title">Earlier revisions</h3>
47
+ <% @past.each do |structure| %>
48
+ <p class="hrl-small">From <%= structure.effective_from.strftime("%d %b %Y") %> — <%= hrl_rupees(structure.breakup(on: structure.effective_from)[:annual_ctc]) %> a year CTC</p>
49
+ <% end %>
50
+ <% end %>
51
+ </section>
52
+
6
53
  <section class="hrl-card">
7
54
  <% if @slips.any? %>
8
55
  <div class="hrl-table-wrap">
@@ -29,7 +29,7 @@
29
29
  <% @board.rows.each do |row| %>
30
30
  <tr>
31
31
  <td data-label="Name">
32
- <span><strong><%= hr_display_name(row.user) %></strong><% if row.profile&.designation.present? %>
32
+ <span><%= tag.strong hr_display_name(row.user), title: ("On leave: #{row.leave.date_range_label}" if row.leave) %><% if row.profile&.designation.present? %>
33
33
  <span class="hrl-small hrl-muted"> · <%= row.profile.designation %></span><% end %></span>
34
34
  </td>
35
35
  <td data-label="Status"><%= hrl_team_status(row) %></td>
@@ -125,6 +125,7 @@
125
125
 
126
126
  <%= javascript_include_tag "hr_lite/confirm", defer: true %>
127
127
  <%= javascript_include_tag "hr_lite/mention", defer: true %>
128
+ <%= javascript_include_tag "hr_lite/nav", defer: true %>
128
129
  <%= yield :scripts %>
129
130
  </body>
130
131
  </html>
@@ -14,7 +14,7 @@ module HrLite
14
14
  # the HR shell; nil (default) shows nothing.
15
15
  :notifications
16
16
 
17
- attr_reader :leave_year_start_month, :slip_release_day
17
+ attr_reader :leave_year_start_month, :slip_release_day, :self_regularization
18
18
 
19
19
  # Misconfiguration must fail at boot, not as production 500s on every
20
20
  # balance screen. Accepts "7" (ENV-friendly) and validates 1..12.
@@ -36,6 +36,15 @@ module HrLite
36
36
  @slip_release_day = day
37
37
  end
38
38
 
39
+ # nil = only HR fixes a missed punch. { within_days: 2, per_week: 2 } = an
40
+ # employee may fix their own day for 2 days after it, at most 2 a week.
41
+ def self_regularization=(value)
42
+ ok = value.nil? || (value.is_a?(Hash) && %i[within_days per_week].all? { |k| value[k].is_a?(Integer) && value[k].positive? })
43
+ raise ArgumentError, "self_regularization must be nil or { within_days:, per_week: } (positive integers), got #{value.inspect}" unless ok
44
+
45
+ @self_regularization = value
46
+ end
47
+
39
48
  # 0.1.0 pre-release name for public_url_base; kept as an alias so early
40
49
  # adopters' initializers don't break.
41
50
  alias_method :mail_link_base, :public_url_base
@@ -77,6 +86,7 @@ module HrLite
77
86
  @back_link = nil # optional {label:, url:} for the shell nav
78
87
  @leave_year_start_month = 1 # 1 = calendar year; 7 = July–June leave year
79
88
  @slip_release_day = nil # e.g. 10 = slips open on the 10th of the next month
89
+ @self_regularization = nil # e.g. { within_days: 2, per_week: 2 } — employees fix their own missed punch
80
90
 
81
91
  # Leadership onboarding/offboarding. onboard_user must return a saved
82
92
  # user record (default: create on user_class with whatever of
@@ -18,6 +18,8 @@ module HrLite
18
18
  "leave.cancelled" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
19
19
  "attendance.flagged" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
20
20
  "attendance.regularized" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
21
+ "attendance.missed_check_in" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
22
+ "attendance.missed_check_out" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
21
23
  "payroll.finalized" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
22
24
  "payroll.published" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
23
25
  "kudos.mentioned" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.18.0"
2
+ VERSION = "0.20.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.18.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -67,6 +67,7 @@ files:
67
67
  - app/assets/javascripts/hr_lite/confirm.js
68
68
  - app/assets/javascripts/hr_lite/geo_punch.js
69
69
  - app/assets/javascripts/hr_lite/mention.js
70
+ - app/assets/javascripts/hr_lite/nav.js
70
71
  - app/assets/javascripts/hr_lite/print.js
71
72
  - app/assets/stylesheets/hr_lite/application.css
72
73
  - app/assets/stylesheets/hr_lite/hr_lite.css
@@ -136,6 +137,7 @@ files:
136
137
  - app/helpers/hr_lite/application_helper.rb
137
138
  - app/jobs/hr_lite/application_job.rb
138
139
  - app/jobs/hr_lite/approval_escalation_job.rb
140
+ - app/jobs/hr_lite/attendance_close_job.rb
139
141
  - app/jobs/hr_lite/daily_digest_job.rb
140
142
  - app/jobs/hr_lite/document_expiry_job.rb
141
143
  - app/jobs/hr_lite/leave_year_rollover_job.rb