hr_lite 0.29.0 → 0.30.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 +6 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +29 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +2 -1
- data/app/controllers/hr_lite/attendance_controller.rb +23 -0
- data/app/jobs/hr_lite/attendance_close_job.rb +12 -0
- data/app/models/hr_lite/attendance_penalty.rb +30 -0
- data/app/models/hr_lite/attendance_record.rb +12 -2
- data/app/models/hr_lite/attendance_slot.rb +38 -0
- data/app/models/hr_lite/employee_profile.rb +11 -0
- data/app/services/hr_lite/attendance_puncher.rb +20 -0
- data/app/views/hr_lite/admin/attendances/_slot.html.erb +24 -0
- data/app/views/hr_lite/admin/attendances/show.html.erb +5 -0
- data/app/views/hr_lite/admin/employees/_form.html.erb +11 -0
- data/app/views/hr_lite/attendance/_penalties.html.erb +25 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +3 -0
- data/app/views/hr_lite/attendance/_slot.html.erb +33 -0
- data/app/views/hr_lite/attendance/show.html.erb +4 -0
- data/app/views/hr_lite/team/show.html.erb +2 -1
- data/config/routes.rb +2 -0
- data/db/migrate/20261013090000_create_hr_lite_attendance_slots.rb +16 -0
- data/db/migrate/20261013090100_create_hr_lite_attendance_penalties.rb +19 -0
- data/db/migrate/20261013090200_add_buffer_to_hr_lite_employee_profiles.rb +7 -0
- data/lib/hr_lite/configuration.rb +4 -1
- data/lib/hr_lite/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f6f41c24e52098ad42a4af7b5827a3fb2fc09a56e5d46d2194122506faa088b
|
|
4
|
+
data.tar.gz: 6c2323f7e9db6bf95db8f28fac7261b7a670dbdb486b902e30809f5331c95a58
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c62ee058dcc94b38c24976b2120d7adcb4df515796c92ccb6201b655ea5ee1e54b5b5b8e45c83c34ec811463c50fbc35c651c4d1ae0940317cdb76bf4773bfbc
|
|
7
|
+
data.tar.gz: 5d130419f330d6aa715f4c43d36e8d7acf10be7df700930514666600659ab2cb241b70311df969aefbc1ab3e879729680b85b4ea9740311b78b305c4a82a0dc4
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.30.0] - 2026-10-13
|
|
11
|
+
|
|
12
|
+
- Attendance slots (`config.attendance_slots = true`, off by default). Employees pick a check-in slot (09:00, 09:30, 10:00 or 10:30; checkout 8h30 later) for today, this week or this month. Saving locks it. HR sets or unlocks a slot from the employee's attendance page. No slot means 10:00.
|
|
13
|
+
- Late penalties. After 5 minutes of grace, each started half hour late adds 30 minutes to that day's checkout. A check-in after 11:00 also adds 1 hour to each of the next two working days, and one after 11:30 makes the day a half day (an approved regularization ticket lifts it). Leaving before the required checkout adds 30 minutes to the next working day (nightly close job). The punch card shows the slot, the required checkout, time left and any shortage, and My attendance lists the month's penalties. HR removes a penalty with a reason; the row is kept.
|
|
14
|
+
- Late buffer. HR gives an employee 1h or 2h with a reason (parental care, medical, health, other) on their profile. It delays the penalty clock. Colleagues see the category on the team board; the note is HR-only and encrypted.
|
|
15
|
+
|
|
10
16
|
## [0.29.0] - 2026-10-11
|
|
11
17
|
|
|
12
18
|
- ID card: Share button. Draws front and back side by side into one PNG and opens the phone's share sheet (WhatsApp, mail…); desktops download the PNG. The card photo is now served through the Active Storage proxy (same site), so it shows up in the image when storage redirects to a cloud bucket. Vendors html-to-image 1.11.11 (MIT).
|
|
@@ -111,6 +111,35 @@ module HrLite
|
|
|
111
111
|
end
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# HR sets a locked slot from today for the period.
|
|
115
|
+
def slot
|
|
116
|
+
@employee = find_manageable_employee
|
|
117
|
+
period = params[:period].to_s
|
|
118
|
+
slot = AttendanceSlot.new(user_id: @employee.id, set_by_id: hr_current_user.id, period: period,
|
|
119
|
+
start_minute: params[:start_minute].to_i, starts_on: Date.current,
|
|
120
|
+
ends_on: AttendanceSlot.ends_on_for(period, Date.current))
|
|
121
|
+
flash_for = slot.save ? { notice: "Slot set to #{AttendanceSlot.label(slot.start_minute)}." } : { alert: slot.errors.full_messages.to_sentence }
|
|
122
|
+
redirect_to admin_attendance_path(@employee.id), flash_for
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Lets the employee pick a new slot themselves.
|
|
126
|
+
def unlock_slot
|
|
127
|
+
@employee = find_manageable_employee
|
|
128
|
+
AttendanceSlot.where(user_id: @employee.id, locked: true).where(ends_on: Date.current..).find_each { |s| s.update!(locked: false) }
|
|
129
|
+
redirect_to admin_attendance_path(@employee.id), notice: "Slot unlocked. #{hr_display_name(@employee)} can pick a new one."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def remove_penalty
|
|
133
|
+
@employee = find_manageable_employee
|
|
134
|
+
penalty = AttendancePenalty.active.where(user_id: @employee.id).find(params[:penalty_id])
|
|
135
|
+
reason = params[:removal_reason].to_s.strip
|
|
136
|
+
month = penalty.date.strftime("%Y-%m")
|
|
137
|
+
return redirect_to admin_attendance_path(@employee.id, month: month), alert: "A reason is required." if reason.blank?
|
|
138
|
+
|
|
139
|
+
penalty.update!(removed_at: Time.current, removed_by_id: hr_current_user.id, removal_reason: reason)
|
|
140
|
+
redirect_to admin_attendance_path(@employee.id, month: month), notice: "Penalty removed."
|
|
141
|
+
end
|
|
142
|
+
|
|
114
143
|
private
|
|
115
144
|
|
|
116
145
|
# Viewing somebody's month and rewriting their punches are different
|
|
@@ -115,7 +115,8 @@ module HrLite
|
|
|
115
115
|
:user_id, :manager_id, :designation, :date_of_birth, :date_of_joining,
|
|
116
116
|
:date_of_exit, :probation_until, :department, :work_location, :pan_number, :pf_uan, :esi_number,
|
|
117
117
|
:bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions,
|
|
118
|
-
:fy_opening_gross, :fy_opening_tds, :blood_group, :emergency_contact
|
|
118
|
+
:fy_opening_gross, :fy_opening_tds, :blood_group, :emergency_contact,
|
|
119
|
+
:buffer_minutes, :buffer_reason, :buffer_note
|
|
119
120
|
)
|
|
120
121
|
end
|
|
121
122
|
|
|
@@ -25,6 +25,29 @@ module HrLite
|
|
|
25
25
|
punch(:check_out)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Saving locks the slot for the period; a locked one only HR changes.
|
|
29
|
+
def slot
|
|
30
|
+
raise ActiveRecord::RecordNotFound unless HrLite.config.attendance_slots
|
|
31
|
+
|
|
32
|
+
period = params[:period].to_s
|
|
33
|
+
to = AttendanceSlot.ends_on_for(period, Date.current)
|
|
34
|
+
if AttendanceSlot.where(user_id: hr_current_user.id, locked: true).overlapping(Date.current, to).exists?
|
|
35
|
+
return redirect_to attendance_path, alert: "Your slot is locked for that period. Ask HR to change it."
|
|
36
|
+
end
|
|
37
|
+
# Today's penalty was priced on the slot at check-in; picking one now would rewrite it.
|
|
38
|
+
if AttendanceRecord.where(user_id: hr_current_user.id, date: Date.current).where.not(check_in_at: nil).exists?
|
|
39
|
+
return redirect_to attendance_path, alert: "Pick your slot before checking in."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
slot = AttendanceSlot.new(user_id: hr_current_user.id, set_by_id: hr_current_user.id, period: period,
|
|
43
|
+
start_minute: params[:start_minute].to_i, starts_on: Date.current, ends_on: to)
|
|
44
|
+
if slot.save
|
|
45
|
+
redirect_to attendance_path, notice: "Slot #{AttendanceSlot.label(slot.start_minute)} saved and locked until #{to.strftime('%d %b')}."
|
|
46
|
+
else
|
|
47
|
+
redirect_to attendance_path, alert: slot.errors.full_messages.to_sentence
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
28
51
|
private
|
|
29
52
|
|
|
30
53
|
def punch(kind)
|
|
@@ -27,11 +27,23 @@ module HrLite
|
|
|
27
27
|
tell(user, date, "attendance.short_day",
|
|
28
28
|
"You worked #{(record.worked_duration / 3600.0).round(1)} of #{record.required_hours}h on #{day} — marked half day")
|
|
29
29
|
end
|
|
30
|
+
penalize_short_day(record) if record&.check_out_at
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
private
|
|
34
35
|
|
|
36
|
+
# Out before the slot's required checkout: +30 min on the next working day.
|
|
37
|
+
def penalize_short_day(record)
|
|
38
|
+
return unless HrLite.config.attendance_slots && record.check_out_at < record.required_check_out_at
|
|
39
|
+
|
|
40
|
+
next_day = AttendancePenalty.next_working_days(record.date, 1).first
|
|
41
|
+
AttendancePenalty.find_or_create_by!(attendance_record: record, reason: "short_day", date: next_day) do |p|
|
|
42
|
+
p.user_id = record.user_id
|
|
43
|
+
p.minutes = AttendanceSlot::STEP_MINUTES
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
35
47
|
def tell(user, date, event, title)
|
|
36
48
|
rule = HrLite.config.self_regularization
|
|
37
49
|
body = if rule
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Minutes added to one day's required checkout. Never deleted: HR removes
|
|
3
|
+
# one with a reason and the row stays as the record.
|
|
4
|
+
class AttendancePenalty < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
REASONS = {
|
|
8
|
+
"late" => "Late check-in",
|
|
9
|
+
"late_carry" => "Checked in after 11:00",
|
|
10
|
+
"short_day" => "Left before the required checkout"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
14
|
+
belongs_to :attendance_record
|
|
15
|
+
belongs_to :removed_by, class_name: HrLite.config.user_class, optional: true
|
|
16
|
+
|
|
17
|
+
validates :reason, inclusion: { in: REASONS.keys }
|
|
18
|
+
validates :minutes, numericality: { greater_than: 0 }
|
|
19
|
+
validates :removal_reason, presence: true, if: :removed_at
|
|
20
|
+
|
|
21
|
+
scope :active, -> { where(removed_at: nil) }
|
|
22
|
+
|
|
23
|
+
def self.next_working_days(date, count)
|
|
24
|
+
calendar = WorkingCalendar.new((date + 1)..(date + 31))
|
|
25
|
+
((date + 1)..(date + 31)).select { |d| calendar.working_day?(d) }.first(count)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def label = REASONS.fetch(reason)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -3,8 +3,9 @@ module HrLite
|
|
|
3
3
|
STATUSES = %w[present half_day].freeze
|
|
4
4
|
AUTO_CHECKOUT_NOTE = "Auto check-out: no check-out punched".freeze
|
|
5
5
|
SHORT_DAY_NOTE = "Short day: under the required hours".freeze
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
LATE_HALF_DAY_NOTE = "Half day: checked in after 11:30".freeze
|
|
7
|
+
# Half days the system set (not HR); an approved ticket lifts them.
|
|
8
|
+
AUTO_NOTES = [ AUTO_CHECKOUT_NOTE, SHORT_DAY_NOTE, LATE_HALF_DAY_NOTE ].freeze
|
|
8
9
|
# Check-in and check-out further apart than this are called out on the board.
|
|
9
10
|
PUNCH_GAP_ALERT_M = 500
|
|
10
11
|
|
|
@@ -45,6 +46,15 @@ module HrLite
|
|
|
45
46
|
!!(hours && worked_duration && worked_duration < hours.hours)
|
|
46
47
|
end
|
|
47
48
|
|
|
49
|
+
# config.attendance_slots: slot start + 8h30 + the day's active penalties.
|
|
50
|
+
def required_check_out_at
|
|
51
|
+
return unless HrLite.config.attendance_slots && check_in_at
|
|
52
|
+
|
|
53
|
+
extra = AttendancePenalty.active.where(user_id: user_id, date: date).sum(:minutes)
|
|
54
|
+
date.in_time_zone(HrLite.config.time_zone) +
|
|
55
|
+
(AttendanceSlot.start_minute_for(user_id, date) + AttendanceSlot::DAY_MINUTES + extra).minutes
|
|
56
|
+
end
|
|
57
|
+
|
|
48
58
|
# Metres between where the day was checked in and checked out.
|
|
49
59
|
def punch_gap_m
|
|
50
60
|
return unless check_in_lat && check_in_lng && check_out_lat && check_out_lng
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The check-in slot an employee commits to for a day, week or month.
|
|
3
|
+
# Saving locks it; only an attendance manager changes or unlocks it.
|
|
4
|
+
class AttendanceSlot < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
START_MINUTES = [ 540, 570, 600, 630 ].freeze # 09:00 09:30 10:00 10:30
|
|
8
|
+
DEFAULT_START_MINUTE = 600
|
|
9
|
+
PERIODS = { "day" => "Today", "week" => "This week", "month" => "This month" }.freeze
|
|
10
|
+
DAY_MINUTES = 510 # 8h30m from slot to checkout
|
|
11
|
+
GRACE_MINUTES = 5
|
|
12
|
+
STEP_MINUTES = 30
|
|
13
|
+
CARRY_AFTER_MINUTE = 660 # 11:00 — +1h on the next 2 working days
|
|
14
|
+
HALF_DAY_AFTER_MINUTE = 690 # 11:30 — the day is a half day
|
|
15
|
+
|
|
16
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
17
|
+
|
|
18
|
+
validates :start_minute, inclusion: { in: START_MINUTES }
|
|
19
|
+
validates :period, inclusion: { in: PERIODS.keys }
|
|
20
|
+
validates :starts_on, :ends_on, presence: true
|
|
21
|
+
|
|
22
|
+
scope :overlapping, ->(from, to) { where("starts_on <= ? AND ends_on >= ?", to, from) }
|
|
23
|
+
|
|
24
|
+
def self.current(user_id, date) = where(user_id: user_id).overlapping(date, date).order(:created_at).last
|
|
25
|
+
def self.start_minute_for(user_id, date) = current(user_id, date)&.start_minute || DEFAULT_START_MINUTE
|
|
26
|
+
|
|
27
|
+
def self.ends_on_for(period, from)
|
|
28
|
+
{ "week" => from.end_of_week(:monday), "month" => from.end_of_month }.fetch(period, from)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 0 within grace, then +30 for every started half hour: 6–35 late = 30, 36–65 = 60.
|
|
32
|
+
def self.penalty_minutes(late)
|
|
33
|
+
late <= GRACE_MINUTES ? 0 : STEP_MINUTES * ((late - GRACE_MINUTES) / STEP_MINUTES.to_f).ceil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.label(minute) = format("%02d:%02d", *minute.divmod(60))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -20,6 +20,17 @@ module HrLite
|
|
|
20
20
|
# read as zero income for the months it cannot see.
|
|
21
21
|
encrypted_money :declared_annual_deductions, :fy_opening_gross, :fy_opening_tds
|
|
22
22
|
|
|
23
|
+
# HR-granted late allowance; it delays the penalty clock. Colleagues see
|
|
24
|
+
# the category, only HR sees the note (encrypted: it can be medical).
|
|
25
|
+
BUFFER_MINUTES = [ 60, 120 ].freeze
|
|
26
|
+
BUFFER_REASONS = { "parental_care" => "Parental care", "medical" => "Medical",
|
|
27
|
+
"health" => "Health", "other" => "Other" }.freeze
|
|
28
|
+
encrypts :buffer_note
|
|
29
|
+
normalizes :buffer_minutes, with: ->(v) { v.presence }
|
|
30
|
+
validates :buffer_minutes, inclusion: { in: BUFFER_MINUTES }, allow_nil: true
|
|
31
|
+
validates :buffer_reason, inclusion: { in: BUFFER_REASONS.keys }, if: :buffer_minutes
|
|
32
|
+
validates :buffer_note, presence: true, if: -> { buffer_minutes && buffer_reason == "other" }
|
|
33
|
+
|
|
23
34
|
TAX_REGIMES = %w[new old].freeze
|
|
24
35
|
BLOOD_GROUPS = %w[A+ A- B+ B- AB+ AB- O+ O-].freeze
|
|
25
36
|
normalizes :blood_group, with: ->(v) { v.presence } # the "Not set" chip posts ""
|
|
@@ -53,6 +53,7 @@ module HrLite
|
|
|
53
53
|
|
|
54
54
|
apply_geo_flags(record)
|
|
55
55
|
record.save!
|
|
56
|
+
apply_slot_rules(record) if @kind == :check_in && HrLite.config.attendance_slots
|
|
56
57
|
end
|
|
57
58
|
notify_if_flagged(record)
|
|
58
59
|
Result.new(record: record)
|
|
@@ -86,6 +87,25 @@ module HrLite
|
|
|
86
87
|
"Already checked in at #{record.check_in_at.in_time_zone(HrLite.config.time_zone).strftime('%H:%M')}."
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
# Late ladder against slot + buffer; after 11:00 also +1h on the next two
|
|
91
|
+
# working days; after 11:30 the day is a half day. The buffer shifts all three.
|
|
92
|
+
def apply_slot_rules(record)
|
|
93
|
+
local = record.check_in_at.in_time_zone(HrLite.config.time_zone)
|
|
94
|
+
arrived = local.hour * 60 + local.min - EmployeeProfile.where(user_id: @user.id).pick(:buffer_minutes).to_i
|
|
95
|
+
late = AttendanceSlot.penalty_minutes(arrived - AttendanceSlot.start_minute_for(@user.id, record.date))
|
|
96
|
+
penalize(record, record.date, late, "late") if late.positive?
|
|
97
|
+
return if arrived <= AttendanceSlot::CARRY_AFTER_MINUTE
|
|
98
|
+
|
|
99
|
+
AttendancePenalty.next_working_days(record.date, 2).each { |day| penalize(record, day, 60, "late_carry") }
|
|
100
|
+
return if arrived <= AttendanceSlot::HALF_DAY_AFTER_MINUTE
|
|
101
|
+
|
|
102
|
+
record.update!(status: "half_day", regularization_note: AttendanceRecord::LATE_HALF_DAY_NOTE)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def penalize(record, date, minutes, reason)
|
|
106
|
+
AttendancePenalty.create!(user_id: @user.id, date: date, minutes: minutes, reason: reason, attendance_record: record)
|
|
107
|
+
end
|
|
108
|
+
|
|
89
109
|
def apply_geo_flags(record)
|
|
90
110
|
label = @kind == :check_in ? "Check-in" : "Check-out"
|
|
91
111
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<% slot = HrLite::AttendanceSlot.current(employee.id, Date.current) %>
|
|
2
|
+
<section class="hrl-card">
|
|
3
|
+
<h2 class="hrl-card__title">Check-in slot</h2>
|
|
4
|
+
<p class="hrl-small">
|
|
5
|
+
<span class="hrl-badge"><%= HrLite::AttendanceSlot.label(slot&.start_minute || HrLite::AttendanceSlot::DEFAULT_START_MINUTE) %><%= " (default)" unless slot %></span>
|
|
6
|
+
<% if slot %><%= slot.locked? ? "Locked" : "Unlocked" %> till <%= slot.ends_on.strftime("%-d %b") %><% end %>
|
|
7
|
+
</p>
|
|
8
|
+
<% if slot&.locked? %>
|
|
9
|
+
<%= form_with url: hr_lite.unlock_slot_admin_attendance_path(employee.id), method: :post, local: true do %>
|
|
10
|
+
<button type="submit" class="hrl-btn">Unlock so they can pick again</button>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<%= form_with url: hr_lite.slot_admin_attendance_path(employee.id), method: :post, local: true, class: "hrl-mt" do %>
|
|
14
|
+
<div class="hrl-field">
|
|
15
|
+
<label for="hrl-admin-slot-start">Set slot</label>
|
|
16
|
+
<%= select_tag :start_minute, options_for_select(HrLite::AttendanceSlot::START_MINUTES.map { |m| [ HrLite::AttendanceSlot.label(m), m ] }, slot&.start_minute || HrLite::AttendanceSlot::DEFAULT_START_MINUTE), id: "hrl-admin-slot-start" %>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="hrl-field">
|
|
19
|
+
<label for="hrl-admin-slot-period">For</label>
|
|
20
|
+
<%= select_tag :period, options_for_select(HrLite::AttendanceSlot::PERIODS.invert), id: "hrl-admin-slot-period" %>
|
|
21
|
+
</div>
|
|
22
|
+
<button type="submit" class="hrl-btn">Set and lock</button>
|
|
23
|
+
<% end %>
|
|
24
|
+
</section>
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
<p class="hrl-small hrl-muted hrl-mt">Tap a date to fix its punches.</p>
|
|
25
25
|
</section>
|
|
26
26
|
|
|
27
|
+
<% if HrLite.config.attendance_slots %>
|
|
28
|
+
<%= render "slot", employee: @employee %>
|
|
29
|
+
<%= render "hr_lite/attendance/penalties", user: @employee, month: @month, removable: @employee.id != hr_current_user.id %>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
27
32
|
<% if @punches.any? %>
|
|
28
33
|
<section class="hrl-card">
|
|
29
34
|
<h2 class="hrl-card__title">Where they punched</h2>
|
|
@@ -74,6 +74,17 @@
|
|
|
74
74
|
<%= f.label :probation_until, "On probation until (blank = not on probation)" %>
|
|
75
75
|
<%= f.date_field :probation_until %>
|
|
76
76
|
</div>
|
|
77
|
+
<% if HrLite.config.attendance_slots %>
|
|
78
|
+
<div class="hrl-field">
|
|
79
|
+
<%= f.label :buffer_minutes, "Late buffer (delays late penalties; colleagues see the reason category)" %>
|
|
80
|
+
<%= f.select :buffer_minutes, HrLite::EmployeeProfile::BUFFER_MINUTES.map { |m| [ "#{m / 60}h", m ] }, include_blank: "None" %>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="hrl-field">
|
|
83
|
+
<%= f.label :buffer_reason, "Buffer reason" %>
|
|
84
|
+
<%= f.select :buffer_reason, HrLite::EmployeeProfile::BUFFER_REASONS.invert, include_blank: "Choose one" %>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="hrl-field"><%= f.label :buffer_note, "Buffer note (HR only; required for Other)" %><%= f.text_area :buffer_note, rows: 2 %></div>
|
|
87
|
+
<% end %>
|
|
77
88
|
<div class="hrl-field">
|
|
78
89
|
<%= f.label :date_of_exit, "Date of exit (leave blank while employed)" %>
|
|
79
90
|
<%= f.date_field :date_of_exit %>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<% penalties = HrLite::AttendancePenalty.where(user_id: user.id, date: month.all_month).order(:date, :id) %>
|
|
2
|
+
<section class="hrl-card">
|
|
3
|
+
<h2 class="hrl-card__title">Penalties in <%= month.strftime("%B") %></h2>
|
|
4
|
+
<% if penalties.empty? %>
|
|
5
|
+
<p class="hrl-card--empty">No penalties this month.</p>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% penalties.each do |penalty| %>
|
|
8
|
+
<div class="hrl-feed__item hrl-small">
|
|
9
|
+
<div class="hrl-feed__body">
|
|
10
|
+
<strong><%= penalty.date.strftime("%a %-d %b") %></strong> ·
|
|
11
|
+
<span class="<%= class_names("hrl-badge", penalty.removed_at ? "hrl-badge--muted" : "hrl-badge--warn") %>">+<%= penalty.minutes %> min</span>
|
|
12
|
+
<%= penalty.label %>
|
|
13
|
+
<% if penalty.removed_at %>
|
|
14
|
+
<div class="hrl-muted">Removed by HR: <%= penalty.removal_reason %></div>
|
|
15
|
+
<% elsif local_assigns[:removable] %>
|
|
16
|
+
<%= form_with url: hr_lite.remove_penalty_admin_attendance_path(user.id, penalty_id: penalty.id), method: :post, local: true, class: "hrl-row" do %>
|
|
17
|
+
<label class="hrl-sr-only" for="hrl-remove-<%= penalty.id %>">Reason to remove</label>
|
|
18
|
+
<input type="text" class="hrl-input" name="removal_reason" id="hrl-remove-<%= penalty.id %>" required placeholder="Reason to remove">
|
|
19
|
+
<button type="submit" class="hrl-btn">Remove</button>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
</section>
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
<span class="hrl-muted">Not checked in yet.</span>
|
|
29
29
|
<% end %>
|
|
30
30
|
</div>
|
|
31
|
+
<% if HrLite.config.attendance_slots %>
|
|
32
|
+
<%= render "hr_lite/attendance/slot", record: record %>
|
|
33
|
+
<% end %>
|
|
31
34
|
<% if record&.flagged? %>
|
|
32
35
|
<p class="hrl-small">Flagged: <%= record.flag_note %></p>
|
|
33
36
|
<% end %>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<% slot = HrLite::AttendanceSlot.current(hr_current_user.id, Date.current)
|
|
2
|
+
start = slot&.start_minute || HrLite::AttendanceSlot::DEFAULT_START_MINUTE
|
|
3
|
+
due = record&.required_check_out_at %>
|
|
4
|
+
<div class="hrl-row hrl-row--mb hrl-small">
|
|
5
|
+
<span class="hrl-badge">Slot <%= HrLite::AttendanceSlot.label(start) %><%= " (default)" unless slot %></span>
|
|
6
|
+
<% if slot&.locked? %><span class="hrl-muted">Locked till <%= slot.ends_on.strftime("%-d %b") %></span><% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<% if due && record.check_out_at %>
|
|
9
|
+
<% short = due - record.check_out_at %>
|
|
10
|
+
<p class="hrl-small">Checkout was due <%= due.strftime("%H:%M") %>.
|
|
11
|
+
<% if short.positive? %><span class="hrl-badge hrl-badge--bad">Short by <%= hrl_duration(short) %></span> +30 min goes on your next working day.<% end %></p>
|
|
12
|
+
<% elsif due %>
|
|
13
|
+
<% left = due - Time.current %>
|
|
14
|
+
<p class="hrl-small">Checked in <%= record.check_in_at.strftime("%H:%M") %> · checkout at <strong><%= due.strftime("%H:%M") %></strong> ·
|
|
15
|
+
<%= left.positive? ? "#{hrl_duration(left)} left" : "you can check out" %></p>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% HrLite::AttendancePenalty.active.where(user_id: hr_current_user.id, date: Date.current).each do |penalty| %>
|
|
18
|
+
<p class="hrl-small"><span class="hrl-badge hrl-badge--warn">+<%= penalty.minutes %> min</span> <%= penalty.label %></p>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% unless slot&.locked? || record&.check_in_at %>
|
|
21
|
+
<%= form_with url: hr_lite.slot_attendance_path, method: :post, local: true, class: "hrl-mt" do %>
|
|
22
|
+
<div class="hrl-field">
|
|
23
|
+
<label for="hrl-slot-start">Check-in slot</label>
|
|
24
|
+
<%= select_tag :start_minute, options_for_select(HrLite::AttendanceSlot::START_MINUTES.map { |m| [ "#{HrLite::AttendanceSlot.label(m)} – out #{HrLite::AttendanceSlot.label(m + HrLite::AttendanceSlot::DAY_MINUTES)}", m ] }, start), id: "hrl-slot-start" %>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="hrl-field">
|
|
27
|
+
<label for="hrl-slot-period">For</label>
|
|
28
|
+
<%= select_tag :period, options_for_select(HrLite::AttendanceSlot::PERIODS.invert), id: "hrl-slot-period" %>
|
|
29
|
+
</div>
|
|
30
|
+
<button type="submit" class="hrl-btn hrl-btn--block">Save and lock slot</button>
|
|
31
|
+
<p class="hrl-small hrl-muted">Once saved, only HR can change it for that period.</p>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% end %>
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
· <a href="<%= hr_lite.regularization_requests_path %>">My tickets</a></p>
|
|
26
26
|
</section>
|
|
27
27
|
|
|
28
|
+
<% if HrLite.config.attendance_slots %>
|
|
29
|
+
<%= render "hr_lite/attendance/penalties", user: hr_current_user, month: @month %>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
28
32
|
<% if @day %>
|
|
29
33
|
<% rec, leave = @day_info.record, @day_info.leave
|
|
30
34
|
part = rec&.half_day_part.presence || leave&.half_day_part.presence
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
<tr>
|
|
31
31
|
<td data-label="Name">
|
|
32
32
|
<span><%= tag.strong hr_display_name(row.user), title: ("On leave: #{row.leave.date_range_label}" if row.leave) %><% if row.profile&.designation.present? %>
|
|
33
|
-
<span class="hrl-small hrl-muted"> · <%= row.profile.designation %></span><% end
|
|
33
|
+
<span class="hrl-small hrl-muted"> · <%= row.profile.designation %></span><% end %><% if HrLite.config.attendance_slots && row.profile&.buffer_minutes %>
|
|
34
|
+
<span class="hrl-badge hrl-badge--muted">+<%= row.profile.buffer_minutes / 60 %>h buffer · <%= HrLite::EmployeeProfile::BUFFER_REASONS[row.profile.buffer_reason] %></span><% end %></span>
|
|
34
35
|
</td>
|
|
35
36
|
<td data-label="Status"><%= hrl_team_status(row) %></td>
|
|
36
37
|
<td data-label="Hours <%= @date == Date.current ? "today" : "that day" %>" class="hrl-num"><%= hrl_duration(row.day_seconds) %></td>
|
data/config/routes.rb
CHANGED
|
@@ -7,6 +7,7 @@ HrLite::Engine.routes.draw do
|
|
|
7
7
|
resource :attendance, only: :show, controller: "attendance" do
|
|
8
8
|
post :check_in
|
|
9
9
|
post :check_out
|
|
10
|
+
post :slot
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
resources :leave_requests, only: %i[index show new create] do
|
|
@@ -59,6 +60,7 @@ HrLite::Engine.routes.draw do
|
|
|
59
60
|
get "overview", to: "overview#index"
|
|
60
61
|
resources :attendances, only: %i[index show update], param: :user_id do
|
|
61
62
|
collection { get :week }
|
|
63
|
+
member { post :slot; post :unlock_slot; post :remove_penalty }
|
|
62
64
|
end
|
|
63
65
|
resources :leave_requests, only: %i[index show new create edit update] do
|
|
64
66
|
member { post :approve; post :reject; post :cancel }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateHrLiteAttendanceSlots < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hr_lite_attendance_slots do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.date :starts_on, null: false
|
|
6
|
+
t.date :ends_on, null: false
|
|
7
|
+
t.string :period, null: false
|
|
8
|
+
t.integer :start_minute, null: false
|
|
9
|
+
t.boolean :locked, null: false, default: true
|
|
10
|
+
t.bigint :set_by_id
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
add_index :hr_lite_attendance_slots, [ :user_id, :starts_on, :ends_on ]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class CreateHrLiteAttendancePenalties < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hr_lite_attendance_penalties do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.date :date, null: false
|
|
6
|
+
t.integer :minutes, null: false
|
|
7
|
+
t.string :reason, null: false
|
|
8
|
+
t.bigint :attendance_record_id, null: false
|
|
9
|
+
t.datetime :removed_at
|
|
10
|
+
t.bigint :removed_by_id
|
|
11
|
+
t.text :removal_reason
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
add_index :hr_lite_attendance_penalties, [ :user_id, :date ]
|
|
16
|
+
add_index :hr_lite_attendance_penalties, [ :attendance_record_id, :reason, :date ], unique: true,
|
|
17
|
+
name: "index_hr_lite_attendance_penalties_once"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddBufferToHrLiteEmployeeProfiles < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :hr_lite_employee_profiles, :buffer_minutes, :integer
|
|
4
|
+
add_column :hr_lite_employee_profiles, :buffer_reason, :string
|
|
5
|
+
add_column :hr_lite_employee_profiles, :buffer_note, :text
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -16,7 +16,9 @@ module HrLite
|
|
|
16
16
|
# e.g. 6 — default probation for new hires (admin edits per employee)
|
|
17
17
|
:probation_months,
|
|
18
18
|
# true = a punch without GPS is refused (default: recorded and flagged)
|
|
19
|
-
:require_punch_location
|
|
19
|
+
:require_punch_location,
|
|
20
|
+
# true = check-in slots, late penalties and HR buffers (default off)
|
|
21
|
+
:attendance_slots
|
|
20
22
|
|
|
21
23
|
attr_reader :leave_year_start_month, :slip_release_day, :self_regularization, :work_hours
|
|
22
24
|
|
|
@@ -102,6 +104,7 @@ module HrLite
|
|
|
102
104
|
@slip_release_day = nil # e.g. 10 = slips open on the 10th of the next month
|
|
103
105
|
@self_regularization = nil # e.g. { within_days: 2, per_week: 2 } — employees fix their own missed punch
|
|
104
106
|
@require_punch_location = false
|
|
107
|
+
@attendance_slots = false
|
|
105
108
|
@work_hours = nil # e.g. { day: 8, probation_day: 9, week: 40 } — short days close as half days
|
|
106
109
|
|
|
107
110
|
# Leadership onboarding/offboarding. onboard_user must return a saved
|
data/lib/hr_lite/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hr_lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -168,7 +168,9 @@ files:
|
|
|
168
168
|
- app/models/hr_lite/approval_step.rb
|
|
169
169
|
- app/models/hr_lite/asset.rb
|
|
170
170
|
- app/models/hr_lite/asset_assignment.rb
|
|
171
|
+
- app/models/hr_lite/attendance_penalty.rb
|
|
171
172
|
- app/models/hr_lite/attendance_record.rb
|
|
173
|
+
- app/models/hr_lite/attendance_slot.rb
|
|
172
174
|
- app/models/hr_lite/audit_log.rb
|
|
173
175
|
- app/models/hr_lite/award.rb
|
|
174
176
|
- app/models/hr_lite/benefit.rb
|
|
@@ -234,6 +236,7 @@ files:
|
|
|
234
236
|
- app/views/hr_lite/admin/appraisals/new.html.erb
|
|
235
237
|
- app/views/hr_lite/admin/assets/index.html.erb
|
|
236
238
|
- app/views/hr_lite/admin/assets/new.html.erb
|
|
239
|
+
- app/views/hr_lite/admin/attendances/_slot.html.erb
|
|
237
240
|
- app/views/hr_lite/admin/attendances/index.html.erb
|
|
238
241
|
- app/views/hr_lite/admin/attendances/show.html.erb
|
|
239
242
|
- app/views/hr_lite/admin/attendances/week.html.erb
|
|
@@ -318,7 +321,9 @@ files:
|
|
|
318
321
|
- app/views/hr_lite/appraisals/show.html.erb
|
|
319
322
|
- app/views/hr_lite/approvals/index.html.erb
|
|
320
323
|
- app/views/hr_lite/attendance/_month_grid.html.erb
|
|
324
|
+
- app/views/hr_lite/attendance/_penalties.html.erb
|
|
321
325
|
- app/views/hr_lite/attendance/_punch_card.html.erb
|
|
326
|
+
- app/views/hr_lite/attendance/_slot.html.erb
|
|
322
327
|
- app/views/hr_lite/attendance/show.html.erb
|
|
323
328
|
- app/views/hr_lite/benefits/index.html.erb
|
|
324
329
|
- app/views/hr_lite/calendar/show.html.erb
|
|
@@ -423,6 +428,9 @@ files:
|
|
|
423
428
|
- db/migrate/20261010100500_grant_hr_lite_tax_to_hr.rb
|
|
424
429
|
- db/migrate/20261010110000_create_hr_lite_ca_reviews.rb
|
|
425
430
|
- db/migrate/20261010110100_seed_hr_lite_ca_role.rb
|
|
431
|
+
- db/migrate/20261013090000_create_hr_lite_attendance_slots.rb
|
|
432
|
+
- db/migrate/20261013090100_create_hr_lite_attendance_penalties.rb
|
|
433
|
+
- db/migrate/20261013090200_add_buffer_to_hr_lite_employee_profiles.rb
|
|
426
434
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
427
435
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
428
436
|
- lib/generators/hr_lite/install/templates/initializer.rb
|