hr_lite 0.5.0 → 0.5.3

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +218 -1
  3. data/README.md +312 -99
  4. data/app/assets/javascripts/hr_lite/confirm.js +31 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +28 -0
  6. data/app/controllers/hr_lite/admin/attendances_controller.rb +19 -5
  7. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +4 -1
  8. data/app/controllers/hr_lite/admin/employees_controller.rb +36 -12
  9. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +19 -13
  10. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +16 -0
  11. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
  12. data/app/controllers/hr_lite/admin/resignations_controller.rb +17 -5
  13. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +22 -8
  14. data/app/controllers/hr_lite/admin/superadmin_controller.rb +5 -0
  15. data/app/controllers/hr_lite/org_controller.rb +6 -1
  16. data/app/controllers/hr_lite/resignations_controller.rb +6 -1
  17. data/app/helpers/hr_lite/application_helper.rb +2 -12
  18. data/app/jobs/hr_lite/application_job.rb +5 -0
  19. data/app/jobs/hr_lite/daily_digest_job.rb +1 -1
  20. data/app/jobs/hr_lite/leave_year_rollover_job.rb +1 -1
  21. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +5 -2
  22. data/app/models/concerns/hr_lite/audited.rb +11 -4
  23. data/app/models/hr_lite/appraisal.rb +9 -1
  24. data/app/models/hr_lite/audit_log.rb +36 -0
  25. data/app/models/hr_lite/comp_off_request.rb +8 -16
  26. data/app/models/hr_lite/designation_change.rb +18 -1
  27. data/app/models/hr_lite/employee_profile.rb +5 -1
  28. data/app/models/hr_lite/leave_balance.rb +51 -5
  29. data/app/models/hr_lite/leave_request.rb +36 -5
  30. data/app/models/hr_lite/leave_type.rb +13 -3
  31. data/app/models/hr_lite/payroll_run.rb +68 -13
  32. data/app/models/hr_lite/regularization_request.rb +11 -4
  33. data/app/models/hr_lite/resignation.rb +27 -0
  34. data/app/models/hr_lite/salary_slip.rb +8 -5
  35. data/app/services/hr_lite/attendance_puncher.rb +8 -1
  36. data/app/services/hr_lite/calculators/esi.rb +7 -4
  37. data/app/services/hr_lite/calculators/professional_tax.rb +5 -1
  38. data/app/services/hr_lite/calculators/tds.rb +8 -1
  39. data/app/services/hr_lite/leave_day_counter.rb +9 -4
  40. data/app/services/hr_lite/overview_query.rb +12 -1
  41. data/app/services/hr_lite/payroll_run_processor.rb +9 -1
  42. data/app/services/hr_lite/slip_builder.rb +54 -8
  43. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
  44. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +19 -5
  45. data/app/views/hr_lite/admin/employees/_form.html.erb +20 -2
  46. data/app/views/hr_lite/admin/employees/show.html.erb +25 -15
  47. data/app/views/hr_lite/admin/leave_requests/show.html.erb +37 -5
  48. data/app/views/hr_lite/admin/overview/index.html.erb +21 -0
  49. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +3 -1
  50. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +18 -4
  51. data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -2
  52. data/app/views/hr_lite/career/show.html.erb +7 -1
  53. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -1
  54. data/app/views/hr_lite/salary_slips/pdf.html.erb +5 -5
  55. data/app/views/layouts/hr_lite/application.html.erb +42 -4
  56. data/config/routes.rb +1 -1
  57. data/db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb +28 -0
  58. data/db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb +14 -0
  59. data/db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb +12 -0
  60. data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
  61. data/lib/hr_lite/amount_in_words.rb +15 -2
  62. data/lib/hr_lite/configuration.rb +3 -6
  63. data/lib/hr_lite/financial_year.rb +26 -0
  64. data/lib/hr_lite/money.rb +18 -0
  65. data/lib/hr_lite/notifications.rb +23 -8
  66. data/lib/hr_lite/statutory_rate_card.rb +51 -6
  67. data/lib/hr_lite/version.rb +1 -1
  68. data/lib/hr_lite.rb +23 -2
  69. metadata +7 -1
