hr_lite 0.16.0 → 0.19.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -0
  3. data/README.md +12 -0
  4. data/app/assets/javascripts/hr_lite/nav.js +21 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +1 -1
  6. data/app/controllers/hr_lite/admin/benefits_controller.rb +61 -0
  7. data/app/controllers/hr_lite/admin/expense_categories_controller.rb +43 -0
  8. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -1
  9. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +3 -3
  10. data/app/controllers/hr_lite/admin/payroll_line_items_controller.rb +47 -0
  11. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +3 -1
  12. data/app/controllers/hr_lite/leave_requests_controller.rb +3 -1
  13. data/app/controllers/hr_lite/regularization_requests_controller.rb +10 -1
  14. data/app/controllers/hr_lite/salary_slips_controller.rb +6 -6
  15. data/app/helpers/hr_lite/application_helper.rb +12 -6
  16. data/app/jobs/hr_lite/attendance_close_job.rb +42 -0
  17. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +4 -3
  18. data/app/jobs/hr_lite/slips_ready_job.rb +10 -0
  19. data/app/models/hr_lite/attendance_record.rb +1 -0
  20. data/app/models/hr_lite/audit_log.rb +1 -1
  21. data/app/models/hr_lite/benefit_enrolment.rb +1 -1
  22. data/app/models/hr_lite/leave_balance.rb +2 -1
  23. data/app/models/hr_lite/leave_request.rb +38 -17
  24. data/app/models/hr_lite/payroll_line_item.rb +5 -3
  25. data/app/models/hr_lite/payroll_run.rb +19 -3
  26. data/app/models/hr_lite/regularization_request.rb +40 -3
  27. data/app/models/hr_lite/salary_slip.rb +6 -0
  28. data/app/services/hr_lite/attendance_summary.rb +12 -15
  29. data/app/services/hr_lite/overview_query.rb +4 -1
  30. data/app/views/hr_lite/admin/benefits/index.html.erb +65 -0
  31. data/app/views/hr_lite/admin/benefits/new.html.erb +37 -0
  32. data/app/views/hr_lite/admin/expense_categories/_form.html.erb +16 -0
  33. data/app/views/hr_lite/admin/expense_categories/edit.html.erb +3 -0
  34. data/app/views/hr_lite/admin/expense_categories/index.html.erb +30 -0
  35. data/app/views/hr_lite/admin/expense_categories/new.html.erb +3 -0
  36. data/app/views/hr_lite/admin/leave_balances/index.html.erb +4 -0
  37. data/app/views/hr_lite/admin/leave_requests/new.html.erb +8 -1
  38. data/app/views/hr_lite/admin/leave_requests/show.html.erb +3 -0
  39. data/app/views/hr_lite/admin/leave_types/index.html.erb +1 -0
  40. data/app/views/hr_lite/admin/payroll_line_items/index.html.erb +47 -0
  41. data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +42 -0
  42. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +1 -0
  43. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +5 -1
  44. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +3 -1
  45. data/app/views/hr_lite/leave_requests/index.html.erb +4 -0
  46. data/app/views/hr_lite/leave_requests/new.html.erb +1 -0
  47. data/app/views/hr_lite/regularization_requests/new.html.erb +3 -0
  48. data/app/views/hr_lite/salary_slips/index.html.erb +1 -1
  49. data/app/views/layouts/hr_lite/application.html.erb +5 -4
  50. data/config/routes.rb +5 -0
  51. data/db/migrate/20260929081109_add_paid_days_to_hr_lite_leave_requests.rb +5 -0
  52. data/lib/hr_lite/configuration.rb +21 -1
  53. data/lib/hr_lite/notifications.rb +8 -2
  54. data/lib/hr_lite/version.rb +1 -1
  55. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14d37a1d62a035315390be2c619a74722c58e6b53b747422aa4ab48458287102
4
- data.tar.gz: fbbb6c7efb893ff38d6992971844fd721deca4819943f0e0dd8f1cd343f4b4fc
3
+ metadata.gz: d99d3408ecc4296bfb60310343f56df91495a48a6bc277881f6ebbd79a57de80
4
+ data.tar.gz: 9286e4b08358dbbe9f360967a57b134aa0ee6d74841daf80310ad79b372ae7b2
5
5
  SHA512:
