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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5463a93b5739d60f73bd2cd554fce576f754218a8415fbd81ae35bdadc6ebcf
4
- data.tar.gz: dadfaf0eb389d5cbe4fd012ef47d05561d03bc7e313462c491359114bb5cf294
3
+ metadata.gz: 2f6f41c24e52098ad42a4af7b5827a3fb2fc09a56e5d46d2194122506faa088b
4
+ data.tar.gz: 6c2323f7e9db6bf95db8f28fac7261b7a670dbdb486b902e30809f5331c95a58
5
5
  SHA512:
6
- metadata.gz: e9d1029c660d36bca33f79ede86ca0684a48616824f243a42df8c8a944b2b1b8fd55401e2cf3fafdf4411e9e33bcca24a9ddbc99c5337ed45db6a0e0830db541
7
- data.tar.gz: 5c7a048e80c6522360eae49ddedf37589762ac548fa4768c5f6d3ff9d2a25be3c5fd8925dcdfdce79ee35daf003c13efd8eb9ecbab29567c83983b87cc5cb70c
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
- # Half days the close job set (not HR); an approved ticket lifts them.
7
- AUTO_NOTES = [ AUTO_CHECKOUT_NOTE, SHORT_DAY_NOTE ].freeze
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 %></span>
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
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.29.0"
2
+ VERSION = "0.30.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.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