data/README.md CHANGED
@@ -1,43 +1,66 @@
1
1
  # hr_lite
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/hr_lite.svg)](https://rubygems.org/gems/hr_lite)
3
4
  [![CI](https://github.com/kshtzkr/hr_lite/actions/workflows/ci.yml/badge.svg)](https://github.com/kshtzkr/hr_lite/actions/workflows/ci.yml)
4
- [![Gem Version](https://badge.fury.io/rb/hr_lite.svg)](https://rubygems.org/gems/hr_lite)
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](MIT-LICENSE)
6
-
7
- A lightweight, self-contained HRMS engine for Rails attendance with
8
- geolocation, leave management with comp-off requests, regularization tickets,
9
- an everyone-visible team board, holiday calendar, full Indian payroll
10
- (PF/ESI/PT/TDS) with salary-slip PDFs, kudos with @mentions, appraisals and
11
- promotions. Think Keka-lite, mounted inside your existing app in a few lines.
12
-
13
- - **Mountable engine**, isolated namespace (`HrLite`), tables prefixed `hr_lite_`.
14
- - **Bring your own users**: any user model + auth (Devise or otherwise) via config hooks.
15
- - **Three access tiers**: employee (self-service), admin (day-to-day operations),
16
- leadership (policy, money, payroll a configurable email list).
17
- - **Powerful notifications**: one event bus routing every HR event to in-app
18
- bells (your notifier), employee emails, and leadership emails — with an
19
- append-only audit trail of every governing change.
20
- - **Self-contained UI**: mobile-first, dependency-free JS, all theming via
21
- CSS custom properties (`--hrl-*`).
22
- - **Encrypted at rest**: PAN/UAN/bank numbers and every money amount use
23
- ActiveRecord encryption.
24
-
25
- ## Try it in one command
26
-
27
- ```bash
28
- git clone https://github.com/kshtzkr/hr_lite && cd hr_lite
29
- bin/demo # → http://localhost:3999
30
- ```
31
-
32
- A throwaway sandbox boots with three persona tiers (leadership / admin /
33
- employee one click to sign in as each) and pre-seeded attendance, leaves,
34
- kudos, a published payroll run and a shared appraisal. Data resets on every
35
- restart.
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg)](https://www.ruby-lang.org)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
7
+
8
+ A mountable Rails engine that adds a small HRMS to an app you already have:
9
+ attendance with geolocation, leave and holidays, Indian payroll (PF/ESI/PT/TDS)
10
+ with salary-slip PDFs, kudos with @mentions, appraisals and promotions. It
11
+ brings its own models, controllers and views under the `HrLite` namespace and
12
+ talks to your app through configuration hooks you keep your users and your
13
+ auth, it does the HR.
14
+
15
+ - Mountable engine, isolated namespace (`HrLite`), tables prefixed `hr_lite_`.
16
+ - Bring your own users: any user model and auth (Devise or otherwise) via config hooks.
17
+ - Access tiers: employee (self-service), admin (day-to-day operations),
18
+ leadership (policy and people), and an optional superadmin tier for money.
19
+ - One event bus routes every HR event to in-app bells, employee emails and a
20
+ leadership fan-out, with an append-only audit trail of governing changes.
21
+ - Self-contained UI: mobile-first, dependency-free JS, theming through CSS
22
+ custom properties (`--hrl-*`).
23
+ - PAN/UAN/bank numbers and every money amount use ActiveRecord encryption.
24
+
25
+ ## Contents
26
+
27
+ - [Requirements](#requirements)
28
+ - [Installation](#installation)
29
+ - [Quick start (sandbox)](#quick-start-sandbox)
30
+ - [Configuration](#configuration)
31
+ - [Access tiers](#access-tiers)
32
+ - [Features](#features)
33
+ - [Attendance and geolocation](#attendance-and-geolocation)
34
+ - [Leave, comp-off and regularization](#leave-comp-off-and-regularization)
35
+ - [Holidays and calendar](#holidays-and-calendar)
36
+ - [Payroll and salary slips](#payroll-and-salary-slips)
37
+ - [Kudos and @mentions](#kudos-and-mentions)
38
+ - [Notifications and the event bus](#notifications-and-the-event-bus)
39
+ - [Appraisals, promotions and the org chart](#appraisals-promotions-and-the-org-chart)
40
+ - [Recurring jobs](#recurring-jobs)
41
+ - [Rake tasks](#rake-tasks)
42
+ - [Generators](#generators)
43
+ - [Theming](#theming)
44
+ - [Statutory disclaimer](#statutory-disclaimer)
45
+ - [Testing and development](#testing-and-development)
46
+ - [Versioning](#versioning)
47
+ - [Contributing](#contributing)
48
+ - [Documentation](#documentation)
49
+ - [License](#license)
50
+
51
+ ## Requirements
52
+
53
+ - Ruby >= 3.2.
54
+ - Rails >= 8.0.
55
+ - Any ActiveRecord database. The migrations use portable column types; the gem
56
+ is developed against PostgreSQL and CI runs on SQLite.
57
+ - ActiveRecord encryption keys configured in the host app — money amounts and
58
+ identity PII (PAN, UAN, bank details) are encrypted at rest. Run
59
+ `bin/rails db:encryption:init` if you have not set keys up yet.
36
60
 
37
61
  ## Installation
38
62
 
39
- Everything lives in the gem a host app adds the gem, a mount line and one
40
- initializer. Nothing else.
63
+ Add the gem to the host app:
41
64
 
42
65
  ```ruby
43
66
  # Gemfile
@@ -45,55 +68,73 @@ gem "hr_lite"
45
68
  ```
46
69
 
47
70
  ```bash
48
- bin/rails g hr_lite:install # annotated initializer + next steps
49
- bin/rails g hr_lite:install --route # …and mount at /hr for you
71
+ bundle install
72
+ bin/rails g hr_lite:install # writes the annotated initializer, prints next steps
73
+ bin/rails g hr_lite:install --route # …and mounts the engine at /hr for you
50
74
  bin/rails db:migrate # engine migrations load straight from the gem
51
- bin/rails hr_lite:seed # leave types + fixed national holidays (idempotent)
75
+ bin/rails hr_lite:seed # default leave types + fixed national holidays (idempotent)
52
76
  ```
53
77
 
54
- Migrations are served from the gem (upgrades included) nothing is copied
55
- into your repo. If you prefer copies, run `bin/rails hr_lite:install:migrations`
56
- once and the engine steps aside.
78
+ Migrations ship from the gem `bin/rails db:migrate` picks them up and upgrades
79
+ bring their own migrations, so nothing is copied into your repo and nothing
80
+ drifts. If you prefer copies you can manage, run
81
+ `bin/rails hr_lite:install:migrations` once; the engine detects the copied
82
+ files (`*.hr_lite.rb`) and stops appending its own.
57
83
 
58
- To serve it on a subdomain instead of a path:
84
+ If you did not pass `--route`, mount it yourself:
59
85
 
60
86
  ```ruby
61
- # config/routes.rb
87
+ # config/routes.rb — at a path…
88
+ mount HrLite::Engine => "/hr", as: :hr_lite
89
+
90
+ # …or on a subdomain:
62
91
  constraints subdomain: "hr" do
63
92
  mount HrLite::Engine => "/", as: :hr_lite
64
93
  end
65
94
  ```
66
95
 
67
- Your app must have ActiveRecord encryption keys configured
68
- (`bin/rails db:encryption:init` if you have not).
96
+ ## Quick start (sandbox)
97
+
98
+ ```bash
99
+ git clone https://github.com/kshtzkr/hr_lite && cd hr_lite
100
+ bin/demo # boots a throwaway app on http://localhost:3999
101
+ ```
102
+
103
+ The sandbox boots a fresh SQLite database with sample data across three persona
104
+ tiers (leadership, admin, employee — one click to sign in as each) and
105
+ pre-seeded attendance, leaves, kudos, a published payroll run and a shared
106
+ appraisal. The data resets on every restart.
69
107
 
70
108
  ## Configuration
71
109
 
110
+ `bin/rails g hr_lite:install` writes `config/initializers/hr_lite.rb`. Every
111
+ key has a working default, so the engine boots in any app; override what your
112
+ app needs:
113
+
72
114
  ```ruby
73
115
  # config/initializers/hr_lite.rb
74
116
  HrLite.configure do |c|
75
- c.parent_controller = "ApplicationController" # inherits your auth, set BEFORE boot
117
+ c.parent_controller = "ApplicationController" # inherit your auth; resolved once at boot
76
118
  c.user_class = "User"
77
119
  c.current_user_method = :current_user
78
120
  c.authenticate_method = :authenticate_user!
79
- c.admin_check = ->(user) { user.admin? }
121
+ c.admin_check = ->(user) { user.respond_to?(:admin?) && user.admin? }
80
122
 
81
- # Leadership: the governing tier. Only these people can change leave
82
- # policy, offices, holidays, employee profiles, salary structures,
83
- # payroll, appraisals. Change the list, not the code.
123
+ # The governing tier: only these people change leave policy, offices,
124
+ # holidays, employee profiles, salary structures, payroll and appraisals.
84
125
  c.leadership_emails = ENV.fetch("HR_LEADERSHIP_EMAILS", "").split(",").map(&:strip)
85
126
 
86
- # In-app notifications -> your bell system.
127
+ # In-app notifications -> your bell system (optional; a no-op by default).
87
128
  c.notify = ->(user:, kind:, title:, body:, path:) {
88
129
  StaffNotifier.notify(user, kind: kind, title: title, body: body, path: path)
89
130
  }
90
131
 
91
- c.mailer_from = "hr@example.com"
92
- c.mail_link_base = "https://hr.example.com" # enables deep links in emails
93
- c.company = -> { { name: "Acme", address: "Head office address", logo_path: nil } }
132
+ c.mailer_from = "hr@example.com"
133
+ c.public_url_base = "https://hr.example.com" # enables deep links in emails
134
+ c.company = -> { { name: "Acme", address: "Head office address", logo_path: nil } }
94
135
 
95
- # Salary-slip PDFs: plug your renderer, or add wicked_pdf to your bundle
96
- # and the built-in renderer takes over. Without either, PDF is disabled.
136
+ # Salary-slip PDFs: plug your renderer, or add wicked_pdf to the bundle and
137
+ # the built-in renderer takes over. With neither, PDF export is disabled.
97
138
  c.render_pdf = ->(template:, assigns:, cache_key:) {
98
139
  PdfRenderer.render(template: template, assigns: assigns, cache_key: cache_key)
99
140
  }
@@ -103,16 +144,190 @@ HrLite.configure do |c|
103
144
  end
104
145
  ```
105
146
 
106
- Every event's channels are configurable via `c.notification_matrix` see
107
- `HrLite::Notifications::DEFAULT_MATRIX` for the routing table (bell /
108
- employee email / leadership email / leadership bell per event).
147
+ `docs/CONFIGURATION.md` documents every key with its type, default and when to
148
+ override it, plus the full event/notification matrix. `parent_controller` is
149
+ resolved once at boot restart after changing it.
150
+
151
+ ## Access tiers
152
+
153
+ Authorization is layered on top of the checks you provide. There is no separate
154
+ role table; a user's tier is derived per request.
155
+
156
+ - **Employee** — every signed-in user. All self-service screens are scoped to
157
+ `current_user` (asking for someone else's record 404s), so a new hire is
158
+ never locked out.
159
+ - **Admin** (`admin_check`, or anyone in leadership) — day-to-day operations:
160
+ team attendance, leave and regularization decisions, the overview board.
161
+ - **Leadership** (`leadership_check` only — admin is not enough) — policy and
162
+ people: leave types, offices, holidays, the weekend rule, employee profiles,
163
+ onboarding/offboarding, the audit trail. Every leadership mutation writes an
164
+ append-only `hr_lite_audit_logs` row and emails leadership with the diff.
165
+ - **Superadmin (money)** (`superadmin_check`) — salary structures, payroll
166
+ runs, salary-slip administration, appraisals and promotions. Set
167
+ `superadmin_emails` to a subset of leadership to separate money from policy.
168
+ Left empty (the default), the money tier is the leadership tier.
169
+
170
+ Leadership is a list you control, not code. The generated initializer reads it
171
+ from `HR_LEADERSHIP_EMAILS` (a comma-separated list) so you can change who
172
+ governs without a deploy — that env var is a convention in the initializer the
173
+ generator writes, not something the gem reads on its own. You can drop the env
174
+ var and assign `c.leadership_emails` directly, or replace `c.leadership_check`
175
+ to derive leadership some other way.
176
+
177
+ `HrLite.admin?(user)`, `HrLite.leadership?(user)` and `HrLite.superadmin?(user)`
178
+ are available if you need the same checks in host code.
179
+
180
+ ## Features
181
+
182
+ Once mounted, staff use the portal at the mount point; admins and leadership get
183
+ an `/admin` area. The routes below are relative to the mount path.
184
+
185
+ ### Attendance and geolocation
186
+
187
+ Employees check in and out from `/attendance` (`POST check_in` / `check_out`).
188
+ The browser sends latitude, longitude and accuracy with each punch. Geolocation
189
+ never blocks a punch: a punch with no GPS, or one outside every office radius,
190
+ is still recorded and then flagged for review — denying location access must not
191
+ stop anyone from working. Distance is a pure-Ruby haversine, so there is no
192
+ geocoding dependency:
193
+
194
+ ```ruby
195
+ HrLite::Geo.distance_m(office.lat, office.lng, punch_lat, punch_lng) # => metres
196
+ ```
197
+
198
+ Offices are `HrLite::OfficeLocation` records with a `radius_m`; leadership
199
+ manages them under `/admin/office_locations`. `OfficeLocation.covering?(lat,
200
+ lng)` answers whether a point falls inside any active office, and
201
+ `OfficeLocation.nearest(lat, lng)` backs the flag note ("1.2 km from Head
202
+ Office"). Admins see the team's day and flagged punches under
203
+ `/admin/attendances`.
204
+
205
+ ### Leave, comp-off and regularization
206
+
207
+ Leave types are policy rows (`HrLite::LeaveType`) with quota, accrual
208
+ (`monthly` or `yearly_upfront`), carry-forward cap and paid/unpaid flag; seeds
209
+ create a sensible default set (CL, SL, EL, LWP, CO). Balances are computed
210
+ against a leave year whose start month is configurable:
211
+
212
+ ```ruby
213
+ HrLite.configure { |c| c.leave_year_start_month = 7 } # July–June instead of Jan–Dec
214
+ HrLite::LeaveYear.current_key # => 2026
215
+ HrLite::LeaveYear.label(2026) # => "2026–27"
216
+ ```
217
+
218
+ Set `leave_year_start_month` once, at install time — balance rows are keyed by
219
+ leave year, and changing it later reinterprets stored balances. Entitlement
220
+ prorates from the joining date (joined on or before the 15th counts that month).
221
+
222
+ Employees apply, cancel and track balances at `/leave_requests` and
223
+ `/leave_balances`; admins approve or reject under `/admin/leave_requests`.
224
+ Comp-off requests (`/comp_off_requests`) credit the comp-off leave type when
225
+ approved; regularization tickets (`/regularization_requests`) let someone who
226
+ forgot to punch propose the real times for admin approval, which writes them
227
+ onto the attendance record with a full audit trail.
228
+
229
+ ### Holidays and calendar
230
+
231
+ `bin/rails hr_lite:seed` inserts the three fixed-date national holidays
232
+ (Republic Day, Independence Day, Gandhi Jayanti). Movable festival dates shift
233
+ every year, so leadership adds them under `/admin/holidays` — including a
234
+ bulk-paste flow. Everyone sees the holiday list at `/holidays` and a combined
235
+ company calendar at `/calendar`. The weekend rule (Sunday only, Saturday and
236
+ Sunday, or 2nd and 4th Saturday) is a leadership setting.
237
+
238
+ ### Payroll and salary slips
239
+
240
+ Payroll runs one month at a time through
241
+ `draft → processing → review → finalized → published`. A run prorates each
242
+ salary structure by attendance, applies the statutory calculators, and produces
243
+ a salary slip per employee plus a payout-register CSV. Statutory rates live in a
244
+ date-keyed table so a budget change is one new entry and old runs keep computing
245
+ on the card that was in force:
246
+
247
+ ```ruby
248
+ HrLite::StatutoryRateCard.for(Date.new(2026, 4, 1)) # => the card effective on/before that month
249
+ ```
250
+
251
+ Leadership (or the superadmin tier, if set) drives runs under
252
+ `/admin/payroll_runs`; employees see their published slips at `/salary_slips`.
253
+ Salary-slip PDFs render through `config.render_pdf`, or through a built-in
254
+ WickedPdf path if `wicked_pdf` is in the bundle. The exact math, rounding rules
255
+ and what is deliberately not modelled are in `docs/PAYROLL.md` — read it with
256
+ your accountant before the first run.
257
+
258
+ ### Kudos and @mentions
259
+
260
+ The kudos wall (`/kudos`) lets staff thank each other with @mentions. The picker
261
+ inserts a plain-text marker, `@[Asha Rao](42)`, and the server parses the ids
262
+ out of it:
263
+
264
+ ```ruby
265
+ HrLite::MentionParser.user_ids("Great save @[Asha Rao](42)") # => [42]
266
+ HrLite::MentionParser.strip_markers("Nice @[Asha Rao](42)") # => "Nice @Asha Rao"
267
+ ```
268
+
269
+ The autocomplete source is `GET <mount>/users/search?q=…`, backed by
270
+ `config.mentionable_users` (a name/email match on your user table by default;
271
+ override it to scope or to use your own search). Each mention notifies the
272
+ mentioned person; the marker is never shown raw in a bell or email.
273
+
274
+ ### Notifications and the event bus
275
+
276
+ Domain code publishes an event; a per-event row in the notification matrix
277
+ decides which channels fire — an in-app bell, an employee email, a single
278
+ leadership email with every configured address in `To:`, and a leadership bell:
279
+
280
+ ```ruby
281
+ HrLite::Notifications.publish(
282
+ "leave.requested",
283
+ title: "Asha requested casual leave",
284
+ bell_to: HrLite.admin_users
285
+ )
286
+ ```
287
+
288
+ The matrix is `HrLite::Notifications::DEFAULT_MATRIX`; override
289
+ `config.notification_matrix` to mute or add channels per event (a host matrix
290
+ pinned on an older gem version still works — unknown events fall back to the
291
+ defaults). Governing changes also publish `policy.changed` with a redacted diff
292
+ and land in the audit trail. The full event list and channel table is in
293
+ `docs/CONFIGURATION.md`.
109
294
 
110
- ## Recurring jobs (host scheduler)
295
+ ### Appraisals, promotions and the org chart
296
+
297
+ Appraisals move `draft → shared` and become permanent once shared; employees
298
+ read theirs at `/appraisals`. Promotions and role changes are recorded as
299
+ designation changes with a timeline (`/career`) and can mirror into your own
300
+ user model through `config.on_designation_change`. The org chart at `/org` is
301
+ visible to everyone and shows the reporting tree — names, designations and
302
+ departments only, never salary or private data — with each viewer's own
303
+ reporting line labelled L1/L2/…
304
+
305
+ ## Recurring jobs
306
+
307
+ Schedule these on the host's job scheduler (cron, GoodJob, Sidekiq-cron,
308
+ whatever you run). Each is idempotent and sends nothing on a quiet day.
111
309
 
112
310
  | Job | Schedule | Purpose |
113
311
  |---|---|---|
114
- | `HrLite::DailyDigestJob` | mornings | leadership digest: out today, pending approvals, flagged punches |
115
- | `HrLite::LeaveYearRolloverJob` | Jan 1 | carry-forward balances (idempotent) |
312
+ | `HrLite::DailyDigestJob` | each morning | Leadership digest: who is out today, pending approvals, flagged punches, missing checkouts |
313
+ | `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) |
314
+ | `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 |
315
+
316
+ ## Rake tasks
317
+
318
+ | Task | What it does |
319
+ |---|---|
320
+ | `hr_lite:seed` | Idempotently seed default leave types and the fixed national holidays. Safe to run on every deploy; it never overwrites operator edits. |
321
+ | `hr_lite:install:migrations` | Copy the engine migrations into the host's `db/migrate` (Rails-provided). Only needed if you want copies instead of gem-served migrations. |
322
+
323
+ ## Generators
324
+
325
+ `hr_lite:install` writes `config/initializers/hr_lite.rb` and prints the
326
+ remaining wiring steps.
327
+
328
+ | Option | Default | Effect |
329
+ |---|---|---|
330
+ | `--route` | off | Also append `mount HrLite::Engine => "/hr", as: :hr_lite` to `config/routes.rb`. |
116
331
 
117
332
  ## Theming
118
333
 
@@ -122,58 +337,56 @@ Override the CSS variables, nothing else:
122
337
  :root { --hrl-accent: #00a24f; --hrl-font: "Inter", sans-serif; }
123
338
  ```
124
339
 
125
- All views are also overridable via standard engine view precedence.
126
-
127
- ## Docs
128
-
129
- - [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — every config key, the event
130
- list and the notification matrix, how the three access tiers gate.
131
- - [docs/PAYROLL.md](docs/PAYROLL.md) — the exact payroll math, rounding rules,
132
- run lifecycle, and what is deliberately not modelled.
340
+ Point `config.extra_stylesheets` at a stylesheet and it is linked after the
341
+ engine's own CSS, so your `--hrl-*` overrides win. Every view is also
342
+ overridable through standard engine view precedence — drop a file at the same
343
+ path under your app's `app/views/hr_lite/…`.
133
344
 
134
345
  ## Statutory disclaimer
135
346
 
136
347
  Payroll math (PF, ESI, PT, TDS) is projection-grade and configured in
137
- `HrLite::StatutoryRateCard` — a date-keyed rate table. **Verify the rates for
138
- each financial year with your accountant** before the first run; per-slip
139
- LOP/TDS overrides are the escape hatch. Surcharge, perquisites and
140
- HRA-exemption math are not modelled.
348
+ `HrLite::StatutoryRateCard` — a date-keyed rate table. Verify the rates for each
349
+ financial year with your accountant before the first run; the per-slip LOP and
350
+ TDS overrides are the escape hatch. Surcharge, perquisites and HRA-exemption
351
+ math are not modelled — see `docs/PAYROLL.md` for the full list of what is left
352
+ to the override.
141
353
 
142
- ## Requirements & versioning
354
+ ## Testing and development
143
355
 
144
- - Rails >= 8.0, Ruby >= 3.2, any ActiveRecord database (portable column
145
- types; developed against PostgreSQL, CI runs SQLite).
146
- - The host app must have ActiveRecord encryption keys configured.
147
- - [SemVer](https://semver.org): 0.x minors may break with a CHANGELOG note;
148
- from 1.0, breaking changes only in majors.
356
+ The test harness is a minimal dummy app in `spec/dummy` SQLite, a bare `User`
357
+ model and a session-based auth stub — so the suite runs in a few seconds with no
358
+ external services.
149
359
 
150
- ## Documentation
360
+ ```bash
361
+ bundle install
362
+ bundle exec rspec # dummy-app suite
363
+ COVERAGE=1 bundle exec rspec # with SimpleCov (the project holds 100% line coverage)
364
+ bundle exec rubocop # rubocop-rails-omakase style
365
+ ```
151
366
 
152
- - [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — every config hook, the
153
- event/notification matrix, access-tier gating.
154
- - [docs/PAYROLL.md](docs/PAYROLL.md) exact payroll math, rounding rules,
155
- and what is deliberately not modelled.
156
- - [CHANGELOG.md](CHANGELOG.md) release history.
367
+ ## Versioning
368
+
369
+ This project follows [Semantic Versioning](https://semver.org). While on 0.x a
370
+ minor release may include a breaking change, always called out in
371
+ [CHANGELOG.md](CHANGELOG.md); from 1.0 breaking changes land only in majors.
157
372
 
158
373
  ## Contributing
159
374
 
160
- Bug reports and pull requests are welcome — see
161
- [CONTRIBUTING.md](CONTRIBUTING.md) for setup and ground rules (100%
162
- coverage, BigDecimal money, config hooks over host couplings). Security
163
- issues: report privately per [SECURITY.md](SECURITY.md). Everyone
164
- interacting with this project is expected to follow the
375
+ Bug reports and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md)
376
+ for setup, the branch and PR flow, and the ground rules (Conventional Commits,
377
+ 100% coverage, BigDecimal money, config hooks over host couplings, CI must
378
+ pass). Security issues: report privately per [SECURITY.md](SECURITY.md).
379
+ Everyone interacting with the project is expected to follow the
165
380
  [code of conduct](CODE_OF_CONDUCT.md).
166
381
 
167
- ## Development
382
+ ## Documentation
168
383
 
169
- ```bash
170
- bundle install
171
- bundle exec rspec # dummy-app test suite (100% line coverage)
172
- COVERAGE=1 bundle exec rspec
173
- bundle exec rubocop
174
- ```
384
+ - [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — every config key with type and
385
+ default, the event/notification matrix, and how the access tiers gate.
386
+ - [docs/PAYROLL.md](docs/PAYROLL.md) the exact payroll math, rounding rules,
387
+ run lifecycle, and what is deliberately not modelled.
388
+ - [CHANGELOG.md](CHANGELOG.md) — release history.
175
389
 
176
390
  ## License
177
391
 
178
- The gem is available as open source under the terms of the
179
- [MIT License](MIT-LICENSE).
392
+ hr_lite is released under the terms of the [MIT License](MIT-LICENSE).
@@ -0,0 +1,31 @@
1
+ // Confirmation prompts for destructive actions.
2
+ //
3
+ // The views carry `data-turbo-confirm` on the forms that delete a draft
4
+ // payroll run, remove a kudos, offboard someone or withdraw a resignation —
5
+ // but the engine deliberately ships no Turbo and no Stimulus, so nothing was
6
+ // ever reading that attribute. Every one of those buttons fired on the first
7
+ // click with no prompt at all.
8
+ //
9
+ // Same attribute name so the markup does not change, and a no-op when a host
10
+ // app does load Turbo (Turbo handles it first; two prompts would be worse
11
+ // than none).
12
+ (function () {
13
+ if (window.Turbo) return;
14
+
15
+ document.addEventListener(
16
+ "submit",
17
+ function (event) {
18
+ var form = event.target;
19
+ if (!form || form.nodeName !== "FORM") return;
20
+
21
+ var message = form.getAttribute("data-turbo-confirm");
22
+ if (!message) return;
23
+
24
+ if (!window.confirm(message)) {
25
+ event.preventDefault();
26
+ event.stopPropagation();
27
+ }
28
+ },
29
+ true // capture, so the check runs before any other submit handler
30
+ );
31
+ })();
@@ -75,6 +75,34 @@
75
75
  padding: .25rem .25rem calc(.25rem + env(safe-area-inset-bottom));
76
76
  }
77
77
  .hrl-tabbar .hrl-nav__link { flex: 1; justify-content: center; font-size: .78rem; }
78
+ /* "More" sheet — everything five tabs cannot hold. Pure <details>: the
79
+ engine ships no Turbo or Stimulus and this must not need any. */
80
+ .hrl-tabbar__more { flex: 1; display: flex; }
81
+ .hrl-tabbar__more > summary {
82
+ flex: 1; justify-content: center; list-style: none; cursor: pointer;
83
+ }
84
+ .hrl-tabbar__more > summary::-webkit-details-marker { display: none; }
85
+ .hrl-tabbar__more[open] > summary { color: var(--hrl-accent); font-weight: 600; }
86
+ .hrl-sheet {
87
+ position: fixed; left: 0; right: 0;
88
+ bottom: calc(3.3rem + env(safe-area-inset-bottom));
89
+ max-height: 60vh; overflow-y: auto;
90
+ display: flex; flex-direction: column; gap: 2px;
91
+ background: var(--hrl-card); border-top: 1px solid var(--hrl-line);
92
+ padding: .6rem .85rem;
93
+ box-shadow: 0 -6px 18px rgb(0 0 0 / 8%);
94
+ }
95
+ /* Left-aligned like the desktop rail — the tab-bar rule centres its own
96
+ links and would otherwise apply here too. */
97
+ .hrl-sheet .hrl-nav__link {
98
+ display: flex; justify-content: flex-start;
99
+ min-height: 44px; font-size: .92rem; padding: .5rem .6rem;
100
+ }
101
+ .hrl-sheet .hrl-side__group {
102
+ color: var(--hrl-muted); font-size: .72rem; font-weight: 700;
103
+ text-transform: uppercase; letter-spacing: .06em;
104
+ margin: .7rem 0 .15rem; padding: 0 .6rem;
105
+ }
78
106
 
79
107
  @media (min-width: 768px) {
80
108
  .hrl-tabbar { display: none; }
@@ -35,12 +35,27 @@ module HrLite
35
35
 
36
36
  attrs = params.require(:attendance_record).permit(:check_in_at, :check_out_at, :status)
37
37
  if attrs[:check_in_at].blank? && attrs[:check_out_at].blank?
38
- record.destroy if record.persisted?
38
+ # Nothing to remove used to still write a `destroy` audit row with
39
+ # subject_id 0 and email the employee that a punch was removed.
40
+ unless record.persisted?
41
+ return redirect_to admin_attendance_path(@employee.id, month: date.strftime("%Y-%m")),
42
+ notice: "Nothing to remove for that day."
43
+ end
44
+
45
+ record.destroy
39
46
  log_regularization(record, note, removed: true)
40
47
  return redirect_to admin_attendance_path(@employee.id, month: date.strftime("%Y-%m")),
41
48
  notice: "Punch removed."
42
49
  end
43
50
 
51
+ # A check-out with no check-in produces a row every consumer reads as
52
+ # absent (they all key off check_in_at), so the day silently stays LOP
53
+ # while the screen reports the fix succeeded.
54
+ if attrs[:check_in_at].blank?
55
+ return redirect_to admin_attendance_path(@employee.id, date: date, month: date.strftime("%Y-%m")),
56
+ alert: "A check-in time is required when a check-out is set."
57
+ end
58
+
44
59
  record.assign_attributes(attrs)
45
60
  record.status = "present" if record.status.blank?
46
61
  record.regularized_by_id = hr_current_user.id
@@ -60,10 +75,9 @@ module HrLite
60
75
  private
61
76
 
62
77
  def log_regularization(record, note, removed:)
63
- AuditLog.create!(
64
- actor: hr_current_user, action: removed ? "destroy" : "regularize",
65
- subject_type: record.class.name, subject_id: record.id || 0,
66
- audited_changes: { "date" => record.date.to_s, "note" => note }
78
+ AuditLog.record!(
79
+ action: removed ? "destroy" : "regularize", subject: record, actor: hr_current_user,
80
+ changes: { "date" => record.date.to_s, "note" => note }
67
81
  )
68
82
  Notifications.publish(
69
83
  "attendance.regularized",
@@ -2,7 +2,10 @@ module HrLite
2
2
  module Admin
3
3
  class AuditLogsController < LeadershipController
4
4
  def index
5
- @audit_logs = paginate(AuditLog.recent.includes(:actor))
5
+ # Appraisal and promotion rows carry review text in plain columns:
6
+ # ordinary leadership governs people and policy, not pay.
7
+ scope = hr_superadmin? ? AuditLog.recent : AuditLog.recent.outside_money_tier
8
+ @audit_logs = paginate(scope.includes(:actor))
6
9
  end
7
10
  end
8
11
  end