6
- metadata.gz: 70b42e3a35bd683ab62ecda90d33009511faeda8595cbc9ec9fc9f1ae2388f61592059a4ea1be151ade6ec3062f4ecea2726098f2f58adc55048f8e108de7460
7
- data.tar.gz: a19199e7cd8a173713e97d77266d4779951ef47d70171c234ee65d2afec5cc35be8670542db4e49ad2e656efe6031b8aba053cc281f687fd08dd6b20e3d83511
6
+ metadata.gz: a96b511a5031812bf57b8b10314c1316eb29857f25f0e5dd4e454caea586bf88c3feff01180e3d5bdd8da693421c52524c9da93038740266c8bf9c079dca24ed
7
+ data.tar.gz: dc3d6659e50a6421935556bb13db5a0749198e119b6bc01fa5a84415a913d26e4a38397c71f2e529c379fdc14663eb9afb8825c1f402e9723e7c1fb2ba933ba8
data/CHANGELOG.md CHANGED
@@ -7,6 +7,123 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.19.0] - 2026-09-30
11
+
12
+ Each night closes the day. A missed check-out becomes a half day, and
13
+ employees can fix a recent missed punch themselves. No migration.
14
+
15
+ ### Added
16
+
17
+ - **`HrLite::AttendanceCloseJob`**, scheduled by the host each night (a run
18
+ before noon closes the previous day, so retries are safe). On a working
19
+ day it checks out every punch still open at the day's end (23:59), marks the
20
+ day `half_day` (payroll pays
21
+ half and counts half as loss of pay), and emails the employee. Anyone who
22
+ never checked in is emailed that the day is absent; no record is written.
23
+ Weekends, holidays and approved leave (full or half day) are skipped. Run it
24
+ once a day: a re-run emails the no-shows again.
25
+ - **Self-fix.** With `config.self_regularization = { within_days:, per_week: }`,
26
+ a ticket that fills a missed punch on a working day, today or up to
27
+ `within_days` back, is approved as soon as the employee submits it, for up to
28
+ `per_week` days in a Monday–Sunday week. The punch is written the way an HR
29
+ approval writes it, with the usual audit row, and nobody is notified. Older
30
+ days, off days, fixes past the weekly limit, a change to a real punch, and a
31
+ day HR already regularized or rejected go to HR as ordinary tickets; the
32
+ flash says which happened. A check-out-only ticket for a day with no check-in
33
+ stays pending for HR.
34
+ `nil` (default) keeps HR as the only one who fixes punches. The ticket form
35
+ states the rule when it is on.
36
+ - The Leaves page links "Record leave for someone" for anyone who can approve
37
+ leave.
38
+ - The record-leave form says why somebody may be missing from the employee
39
+ list (no HR profile yet). Leadership gets a link to set one up.
40
+
41
+ ### Changed
42
+
43
+ - Approving a regularization ticket on a day the close job marked half day
44
+ sets it back to present. A half day HR set on purpose stays.
45
+ - Approving a ticket for a month whose payroll run is in review adds a warning
46
+ to that run, so the slip is recomputed before it is finalized.
47
+ - The admin overview and daily digest still count a day the close job checked
48
+ out as a missing checkout.
49
+
50
+ ### Fixed
51
+
52
+ - The sidebar, and the phone's More sheet, open scrolled to the current screen
53
+ instead of the top. A Manage item such as Approvals is no longer below the
54
+ fold.
55
+ - Page action buttons wrap on a narrow phone instead of running off the screen.
56
+
57
+ ### Upgrading
58
+
59
+ Schedule `HrLite::AttendanceCloseJob` daily at `55 23 * * * Asia/Kolkata` in
60
+ your job scheduler (a run before noon closes the previous day). To let employees fix their own recent misses, set
61
+ `c.self_regularization = { within_days: 2, per_week: 2 }` in the initializer.
62
+ No migration.
63
+
64
+ ## [0.18.0] - 2026-09-29
65
+
66
+ Salary follows leave. One migration (`paid_days` on leave requests, nullable;
67
+ existing rows read as fully paid).
68
+
69
+ ### Changed
70
+
71
+ - **Leave beyond the balance is loss of pay, not refused.** On approval —
72
+ applied by the employee or recorded by HR — the balance covers the leave's
73
+ earliest working days in half-day steps (`paid_days`), and payroll cuts the
74
+ rest at the month's gross divided by its calendar days. The balance is never
75
+ overdrawn by it, so next month's accrual is not swallowed. Each unpaid day is
76
+ cut in its own month when a leave spans two. The split is fixed at approval;
77
+ correct it with the slip's LOP override while the run is in review.
78
+ - Comp-off is still refused past its earned credit.
79
+ - The monthly draft alert on the 1st goes to whoever runs payroll
80
+ (`payroll.manage`), with email, instead of leadership — who cannot open it.
81
+
82
+ ### Added
83
+
84
+ - The employee is told on applying how many days will be unpaid; the approver
85
+ sees it on the request before approving.
86
+ - `config.slip_release_day` (1..28): employees see a published slip from that
87
+ day of the following month, and the "slip ready" bell and email wait until
88
+ then. `nil` (default) keeps today's behaviour. Needs an ActiveJob backend
89
+ that runs scheduled jobs.
90
+
91
+ ## [0.17.0] - 2026-09-29
92
+
93
+ Screens for the things other screens were waiting on. An audit of every HR
94
+ screen found four that could never show anything, because the rows they list
95
+ could only be created from a console. No migration.
96
+
97
+ ### Added
98
+
99
+ - **Expense categories** (Settings → Expense categories, leadership). Until
100
+ now nothing created one, so the claim form had no categories and every
101
+ expense claim failed with "Category must exist". Add or edit name, monthly
102
+ cap, receipt rule and active; untick Active to retire one (no delete —
103
+ claims point at it).
104
+ - **One-off pay items** (Payroll → a run → One-off pay items, money tier): a
105
+ bonus, incentive, arrears, reimbursement or one-time deduction for a person
106
+ and month, carried by that month's computed slip. A month already paid
107
+ refuses both adding and removing an item; the loan repayment line stays
108
+ with the loan. Items are money-tier in the audit trail, so the leadership
109
+ email no longer carries the amount.
110
+ - **Benefits admin** (Manage → Benefits, `benefit.manage`): add a benefit,
111
+ enrol somebody with their dependants, end their cover. Employees' Benefits
112
+ page was always empty before.
113
+
114
+ ### Fixed
115
+
116
+ - **A manager could adjust anybody's leave balance.** The adjust action never
117
+ asked whose balance it was. It now needs `leave.manage` reaching that person;
118
+ the balances grid lists only people the viewer can see, and the adjust form
119
+ shows only to someone who holds `leave.manage`.
120
+ - The professional-tax state picker on the salary structure form had no
121
+ options.
122
+ - Sidebar links that led nowhere: the employee Approvals inbox shows only once
123
+ an approval flow exists; Claims shows to whoever approves claims (Finance
124
+ sees it, HR no longer lands on access denied); Profile is linked, and with it
125
+ Resignation; both leave balance pages are linked.
126
+
10
127
  ## [0.16.0] - 2026-09-27
