hr_lite 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +2 -1
- data/app/assets/stylesheets/hr_lite/hr_lite.css +17 -4
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +38 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +4 -1
- data/app/controllers/hr_lite/admin/leave_types_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +38 -0
- data/app/controllers/hr_lite/comp_off_requests_controller.rb +46 -0
- data/app/controllers/hr_lite/regularization_requests_controller.rb +36 -0
- data/app/controllers/hr_lite/team_controller.rb +11 -0
- data/app/helpers/hr_lite/application_helper.rb +61 -3
- data/app/mailers/hr_lite/event_mailer.rb +2 -2
- data/app/models/hr_lite/comp_off_request.rb +177 -0
- data/app/models/hr_lite/leave_request.rb +18 -0
- data/app/models/hr_lite/leave_type.rb +17 -0
- data/app/models/hr_lite/regularization_request.rb +181 -0
- data/app/services/hr_lite/team_day.rb +84 -0
- data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +37 -0
- data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +47 -0
- data/app/views/hr_lite/admin/employees/_form.html.erb +3 -1
- data/app/views/hr_lite/admin/leave_requests/index.html.erb +2 -0
- data/app/views/hr_lite/admin/leave_types/_form.html.erb +4 -0
- data/app/views/hr_lite/admin/regularization_requests/index.html.erb +37 -0
- data/app/views/hr_lite/admin/regularization_requests/show.html.erb +50 -0
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +11 -0
- data/app/views/hr_lite/attendance/show.html.erb +3 -0
- data/app/views/hr_lite/comp_off_requests/index.html.erb +42 -0
- data/app/views/hr_lite/comp_off_requests/new.html.erb +28 -0
- data/app/views/hr_lite/leave_requests/index.html.erb +1 -0
- data/app/views/hr_lite/regularization_requests/index.html.erb +41 -0
- data/app/views/hr_lite/regularization_requests/new.html.erb +30 -0
- data/app/views/hr_lite/team/show.html.erb +48 -0
- data/app/views/layouts/hr_lite/application.html.erb +1 -1
- data/config/routes.rb +13 -0
- data/db/migrate/20260719104315_add_comp_off_to_hr_lite_leave_types.rb +6 -0
- data/db/migrate/20260719104316_create_hr_lite_comp_off_requests.rb +24 -0
- data/db/migrate/20260719104317_create_hr_lite_regularization_requests.rb +20 -0
- data/lib/hr_lite/configuration.rb +5 -1
- data/lib/hr_lite/notifications.rb +19 -6
- data/lib/hr_lite/seeds.rb +16 -1
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +7 -0
- metadata +22 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4ea6b78faec5dd0012db2be857b1874707c5085e99c54114d611893122be1a0
|
|
4
|
+
data.tar.gz: 0572af1000548da1d1d66699939360827dd8ef6fa1cc420ed236dfb90fd69a25
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79300a2187ebae2eaa26a51a4c0d8845a380cfdb6787dd2df0e3d54a18a0da32b2668586f4cd2c4f4c5d5644ff7c4a5979ab60faf69c94c8a6ee1db7bf18f91a
|
|
7
|
+
data.tar.gz: 91442183160220175e78f0951917da321b4c555f1384772dc08cbc9afc48c7ae0fdaaa7d59591181b72bb8358a75b4e20f941283860b1a755b8aa5eddc1e3729
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-07-19
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Comp-off requests**: employees request a credit for working a weekend
|
|
15
|
+
or holiday; admin approval credits the comp-off leave type's balance
|
|
16
|
+
(mark the type under Settings — seeds flag `CO`). New events
|
|
17
|
+
`comp_off.requested/approved/rejected/cancelled`.
|
|
18
|
+
- **Regularization tickets**: forgot to punch? Employees propose the actual
|
|
19
|
+
times with a reason; admin approval writes them onto the day's attendance
|
|
20
|
+
record with the full regularization trail. New events
|
|
21
|
+
`regularization.requested/approved/rejected/cancelled`.
|
|
22
|
+
- **Team board (`/team`)**: everyone-visible who's-in/who's-out for any
|
|
23
|
+
date — punch times, leave badges, hours worked that day and that month.
|
|
24
|
+
- **Team leave notices**: approving a leave now bells + emails the whole
|
|
25
|
+
team ("X is on leave …", matrix row `leave.team_notice`; reasons are
|
|
26
|
+
never broadcast).
|
|
27
|
+
- Approvals screens grew Leaves / Comp-off / Regularization tabs with
|
|
28
|
+
pending counts.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Checkboxes rendered 100%-wide with misplaced labels (the bare
|
|
33
|
+
`input[type]` width rule out-ranked `.hrl-field--check`); checkbox rows
|
|
34
|
+
now size naturally and pick up the accent colour.
|
|
35
|
+
- Hardening from adversarial review: comp-off credits land in the year
|
|
36
|
+
they can be spent (a December Sunday approved in January no longer
|
|
37
|
+
strands the credit on last year's balance); the balance increment locks
|
|
38
|
+
the balance row (no lost update when two admins approve concurrently);
|
|
39
|
+
a partial unique index guarantees one live comp-off request per person
|
|
40
|
+
per date; approval re-checks the calendar (StaleOffDay) after holiday
|
|
41
|
+
edits; regularization approval refuses merges that would corrupt the
|
|
42
|
+
record (checkout with no check-in, checkout before the genuine
|
|
43
|
+
check-in) with the real reason surfaced to the admin, keeps GPS flags,
|
|
44
|
+
and writes an AuditLog row like the manual fix path; tickets cannot
|
|
45
|
+
target a day covered by approved leave; only one leave type can carry
|
|
46
|
+
the comp-off flag; team notices skip exited staff; a host
|
|
47
|
+
notification-matrix override pinned on an older version no longer
|
|
48
|
+
silently drops events it doesn't know (defaults merge underneath).
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- `Seeds.run!` no longer re-flags CO as comp-off on every deploy (an
|
|
53
|
+
operator disabling comp-off stays disabled); pre-0.3.0 installs run
|
|
54
|
+
`Seeds.seed_comp_off_flag!` once or tick the box under Settings.
|
|
55
|
+
|
|
56
|
+
## [0.2.2] - 2026-07-19
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- Link-styled controls were unreadable: the global `.hrl-body a` colour
|
|
61
|
+
rule out-ranked component classes, so primary action links ("New
|
|
62
|
+
structure", "New run", "Add office", "Apply", "Download PDF", active
|
|
63
|
+
filter chips) rendered accent-on-accent with invisible text, and the
|
|
64
|
+
side-nav links lost their muted colour. The rule is now wrapped in
|
|
65
|
+
`:where()` (zero specificity) so every `.hrl-*` component wins.
|
|
66
|
+
|
|
67
|
+
## [0.2.1] - 2026-07-19
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
|
|
71
|
+
- Email-invite onboarding: leave the starting password blank and the
|
|
72
|
+
welcome email carries a set-your-password link from the host's
|
|
73
|
+
`invite_url_for` hook; `Notifications.publish`/`EventMailer.event`
|
|
74
|
+
accept an absolute `link_url` for tokenized URLs. (Intended for 0.2.0;
|
|
75
|
+
missed the merge window.)
|
|
76
|
+
|
|
10
77
|
## [0.2.0] - 2026-07-19
|
|
11
78
|
|
|
12
79
|
### Added
|
data/README.md
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
[](MIT-LICENSE)
|
|
6
6
|
|
|
7
7
|
A lightweight, self-contained HRMS engine for Rails — attendance with
|
|
8
|
-
geolocation, leave management
|
|
8
|
+
geolocation, leave management with comp-off requests, regularization tickets,
|
|
9
|
+
an everyone-visible team board, holiday calendar, full Indian payroll
|
|
9
10
|
(PF/ESI/PT/TDS) with salary-slip PDFs, kudos with @mentions, appraisals and
|
|
10
11
|
promotions. Think Keka-lite, mounted inside your existing app in a few lines.
|
|
11
12
|
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
-webkit-font-smoothing: antialiased;
|
|
33
33
|
overflow-x: hidden;
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
/* :where() keeps this at zero specificity so any component class
|
|
36
|
+
(.hrl-btn--primary, .hrl-nav__link, ...) can set its own link colour —
|
|
37
|
+
a plain `.hrl-body a` would out-rank them and paint accent-on-accent. */
|
|
38
|
+
:where(.hrl-body) a { color: var(--hrl-accent); text-decoration: none; }
|
|
36
39
|
.hrl-shell { display: flex; min-height: 100vh; }
|
|
37
40
|
.hrl-content { flex: 1; min-width: 0; }
|
|
38
41
|
.hrl-main {
|
|
@@ -170,20 +173,30 @@
|
|
|
170
173
|
.hrl-table--stack td { display: flex; justify-content: space-between; gap: .8rem; border-bottom: 1px dashed var(--hrl-line); }
|
|
171
174
|
.hrl-table--stack td::before { content: attr(data-label); color: var(--hrl-muted); font-size: .78rem; }
|
|
172
175
|
.hrl-table--stack td.hrl-num { text-align: left; }
|
|
176
|
+
.hrl-table--stack td.hrl-cell--void { display: none; }
|
|
173
177
|
}
|
|
174
178
|
|
|
175
179
|
/* Forms */
|
|
176
180
|
.hrl-field { margin-bottom: .8rem; }
|
|
177
181
|
.hrl-field label { display: block; font-size: .82rem; font-weight: 600; margin-bottom: .3rem; }
|
|
178
182
|
.hrl-field .hrl-hint { color: var(--hrl-muted); font-size: .76rem; margin-top: .25rem; }
|
|
179
|
-
|
|
183
|
+
/* Full-width text-ish controls only — checkboxes/radios/hidden must keep
|
|
184
|
+
their natural size (a bare [type] selector out-ranked .hrl-field--check
|
|
185
|
+
and blew checkboxes up to 100% width). */
|
|
186
|
+
.hrl-input,
|
|
187
|
+
.hrl-field input[type]:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
|
|
188
|
+
.hrl-field select, .hrl-field textarea {
|
|
180
189
|
width: 100%; min-height: 44px; padding: .55rem .7rem; font: inherit;
|
|
181
190
|
border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius-sm);
|
|
182
191
|
background: var(--hrl-card); color: var(--hrl-ink);
|
|
183
192
|
}
|
|
184
193
|
.hrl-field textarea { min-height: 90px; resize: vertical; }
|
|
185
|
-
.hrl-field--check { display: flex; align-items: center; gap: .
|
|
186
|
-
.hrl-field--check input {
|
|
194
|
+
.hrl-field--check { display: flex; align-items: center; gap: .55rem; min-height: 44px; }
|
|
195
|
+
.hrl-field--check input {
|
|
196
|
+
width: 1.15rem; height: 1.15rem; min-height: 0; flex: 0 0 auto;
|
|
197
|
+
accent-color: var(--hrl-accent);
|
|
198
|
+
}
|
|
199
|
+
.hrl-field--check label { margin-bottom: 0; font-weight: 600; }
|
|
187
200
|
.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; }
|
|
188
201
|
.hrl-errors ul { margin: .3rem 0 0; padding-left: 1.1rem; }
|
|
189
202
|
.hrl-form-actions { display: flex; gap: .6rem; margin-top: 1rem; }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
class CompOffRequestsController < BaseController
|
|
4
|
+
def index
|
|
5
|
+
@status = params[:status].presence_in(CompOffRequest::STATUSES) || "pending"
|
|
6
|
+
@requests = paginate(CompOffRequest.includes(:user).where(status: @status).recent_first)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
@request = CompOffRequest.find(params[:id])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def approve
|
|
14
|
+
request = CompOffRequest.find(params[:id])
|
|
15
|
+
request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
|
|
16
|
+
redirect_to admin_comp_off_requests_path,
|
|
17
|
+
notice: "Approved — #{request.credit_days.to_f} day credited."
|
|
18
|
+
rescue CompOffRequest::MissingCompOffType, CompOffRequest::StaleOffDay => e
|
|
19
|
+
redirect_to admin_comp_off_request_path(request), alert: e.message
|
|
20
|
+
rescue ActiveRecord::RecordInvalid
|
|
21
|
+
redirect_to admin_comp_off_request_path(request), alert: "Only pending requests can be decided."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reject
|
|
25
|
+
request = CompOffRequest.find(params[:id])
|
|
26
|
+
note = params[:decision_note].to_s.strip
|
|
27
|
+
if note.blank?
|
|
28
|
+
return redirect_to admin_comp_off_request_path(request), alert: "A note is required to reject."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
request.reject!(actor: hr_current_user, note: note)
|
|
32
|
+
redirect_to admin_comp_off_requests_path, notice: "Request rejected."
|
|
33
|
+
rescue ActiveRecord::RecordInvalid
|
|
34
|
+
redirect_to admin_comp_off_request_path(request), alert: "Only pending requests can be decided."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -36,11 +36,14 @@ module HrLite
|
|
|
36
36
|
|
|
37
37
|
if @profile.save
|
|
38
38
|
if new_login
|
|
39
|
+
invite_url = HrLite.config.invite_url_for&.call(@profile.user)
|
|
39
40
|
Notifications.publish(
|
|
40
41
|
"employee.onboarded",
|
|
41
42
|
title: "Welcome aboard — your HR account is ready",
|
|
42
|
-
body: "
|
|
43
|
+
body: invite_url ? "Set your password with the button below, then sign in with your email."
|
|
44
|
+
: "Sign in with your email; your manager has your starting password.",
|
|
43
45
|
path: "/",
|
|
46
|
+
link_url: invite_url,
|
|
44
47
|
bell_to: [ @profile.user ],
|
|
45
48
|
email_to: [ @profile.user ]
|
|
46
49
|
)
|
|
@@ -46,7 +46,7 @@ module HrLite
|
|
|
46
46
|
|
|
47
47
|
def leave_type_params
|
|
48
48
|
params.require(:leave_type)
|
|
49
|
-
.permit(:name, :code, :color, :paid, :annual_quota, :accrual,
|
|
49
|
+
.permit(:name, :code, :color, :paid, :comp_off, :annual_quota, :accrual,
|
|
50
50
|
:carry_forward_cap, :active, :position)
|
|
51
51
|
end
|
|
52
52
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
class RegularizationRequestsController < BaseController
|
|
4
|
+
def index
|
|
5
|
+
@status = params[:status].presence_in(RegularizationRequest::STATUSES) || "pending"
|
|
6
|
+
@requests = paginate(RegularizationRequest.includes(:user).where(status: @status).recent_first)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
@request = RegularizationRequest.find(params[:id])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def approve
|
|
14
|
+
request = RegularizationRequest.find(params[:id])
|
|
15
|
+
request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
|
|
16
|
+
redirect_to admin_regularization_requests_path, notice: "Ticket approved — attendance fixed."
|
|
17
|
+
rescue RegularizationRequest::InvalidMerge => e
|
|
18
|
+
redirect_to admin_regularization_request_path(request),
|
|
19
|
+
alert: "Cannot apply — #{e.message}. Fix the day manually or reject with a note."
|
|
20
|
+
rescue ActiveRecord::RecordInvalid
|
|
21
|
+
redirect_to admin_regularization_request_path(request), alert: "Only pending tickets can be decided."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reject
|
|
25
|
+
request = RegularizationRequest.find(params[:id])
|
|
26
|
+
note = params[:decision_note].to_s.strip
|
|
27
|
+
if note.blank?
|
|
28
|
+
return redirect_to admin_regularization_request_path(request), alert: "A note is required to reject."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
request.reject!(actor: hr_current_user, note: note)
|
|
32
|
+
redirect_to admin_regularization_requests_path, notice: "Ticket rejected."
|
|
33
|
+
rescue ActiveRecord::RecordInvalid
|
|
34
|
+
redirect_to admin_regularization_request_path(request), alert: "Only pending tickets can be decided."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Employee-side comp-off: request a credit for working a weekend/holiday.
|
|
3
|
+
# Scoping to hr_current_user IS the authorization.
|
|
4
|
+
class CompOffRequestsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@requests = paginate(CompOffRequest.where(user_id: hr_current_user.id).recent_first)
|
|
7
|
+
@comp_off_type = LeaveType.comp_off_type
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def new
|
|
11
|
+
@request = CompOffRequest.new(date_worked: default_date)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
@request = CompOffRequest.new(request_params.merge(user_id: hr_current_user.id))
|
|
16
|
+
if @request.save
|
|
17
|
+
redirect_to comp_off_requests_path, notice: "Comp-off request sent for approval."
|
|
18
|
+
else
|
|
19
|
+
render :new, status: :unprocessable_entity
|
|
20
|
+
end
|
|
21
|
+
rescue ActiveRecord::RecordNotUnique
|
|
22
|
+
@request.errors.add(:date_worked, "already has a comp-off request")
|
|
23
|
+
render :new, status: :unprocessable_entity
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cancel
|
|
27
|
+
request = CompOffRequest.where(user_id: hr_current_user.id).find(params[:id])
|
|
28
|
+
request.cancel!(actor: hr_current_user)
|
|
29
|
+
redirect_to comp_off_requests_path, notice: "Request cancelled."
|
|
30
|
+
rescue ActiveRecord::RecordInvalid
|
|
31
|
+
redirect_to comp_off_requests_path, alert: "Only pending requests can be cancelled."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def request_params
|
|
37
|
+
params.require(:comp_off_request).permit(:date_worked, :half_day, :reason)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Most recent non-working day — the day they most likely worked extra.
|
|
41
|
+
def default_date
|
|
42
|
+
calendar = WorkingCalendar.new((Date.current - 14)..Date.current)
|
|
43
|
+
(0..14).map { |i| Date.current - i }.find { |d| !calendar.working_day?(d) } || Date.current
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Employee-side regularization tickets: propose punch times for a day you
|
|
3
|
+
# forgot to check in/out. Scoping to hr_current_user IS the authorization.
|
|
4
|
+
class RegularizationRequestsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@requests = paginate(RegularizationRequest.where(user_id: hr_current_user.id).recent_first)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def new
|
|
10
|
+
@request = RegularizationRequest.new(date: parse_date_param(params[:date]))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def create
|
|
14
|
+
@request = RegularizationRequest.new(request_params.merge(user_id: hr_current_user.id))
|
|
15
|
+
if @request.save
|
|
16
|
+
redirect_to regularization_requests_path, notice: "Ticket raised — an admin will review it."
|
|
17
|
+
else
|
|
18
|
+
render :new, status: :unprocessable_entity
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cancel
|
|
23
|
+
request = RegularizationRequest.where(user_id: hr_current_user.id).find(params[:id])
|
|
24
|
+
request.cancel!(actor: hr_current_user)
|
|
25
|
+
redirect_to regularization_requests_path, notice: "Ticket cancelled."
|
|
26
|
+
rescue ActiveRecord::RecordInvalid
|
|
27
|
+
redirect_to regularization_requests_path, alert: "Only pending tickets can be cancelled."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def request_params
|
|
33
|
+
params.require(:regularization_request).permit(:date, :check_in_at, :check_out_at, :reason)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The everyone-visible team board: who's in, who's out, who's on leave,
|
|
3
|
+
# hours worked — for any date. Transparency by design; shows punch times
|
|
4
|
+
# and leave types only, never reasons or personal data.
|
|
5
|
+
class TeamController < ApplicationController
|
|
6
|
+
def show
|
|
7
|
+
@date = parse_date_param(params[:date])
|
|
8
|
+
@board = TeamDay.new(date: @date)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -2,8 +2,9 @@ module HrLite
|
|
|
2
2
|
module ApplicationHelper
|
|
3
3
|
NAV_ITEMS = [
|
|
4
4
|
{ label: "Home", path: :root_path, match: [ "/" ] },
|
|
5
|
-
{ label: "Attendance", path: :attendance_path, match: [ "/attendance" ] },
|
|
6
|
-
{ label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances" ] },
|
|
5
|
+
{ label: "Attendance", path: :attendance_path, match: [ "/attendance", "/regularization_requests" ] },
|
|
6
|
+
{ label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances", "/comp_off_requests" ] },
|
|
7
|
+
{ label: "Team", path: :team_path, match: [ "/team" ] },
|
|
7
8
|
{ label: "Calendar", path: :calendar_path, match: [ "/calendar", "/holidays" ] },
|
|
8
9
|
{ label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
|
|
9
10
|
{ label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
|
|
@@ -13,7 +14,7 @@ module HrLite
|
|
|
13
14
|
ADMIN_NAV_ITEMS = [
|
|
14
15
|
{ label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
|
|
15
16
|
{ label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
|
|
16
|
-
{ label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances" ] }
|
|
17
|
+
{ label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] }
|
|
17
18
|
].freeze
|
|
18
19
|
|
|
19
20
|
LEADERSHIP_NAV_ITEMS = [
|
|
@@ -85,5 +86,62 @@ module HrLite
|
|
|
85
86
|
def hrl_amount_in_words(amount)
|
|
86
87
|
HrLite::AmountInWords.words(amount)
|
|
87
88
|
end
|
|
89
|
+
|
|
90
|
+
def hrl_duration(seconds)
|
|
91
|
+
return "\u2014" if seconds.nil?
|
|
92
|
+
|
|
93
|
+
total = seconds.to_i
|
|
94
|
+
format("%dh %02dm", total / 3600, (total % 3600) / 60)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Status badge for a TeamDay row — punch times over vague labels.
|
|
98
|
+
def hrl_team_status(row)
|
|
99
|
+
case row.kind
|
|
100
|
+
when :holiday then hrl_status_badge("Holiday", "hrl-badge--muted", worked_hint(row))
|
|
101
|
+
when :weekend then hrl_status_badge("Weekend", "hrl-badge--muted", worked_hint(row))
|
|
102
|
+
when :leave then hrl_status_badge("On leave (#{row.leave.leave_type.code})", "hrl-badge--warn")
|
|
103
|
+
when :half_day_leave then hrl_status_badge("Half-day leave (#{row.leave.leave_type.code})", "hrl-badge--warn", worked_hint(row))
|
|
104
|
+
when :upcoming then hrl_status_badge("\u2014", "hrl-badge--muted")
|
|
105
|
+
when :absent
|
|
106
|
+
label = row.date_today? ? "Not in yet" : "Absent"
|
|
107
|
+
hrl_status_badge(label, "hrl-badge--bad")
|
|
108
|
+
else # :present / :half_day punch
|
|
109
|
+
record = row.record
|
|
110
|
+
if record.check_out_at
|
|
111
|
+
hrl_status_badge("Done #{record.check_in_at.strftime('%H:%M')}\u2013#{record.check_out_at.strftime('%H:%M')}", "hrl-badge--ok")
|
|
112
|
+
elsif row.date_today?
|
|
113
|
+
hrl_status_badge("In since #{record.check_in_at.strftime('%H:%M')}", "hrl-badge--ok")
|
|
114
|
+
else
|
|
115
|
+
hrl_status_badge("In #{record.check_in_at.strftime('%H:%M')}, no check-out", "hrl-badge--warn")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Status pill for a request lifecycle string — one colour map for every
|
|
121
|
+
# list and show screen.
|
|
122
|
+
def hrl_request_status_badge(status)
|
|
123
|
+
css = { "approved" => "hrl-badge--ok", "rejected" => "hrl-badge--bad",
|
|
124
|
+
"cancelled" => "hrl-badge--muted" }[status]
|
|
125
|
+
content_tag(:span, status.humanize, class: "hrl-badge #{css}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# Always ONE root node — these land inside stacked-table flex cells,
|
|
131
|
+
# where a second sibling would be flung to the far edge.
|
|
132
|
+
def hrl_status_badge(label, css, hint = nil)
|
|
133
|
+
badge = content_tag(:span, label, class: "hrl-badge #{css}")
|
|
134
|
+
return badge unless hint
|
|
135
|
+
|
|
136
|
+
content_tag(:span, safe_join([ badge, " ", content_tag(:span, hint, class: "hrl-small hrl-muted") ]))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A punch on a holiday/weekend/half-day-leave is worth surfacing —
|
|
140
|
+
# that's exactly what comp-off requests point at.
|
|
141
|
+
def worked_hint(row)
|
|
142
|
+
return nil unless row.record&.check_in_at
|
|
143
|
+
|
|
144
|
+
"worked #{row.record.check_in_at.strftime('%H:%M')}#{row.record.check_out_at ? "\u2013#{row.record.check_out_at.strftime('%H:%M')}" : ""}"
|
|
145
|
+
end
|
|
88
146
|
end
|
|
89
147
|
end
|
|
@@ -3,11 +3,11 @@ module HrLite
|
|
|
3
3
|
# body, detail lines, diff table) is assembled by the Notifications bus so
|
|
4
4
|
# adding an event never means adding a template.
|
|
5
5
|
class EventMailer < ApplicationMailer
|
|
6
|
-
def event(to:, subject:, heading:, body: nil, lines: [], path: nil)
|
|
6
|
+
def event(to:, subject:, heading:, body: nil, lines: [], path: nil, link_url: nil)
|
|
7
7
|
@heading = heading
|
|
8
8
|
@body = body
|
|
9
9
|
@lines = Array(lines)
|
|
10
|
-
@cta_url = HrLite::EventMailer.link_for(path)
|
|
10
|
+
@cta_url = link_url.presence || HrLite::EventMailer.link_for(path)
|
|
11
11
|
mail(to: to, from: HrLite.config.mailer_from, subject: subject)
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "I worked on a weekend/holiday — credit me a day off." Approval credits
|
|
3
|
+
# the comp-off leave type's balance (an adjustment, so it shows up in the
|
|
4
|
+
# normal balance chips and can be spent through the ordinary leave flow).
|
|
5
|
+
class CompOffRequest < ApplicationRecord
|
|
6
|
+
STATUSES = %w[pending approved rejected cancelled].freeze
|
|
7
|
+
|
|
8
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
9
|
+
belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
|
|
10
|
+
|
|
11
|
+
validates :date_worked, :reason, presence: true
|
|
12
|
+
validates :status, inclusion: { in: STATUSES }
|
|
13
|
+
|
|
14
|
+
with_options on: :create do
|
|
15
|
+
validate :date_not_in_future
|
|
16
|
+
validate :date_was_an_off_day
|
|
17
|
+
validate :no_duplicate_for_date
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
after_create :notify_requested
|
|
21
|
+
|
|
22
|
+
scope :pending, -> { where(status: "pending") }
|
|
23
|
+
scope :recent_first, -> { order(date_worked: :desc, id: :desc) }
|
|
24
|
+
|
|
25
|
+
STATUSES.each do |s|
|
|
26
|
+
define_method("#{s}?") { status == s }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def credit_days
|
|
30
|
+
half_day ? BigDecimal("0.5") : BigDecimal("1")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Shown to the approver: proof the person actually punched that day.
|
|
34
|
+
def punch
|
|
35
|
+
AttendanceRecord.find_by(user_id: user_id, date: date_worked)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# --- transitions -------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
# Credits the comp-off balance inside the row lock, so a double-tap
|
|
41
|
+
# cannot credit twice. Fails loudly when no comp-off type is configured
|
|
42
|
+
# or the day stopped being an off day (holiday edited away).
|
|
43
|
+
def approve!(actor:, note: nil)
|
|
44
|
+
type = LeaveType.comp_off_type
|
|
45
|
+
raise MissingCompOffType if type.nil?
|
|
46
|
+
|
|
47
|
+
transition!("approved", actor, note) do
|
|
48
|
+
raise StaleOffDay if WorkingCalendar.new(date_worked..date_worked).working_day?(date_worked)
|
|
49
|
+
|
|
50
|
+
# (A duplicate live request for the same date cannot exist — the
|
|
51
|
+
# partial unique index on [user_id, date_worked] guarantees it.)
|
|
52
|
+
credit_balance!(type)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Notifications.publish(
|
|
56
|
+
"comp_off.approved",
|
|
57
|
+
title: "Comp-off approved — #{credit_days.to_f} day#{'s' if credit_days > 1} credited for #{date_worked.strftime('%d %b')}",
|
|
58
|
+
body: decision_note.presence,
|
|
59
|
+
path: "/comp_off_requests",
|
|
60
|
+
bell_to: [ user ], email_to: [ user ]
|
|
61
|
+
)
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def reject!(actor:, note:)
|
|
66
|
+
transition!("rejected", actor, note)
|
|
67
|
+
Notifications.publish(
|
|
68
|
+
"comp_off.rejected",
|
|
69
|
+
title: "Comp-off rejected — #{date_worked.strftime('%d %b')}",
|
|
70
|
+
body: decision_note.presence,
|
|
71
|
+
path: "/comp_off_requests",
|
|
72
|
+
bell_to: [ user ], email_to: [ user ]
|
|
73
|
+
)
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cancel!(actor:)
|
|
78
|
+
transition!("cancelled", actor, nil)
|
|
79
|
+
Notifications.publish(
|
|
80
|
+
"comp_off.cancelled",
|
|
81
|
+
title: "#{HrLite.display_name(user)} cancelled a comp-off request (#{date_worked.strftime('%d %b')})",
|
|
82
|
+
path: "/admin/comp_off_requests",
|
|
83
|
+
bell_to: HrLite.admin_users
|
|
84
|
+
)
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class MissingCompOffType < StandardError
|
|
89
|
+
def message
|
|
90
|
+
"No active leave type is marked as comp-off — enable one under Settings → Leave types first."
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class StaleOffDay < StandardError
|
|
95
|
+
def message
|
|
96
|
+
"That date is now a regular working day (the calendar changed since the request) — " \
|
|
97
|
+
"reject it and ask them to re-file if it still applies."
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# The credit goes into the year it can actually be SPENT (the current
|
|
104
|
+
# year at approval time) — crediting date_worked.year would strand a
|
|
105
|
+
# late-December day approved in January on last year's dead balance.
|
|
106
|
+
# The balance row is locked for the increment; two admins approving two
|
|
107
|
+
# different requests for the same person cannot lose an update.
|
|
108
|
+
def credit_balance!(type)
|
|
109
|
+
year = [ date_worked.year, Date.current.year ].max
|
|
110
|
+
balance = LeaveBalance.for(user, type, year)
|
|
111
|
+
if balance.new_record?
|
|
112
|
+
begin
|
|
113
|
+
balance.save!
|
|
114
|
+
rescue ActiveRecord::RecordNotUnique
|
|
115
|
+
balance = LeaveBalance.for(user, type, year)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
balance.with_lock do
|
|
119
|
+
balance.adjustment += credit_days
|
|
120
|
+
balance.adjustment_note = [ balance.adjustment_note.presence,
|
|
121
|
+
"+#{credit_days.to_f} comp-off for #{date_worked} (request ##{id})" ].compact.join("; ")
|
|
122
|
+
balance.save!
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def transition!(new_status, actor, note)
|
|
127
|
+
with_lock do
|
|
128
|
+
raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
|
|
129
|
+
|
|
130
|
+
yield if block_given?
|
|
131
|
+
self.status = new_status
|
|
132
|
+
self.decided_by_id = actor.id
|
|
133
|
+
self.decided_at = Time.current
|
|
134
|
+
self.decision_note = note
|
|
135
|
+
save!
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def date_not_in_future
|
|
140
|
+
return unless date_worked
|
|
141
|
+
|
|
142
|
+
errors.add(:date_worked, "cannot be in the future") if date_worked > Date.current
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Comp-off is for working on a day you were NOT supposed to — a weekend
|
|
146
|
+
# or a holiday (per the company calendar). Extra hours on a working day
|
|
147
|
+
# are not comp-off under the policy.
|
|
148
|
+
def date_was_an_off_day
|
|
149
|
+
return unless date_worked
|
|
150
|
+
return if date_worked > Date.current
|
|
151
|
+
|
|
152
|
+
calendar = WorkingCalendar.new(date_worked..date_worked)
|
|
153
|
+
if calendar.working_day?(date_worked)
|
|
154
|
+
errors.add(:date_worked, "was a regular working day — comp-off is for working on a weekend or holiday")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def no_duplicate_for_date
|
|
159
|
+
return unless date_worked
|
|
160
|
+
|
|
161
|
+
clash = self.class.where(user_id: user_id, date_worked: date_worked,
|
|
162
|
+
status: %w[pending approved]).where.not(id: id)
|
|
163
|
+
errors.add(:date_worked, "already has a comp-off request") if clash.exists?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def notify_requested
|
|
167
|
+
Notifications.publish(
|
|
168
|
+
"comp_off.requested",
|
|
169
|
+
title: "#{HrLite.display_name(user)} requested comp-off for #{date_worked.strftime('%d %b')} " \
|
|
170
|
+
"(#{half_day ? 'half' : 'full'} day)",
|
|
171
|
+
body: reason.presence,
|
|
172
|
+
path: "/admin/comp_off_requests/#{id}",
|
|
173
|
+
bell_to: HrLite.admin_users
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -52,6 +52,7 @@ module HrLite
|
|
|
52
52
|
return false if insufficient
|
|
53
53
|
|
|
54
54
|
notify_decision("Leave approved")
|
|
55
|
+
notify_team
|
|
55
56
|
true
|
|
56
57
|
end
|
|
57
58
|
|
|
@@ -132,6 +133,23 @@ module HrLite
|
|
|
132
133
|
)
|
|
133
134
|
end
|
|
134
135
|
|
|
136
|
+
# Everyone should know a colleague will be away — bell + email to the
|
|
137
|
+
# whole team (matrix row "leave.team_notice"; hosts can mute either
|
|
138
|
+
# channel). Deliberately excludes the reason: dates are team-relevant,
|
|
139
|
+
# the why is not.
|
|
140
|
+
def notify_team
|
|
141
|
+
team = HrLite.active_employees.reject { |member| member.id == user_id }
|
|
142
|
+
return if team.empty?
|
|
143
|
+
|
|
144
|
+
Notifications.publish(
|
|
145
|
+
"leave.team_notice",
|
|
146
|
+
title: "#{HrLite.display_name(user)} is on leave — #{leave_type.name} (#{date_range_label})",
|
|
147
|
+
path: "/team",
|
|
148
|
+
bell_to: team,
|
|
149
|
+
email_to: team
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
135
153
|
def notify_decision(title)
|
|
136
154
|
Notifications.publish(
|
|
137
155
|
status == "approved" ? "leave.approved" : "leave.rejected",
|