11
128
 
12
129
  Employee ID cards, print requests through Ask HR, recording leave for
data/README.md CHANGED
@@ -141,6 +141,10 @@ HrLite.configure do |c|
141
141
  c.render_pdf = ->(template:, assigns:, cache_key:) {
142
142
  PdfRenderer.render(template: template, assigns: assigns, cache_key: cache_key)
143
143
  }
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 }
144
148
 
145
149
  # Mirror promotions into your own user model (optional).
146
150
  c.on_designation_change = ->(user, designation) { user.update!(designation: designation) }
@@ -266,6 +270,13 @@ approved; regularization tickets (`/regularization_requests`) let someone who
266
270
  forgot to punch propose the real times for admin approval, which writes them
267
271
  onto the attendance record with a full audit trail.
268
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
+
269
280
  ### Holidays and calendar
270
281
 
271
282
  `bin/rails hr_lite:seed` inserts the three fixed-date national holidays
@@ -352,6 +363,7 @@ whatever you run). Each is idempotent and sends nothing on a quiet day.
352
363
  | `HrLite::DailyDigestJob` | each morning | Leadership digest: who is out today, pending approvals, flagged punches, missing checkouts |
353
364
  | `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) |
354
365
  | `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 |
366
+ | `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. |
355
367
 
356
368
  ## Rake tasks
357
369
 
@@ -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 {
@@ -0,0 +1,61 @@
1
+ module HrLite
2
+ module Admin
3
+ # Who is covered by what. Before this screen nothing created a benefit,
4
+ # so every employee's /benefits page said "not enrolled".
5
+ class BenefitsController < BaseController
6
+ skip_before_action :require_operations_access!
7
+ before_action :require_benefits!
8
+
9
+ def index
10
+ @benefits = Benefit.order(:name)
11
+ @enrolments = paginate(covered.includes(:benefit, :user).order(:enrolled_on))
12
+ end
13
+
14
+ def new
15
+ @benefit = Benefit.new
16
+ end
17
+
18
+ def create
19
+ @benefit = Benefit.new(benefit_params)
20
+ if @benefit.save
21
+ redirect_to admin_benefits_path, notice: "#{@benefit.name} added."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def enrol
28
+ benefit = Benefit.find(params[:id])
29
+ user = HrLite.user_klass.find(params[:user_id])
30
+ benefit.benefit_enrolments.create!(user_id: user.id, enrolled_on: parse_date_param(params[:enrolled_on]),
31
+ dependants: params[:dependants].presence || 0)
32
+ redirect_to admin_benefits_path, notice: "#{HrLite.display_name(user)} enrolled in #{benefit.name}."
33
+ rescue ActiveRecord::RecordInvalid => e
34
+ redirect_to admin_benefits_path, alert: e.record.errors.full_messages.to_sentence
35
+ end
36
+
37
+ # Ends cover rather than deleting the row: who was covered when is
38
+ # the record an insurance claim gets checked against.
39
+ def unenrol
40
+ enrolment = covered.where(benefit_id: params[:id]).find_by!(user_id: params[:user_id])
41
+ enrolment.update!(ended_on: parse_date_param(params[:ended_on]))
42
+ redirect_to admin_benefits_path, notice: "Cover ended for #{HrLite.display_name(enrolment.user)}."
43
+ rescue ActiveRecord::RecordInvalid => e
44
+ redirect_to admin_benefits_path, alert: e.record.errors.full_messages.to_sentence
45
+ end
46
+
47
+ private
48
+
49
+ def require_benefits! = hr_require_permission!("benefit.manage", scope: :all)
50
+
51
+ # Still covered today — including cover that ends on a future date, which
52
+ # HR must still be able to see and change.
53
+ def covered = BenefitEnrolment.where("ended_on IS NULL OR ended_on >= ?", Date.current)
54
+
55
+ def benefit_params
56
+ params.require(:benefit).permit(:name, :kind, :provider, :policy_number, :coverage, :employer_premium,
57
+ :employee_premium, :effective_from, :expires_on, :notes)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,43 @@
1
+ module HrLite
2
+ module Admin
3
+ # No destroy: claims hold the category by foreign key. Retire one by
4
+ # unticking Active, which takes it off the claim form.
5
+ class ExpenseCategoriesController < LeadershipController
6
+ def index
7
+ @expense_categories = ExpenseCategory.alphabetical
8
+ end
9
+
10
+ def new
11
+ @expense_category = ExpenseCategory.new
12
+ end
13
+
14
+ def create
15
+ @expense_category = ExpenseCategory.new(expense_category_params)
16
+ if @expense_category.save
17
+ redirect_to admin_expense_categories_path, notice: "Category added."
18
+ else
19
+ render :new, status: :unprocessable_entity
20
+ end
21
+ end
22
+
23
+ def edit
24
+ @expense_category = ExpenseCategory.find(params[:id])
25
+ end
26
+
27
+ def update
28
+ @expense_category = ExpenseCategory.find(params[:id])
29
+ if @expense_category.update(expense_category_params)
30
+ redirect_to admin_expense_categories_path, notice: "Category updated."
31
+ else
32
+ render :edit, status: :unprocessable_entity
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def expense_category_params
39
+ params.require(:expense_category).permit(:name, :monthly_cap, :receipt_required, :active)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -4,12 +4,15 @@ module HrLite
4
4
  def index
5
5
  @year = sanitized_year
6
6
  @types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
7
- @employees = HrLite.employees
7
+ @employees = HrLite.employees.select { |employee| hr_reaches?("leave.view", employee) }
8
8
  end
9
9
 
10
10
  # Manual credit/debit — also the comp-off credit mechanism.
11
11
  def adjust
12
12
  user = HrLite.user_klass.find(params[:user_id])
13
+ # A manager reaches this screen through leave.approve; changing
14
+ # somebody's quota is leave.manage, for THAT person.
15
+ hr_require_reach!("leave.manage", user)
13
16
  type = LeaveType.find(params[:leave_type_id])
14
17
  # Unsanitised, a missing param wrote the adjustment into leave year 0,
15
18
  # where no screen can ever show it.
@@ -17,7 +17,7 @@ module HrLite
17
17
  end
18
18
 
19
19
  # HR records leave somebody forgot to apply for. Approved on save, by the
20
- # person recording it; the balance check still applies.
20
+ # person recording it; days beyond the balance become loss of pay.
21
21
  def new
22
22
  @request = LeaveRequest.new(start_date: Date.current, end_date: Date.current)
23
23
  @people = recordable
@@ -32,7 +32,7 @@ module HrLite
32
32
  end
33
33
  return redirect_to(admin_leave_request_path(@request), notice: "Leave recorded and approved.") if recorded
34
34
 
35
- @request.errors.add(:base, profile ? "Not enough balance — adjust it first or use unpaid leave" : "Pick an employee from the list") if @request.errors.empty?
35
+ @request.errors.add(:base, profile ? "Not enough comp-off credit" : "Pick an employee from the list") if @request.errors.empty?
36
36
  @people = recordable
37
37
  render :new, status: :unprocessable_entity
38
38
  end
@@ -43,7 +43,7 @@ module HrLite
43
43
  redirect_to admin_leave_requests_path, notice: "Leave approved."
44
44
  else
45
45
  redirect_to admin_leave_request_path(request),
46
- alert: "Cannot approve — balance no longer covers this request."
46
+ alert: "Cannot approve — comp-off credit no longer covers this request."
47
47
  end
48
48
  rescue ActiveRecord::RecordInvalid
49
49
  redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
@@ -0,0 +1,47 @@
1
+ module HrLite
2
+ module Admin
3
+ # One-off payslip lines: a bonus, an incentive, arrears, a reimbursement,
4
+ # a one-time deduction. Money tier, like loans — this is somebody's pay.
5
+ # Create and destroy are audited by PayrollLineItem's Audited concern.
6
+ class PayrollLineItemsController < SuperadminController
7
+ def index
8
+ @month = parse_month_param(params[:month])
9
+ @items = paginate(PayrollLineItem.for_month(@month).includes(:user, :component).order(:created_at))
10
+ end
11
+
12
+ def new
13
+ @item = PayrollLineItem.new(period_month: parse_month_param(params[:month]))
14
+ end
15
+
16
+ def create
17
+ @item = PayrollLineItem.new(item_params.merge(
18
+ period_month: parse_month_param(params.dig(:payroll_line_item, :period_month)),
19
+ created_by_id: hr_current_user.id,
20
+ # Same list the form offers: loan repayments come from the loan itself.
21
+ component: addable_components.find_by(id: params.dig(:payroll_line_item, :component_id))
22
+ ))
23
+ if @item.save
24
+ redirect_to admin_payroll_line_items_path(month: @item.period_month.strftime("%Y-%m")),
25
+ notice: "#{@item.component.label} added for #{HrLite.display_name(@item.user)}."
26
+ else
27
+ render :new, status: :unprocessable_entity
28
+ end
29
+ end
30
+
31
+ def destroy
32
+ item = PayrollLineItem.find(params[:id])
33
+ removed = item.destroy
34
+ redirect_to admin_payroll_line_items_path(month: item.period_month.strftime("%Y-%m")), status: :see_other,
35
+ **(removed ? { notice: "Removed." } : { alert: "That month has already been paid — it can't change now." })
36
+ end
37
+
38
+ private
39
+
40
+ def item_params
41
+ params.require(:payroll_line_item).permit(:user_id, :amount, :note)
42
+ end
43
+
44
+ def addable_components = SalaryComponent.active.where.not(code: "loan_repayment")
45
+ end
46
+ end
47
+ end
@@ -48,7 +48,9 @@ module HrLite
48
48
  end
49
49
 
50
50
  def publish
51
- transition { |run| run.publish!(actor: hr_current_user) && "Published — employees notified." }
51
+ day = HrLite.config.slip_release_day
52
+ opens = day && "Published — employees can open it, and are notified, from the #{day.ordinalize} of next month."
53
+ transition { |run| run.publish!(actor: hr_current_user) && (opens || "Published — employees notified.") }
52
54
  end
53
55
 
54
56
  # Payout register: the full money sheet including bank details —
@@ -19,7 +19,9 @@ module HrLite
19
19
  def create
20
20
  @request = LeaveRequest.new(request_params.merge(user_id: hr_current_user.id))
21
21
  if @request.save
22
- redirect_to leave_requests_path, notice: "Leave request submitted."
22
+ # Say it now, not on the payslip: days past the balance are unpaid.
23
+ short = @request.days_beyond_balance
24
+ redirect_to leave_requests_path, notice: "Leave request submitted.#{" #{short.to_s('F')} day(s) are beyond your balance and will be unpaid if approved." if short.positive?}"
23
25
  else
24
26
  @balances = balance_cards
25
27
  render :new, status: :unprocessable_entity
@@ -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
@@ -1,13 +1,13 @@
1
1
  module HrLite
2
- # Employee surface: strictly own PUBLISHED slips — a foreign or
3
- # unpublished id 404s through the scoped relation, never 403s.
2
+ # Employee surface: strictly own RELEASED slips — a foreign, unpublished
3
+ # or not-yet-released id 404s through the scoped relation, never 403s.
4
4
  class SalarySlipsController < ApplicationController
5
5
  def index
6
- @slips = paginate(own_published.recent_first)
6
+ @slips = paginate(own_released.recent_first)
7
7
  end
8
8
 
9
9
  def show
10
- @slip = own_published.find(params[:id])
10
+ @slip = own_released.find(params[:id])
11
11
 
12
12
  respond_to do |format|
13
13
  format.html
@@ -17,8 +17,8 @@ module HrLite
17
17
 
18
18
  private
19
19
 
20
- def own_published
21
- SalarySlip.published.where(user_id: hr_current_user.id)
20
+ def own_released
21
+ SalarySlip.released.where(user_id: hr_current_user.id)
22
22
  end
23
23
 
24
24
  def send_slip_pdf(slip)
@@ -11,7 +11,8 @@ module HrLite
11
11
  # belongs in "More" rather than displacing something everybody uses.
12
12
  # Employee tier all the same — holding an approval IS the authorisation,
13
13
  # so a manager and a stand-in covering for one reach the same screen.
14
- { label: "Approvals", path: :approvals_path, match: [ "/approvals" ] },
14
+ { label: "Approvals", path: :approvals_path, match: [ "/approvals" ],
15
+ if: -> { HrLite::ApprovalFlow.where(active: true).exists? } },
15
16
  { label: "Expenses", path: :expenses_path, match: [ "/expenses" ] },
16
17
  { label: "Benefits", path: :benefits_path, match: [ "/benefits" ] },
17
18
  { label: "Policies", path: :policies_path, match: [ "/policies" ] },
@@ -19,7 +20,8 @@ module HrLite
19
20
  { label: "Org", path: :org_path, match: [ "/org" ] },
20
21
  { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
21
22
  { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
22
- { label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] },
23
+ { label: "Career", path: :career_path, match: [ "/career", "/appraisals" ] },
24
+ { label: "Profile", path: :employee_profile_path, match: [ "/profile", "/resignation" ] },
23
25
  { label: "Documents", path: :documents_path, match: [ "/documents" ] },
24
26
  { label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
25
27
  { label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
@@ -31,15 +33,17 @@ module HrLite
31
33
  { label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
32
34
  { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
33
35
  { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] },
34
- { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ] },
36
+ { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ],
37
+ if: -> { hr_can?("expense.approve", scope: :team) } },
35
38
  { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
36
39
  { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
40
+ { label: "Benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ] },
37
41
  { label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
38
42
  ].freeze
39
43
 
40
44
  LEADERSHIP_NAV_ITEMS = [
41
45
  { label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
42
- { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
46
+ { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting", "/admin/expense_categories" ] },
43
47
  { label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
44
48
  { label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
45
49
  { label: "Documents", path: :admin_documents_path, match: [ "/admin/documents" ] }
@@ -63,9 +67,11 @@ module HrLite
63
67
  RQRCode::QRCode.new(text).as_svg(use_path: true, viewbox: true, color: "0F172A").html_safe
64
68
  end
65
69
 
66
- # Only items whose routes exist yet (the nav grows with each phase).
70
+ # Only items whose routes exist yet (the nav grows with each phase), and
71
+ # whose optional `if:` holds — a link that always lands on "access
72
+ # denied" or an empty page is worse than no link.
67
73
  def hrl_nav_items(items)
68
- items.select { |item| hr_lite_route?(item[:path]) }
74
+ items.select { |item| hr_lite_route?(item[:path]) && (item[:if].nil? || instance_exec(&item[:if])) }
69
75
  end
70
76
 
71
77
  def hr_lite_route?(helper_name)
@@ -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
@@ -1,7 +1,7 @@
1
1
  module HrLite
2
2
  # Monthly automation: on the 1st, draft + compute the previous month's
3
- # payroll from attendance and the policy, then tell leadership it is
4
- # waiting for review. Publishing stays a deliberate human action —
3
+ # payroll from attendance and the policy, then tell whoever runs payroll
4
+ # (payroll.manage) it is waiting for review — leadership cannot open it. Publishing stays a deliberate human action —
5
5
  # the system prepares, people approve.
6
6
  class PayrollAutoDraftJob < ApplicationJob
7
7
  queue_as :default
@@ -16,10 +16,11 @@ module HrLite
16
16
  return unless run.draft? && run.salary_slips.none?
17
17
 
18
18
  run.compute!(actor: nil)
19
+ payroll = HrLite.users_holding("payroll.manage", scope: :all).to_a
19
20
  Notifications.publish(
20
21
  "payroll.draft_ready",
21
22
  title: "Payroll #{run.label} computed from attendance — #{run.salary_slips.count} slips await review",
22
- path: "/admin/payroll_runs/#{run.id}"
23
+ path: "/admin/payroll_runs/#{run.id}", bell_to: payroll, email_to: payroll
23
24
  )
24
25
  end
25
26
  end
@@ -0,0 +1,10 @@
1
+ module HrLite
2
+ # Tells employees their slip is ready on config.slip_release_day, when
3
+ # SalarySlip.released first lets them open it. Enqueued by
4
+ # PayrollRun#publish!, which already sent the leadership copy.
5
+ class SlipsReadyJob < ApplicationJob
6
+ def perform(run)
7
+ run.notify_slips_ready(leadership: false)
8
+ end
9
+ end
10
+ end
@@ -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
@@ -14,7 +14,7 @@ module HrLite
14
14
  # leadership audit screen or the policy.changed email.
15
15
  MONEY_TIER_TYPES = %w[
16
16
  HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
17
- HrLite::PayrollRun HrLite::SalarySlip
17
+ HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem
18
18
  ].freeze
19
19
 
20
20
  scope :recent, -> { order(created_at: :desc) }
@@ -9,7 +9,7 @@ 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 }
12
+ validates :user_id, uniqueness: { scope: :benefit_id, message: "is already enrolled" }
13
13
  validates :dependants, numericality: { greater_than_or_equal_to: 0 }
14
14
  validate :ended_after_it_started
15
15