hr_lite 0.40.2 → 0.48.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/app/assets/javascripts/hr_lite/confirm.js +2 -1
  4. data/app/assets/javascripts/hr_lite/geo_punch.js +16 -5
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +1 -1
  6. data/app/controllers/hr_lite/admin/attendances_controller.rb +31 -2
  7. data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
  8. data/app/controllers/hr_lite/admin/settings_controller.rb +2 -1
  9. data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +14 -1
  10. data/app/jobs/hr_lite/attendance_close_job.rb +4 -3
  11. data/app/models/hr_lite/attendance_penalty.rb +10 -0
  12. data/app/models/hr_lite/attendance_punch.rb +14 -0
  13. data/app/models/hr_lite/attendance_record.rb +30 -12
  14. data/app/models/hr_lite/attendance_slot.rb +0 -1
  15. data/app/models/hr_lite/employee_profile.rb +11 -0
  16. data/app/models/hr_lite/leave_balance.rb +11 -0
  17. data/app/models/hr_lite/leave_request.rb +13 -6
  18. data/app/models/hr_lite/penalty_removal.rb +13 -0
  19. data/app/models/hr_lite/setting.rb +8 -0
  20. data/app/models/hr_lite/wfh_request.rb +23 -3
  21. data/app/services/hr_lite/attendance_puncher.rb +26 -10
  22. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
  23. data/app/views/hr_lite/admin/attendances/show.html.erb +9 -0
  24. data/app/views/hr_lite/admin/employees/_form.html.erb +5 -0
  25. data/app/views/hr_lite/admin/leave_balances/index.html.erb +1 -1
  26. data/app/views/hr_lite/admin/settings/edit.html.erb +24 -0
  27. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +17 -0
  28. data/app/views/hr_lite/attendance/_penalties.html.erb +18 -0
  29. data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -10
  30. data/app/views/hr_lite/attendance/_slot.html.erb +1 -1
  31. data/app/views/hr_lite/attendance/_trail.html.erb +3 -0
  32. data/app/views/hr_lite/attendance/show.html.erb +1 -0
  33. data/app/views/hr_lite/leave_balances/index.html.erb +1 -0
  34. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +1 -1
  35. data/app/views/hr_lite/leave_requests/new.html.erb +2 -2
  36. data/app/views/hr_lite/work_time/_bars.html.erb +2 -2
  37. data/app/views/hr_lite/work_time/_report.html.erb +2 -2
  38. data/config/routes.rb +2 -2
  39. data/db/migrate/20261015090000_add_working_day_to_hr_lite_settings.rb +8 -0
  40. data/db/migrate/20261015091000_add_rechecked_in_at_to_hr_lite_attendance_records.rb +5 -0
  41. data/db/migrate/20261015092000_add_hours_allowance_to_hr_lite_employee_profiles.rb +6 -0
  42. data/db/migrate/20261015100000_create_hr_lite_attendance_punches.rb +20 -0
  43. data/db/migrate/20261016090000_add_penalty_leave_type_to_hr_lite_settings.rb +5 -0
  44. data/db/migrate/20261016091000_create_hr_lite_penalty_removals.rb +13 -0
  45. data/lib/hr_lite/configuration.rb +3 -3
  46. data/lib/hr_lite/notifications.rb +1 -0
  47. data/lib/hr_lite/version.rb +1 -1
  48. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: beaf157b1f52eee0cddeed95c889cf3b27712630941baacd5b60fbfd1ba2b481
4
- data.tar.gz: 8a22145f2f009a31103bbe27f63e3d1418d0c051789bf0922e12a3ef9bc5e119
3
+ metadata.gz: 3780e666e11b64f65243366d445d5f45ee6740eaee2ba3b67c3c4afd5524c630
4
+ data.tar.gz: e28d1822a25528f83491a9c14bb8ff79aa79c3a63735e93714e9cb96874099b3
5
5
  SHA512:
6
- metadata.gz: e7aa940b9bd69ecf36183e72b9188923be608d9304abe86a9cbb676d55a8235a77505f55a581e6e8ab4516fa3f24460915ad26ce11e3693a1b31f551f875b034
7
- data.tar.gz: 0e1ae948beda575e12fe9ad9b01608eac52bc4ea276d0e7a2292ce6a71b39d14e126af1248cd78de389bd696abef87a496ae3e60930e47e107dd99cf20c2b7a1
6
+ metadata.gz: 8ca3e1262c00c6c81477dcda625defdc91bf4e80fd159cc9f6e46ec011867b920398e1cd680fbd9942407b4743d093779759718cad771dc5bd44fff564454cf2
7
+ data.tar.gz: '0395753ca5277c0aa9a01063ced7362531d4514f8d785e07e7dd717c50c5237a9482d38e575396d7ae9891886e2aeeceef813e4143d131990ddf4835993342d9'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.48.2] - 2026-10-16
11
+
12
+ - Breaks count as working time. The standard day is 8h 30m including its break, but My work time (and HR's work time report) showed "Working time 8h 00m" for a full 8h 30m shift, because lunch and short breaks were left out. The KPI now reads "Working time (breaks included)" (8h 30m for that shift), and breaks show as "Of which breaks". The daily table's columns are "Working (breaks incl.)" and "Of which breaks". Only the display changed: attendance, penalties, half day and pay already used check-in to check-out.
13
+
14
+ ## [0.48.1] - 2026-10-16
15
+
16
+ - A choice the punch needs can't be missed on a phone any more. Examples: "Is this your home location?" (Yes, save it / No, I am travelling) and "I am travelling — check out here". The choice sits in the red box, and the page scrolls it to the middle of the screen when the card loads. If the button is tapped without a choice, the page scrolls back to it (the browser's own "select an option" note still shows). Checked at 360 px: the box comes into view, the check-out is held until it's ticked, then it goes through.
17
+
18
+ ## [0.48.0] - 2026-10-16
19
+
20
+ - When location is blocked, the punch card's message ("Location is blocked. To allow it:") and its steps now show in a red box instead of small grey text. The box clears on the next try.
21
+ - New iPhone steps. Location Services can be on while Safari is still blocked, so the steps now cover all three settings: turn on Location Services; on that screen set Safari Websites (or Chrome, Firefox, Edge) to While Using the App; and for Safari, set Settings → Apps → Safari → Location to Allow (or Ask). Then reload. From the Home Screen app, close and reopen it instead. Checked in the iOS 26 Simulator: tapping Check in and choosing Don't Allow shows these steps in red.
22
+
23
+ ## [0.47.0] - 2026-10-16
24
+
25
+ - Penalty hours build up and limit leave. A person's attendance penalties (late, after 11:00, short day) add up with no yearly reset until HR removes them. Every 5 hours holds half a day and 10 hours a full day, the same for everyone: 100h = 10 days.
26
+ - In Settings, HR picks "Penalties come off" a leave type (for example Earned leave); with no type picked, penalties limit nothing. The balance itself is not cut: 12 stays 12. You can apply for at most 12 − 10 = 2 days, rounded down to half days. Past that, the request is refused: "For these dates you can apply for at most 2 days of Earned leave — 10 days held for penalties". Other leave types keep today's rule, where days past the balance are unpaid.
27
+ - A "Penalty hours" section on the employee's Attendance page, and on HR's page for that person, shows the hours held, the days they hold, and every HR removal (hours, who, when, reason). HR can remove any number of hours with a required reason: remove 50 of 100 and 50 stay, or remove all of it to settle to 0. The employee gets a bell and an email, and the audit log records it. Removing a single penalty works as before.
28
+ - HR's team attendance view shows "Penalties 100h 00m · 10 days" on each person who has any, linking to their section.
29
+ - "Held for penalties" and "You can apply" show on the employee's balances page, the leave chips, the apply form (with today's limit) and HR's balances grid.
30
+ - Migrations: `hr_lite_settings.penalty_leave_type_id` and `hr_lite_penalty_removals`.
31
+
32
+ ## [0.46.0] - 2026-10-15
33
+
34
+ - Every check-in and check-out is kept. Checking in again, a repeat check-out and an HR reset used to overwrite the day's check-out, including its time and GPS. Now each punch is saved as its own row: time, GPS, accuracy, place and distance from home. Those rows are never edited or deleted, and saving a change to one raises an error. When a day has more than one in/out pair, "All punches: In 09:30 Office · Out 11:45 Office · In 12:10 Office · Out 18:05 Office" shows on the employee's Today card, their day detail and My work time, and on HR's "Where they punched". A day HR reset with Reset check-in keeps its old punches on show.
35
+ - The day record is still the summary: first check-in to latest check-out. Hours, short day, half day and penalties count the same as before, and the gap between two sessions still counts as worked.
36
+ - Migration: `hr_lite_attendance_punches`. Days punched before this release have no punch rows and show as before.
37
+
38
+ ## [0.45.0] - 2026-10-15
39
+
40
+ - HR can change a WFH request after deciding it. On an approved or rejected request, the "Change this WFH" card lets HR set new dates and switch between Approved and Rejected, with a required reason. For example, a request rejected for 19–23 Oct can be approved for 20–21 Oct only. Past dates are allowed so a request can match the days actually worked from home, up to 60 days long. It can't clash with that person's other pending or approved WFH. The audit log records the old and new dates and decision ("wfh.changed"), and the employee gets the approved/rejected notice with the reason. Attendance days already punched are not rewritten.
41
+
42
+ ## [0.44.0] - 2026-10-15
43
+
44
+ - HR can reset today's punch when someone taps by mistake again (after their one "Check in again" is used). On the employee's attendance page, tap today, give the reason, then pick a reset. Each one asks OK/Cancel first. **Reset check-out** puts them back in: the first check-in stands, their work mode reopens, and they check out later. **Reset check-in** clears the whole day so they check in fresh. Both are audited and the employee is notified.
45
+ - Removing a day's punch now also lifts that check-in's late penalties, recorded as removed by HR with the reason. Before, checking in again was charged twice.
46
+ - `data-turbo-confirm` can sit on a button as well as a form, so one form can hold several confirmed actions.
47
+
48
+ ## [0.43.0] - 2026-10-15
49
+
50
+ - Shorter day: HR can give one employee 30, 60 or 90 minutes off the working day, for example for a long commute. It's set on their profile, with a required reason. The reason is encrypted, kept out of the audit trail, and seen only by HR. Their day becomes the Settings day minus the allowance (8h30 − 90 min = 7h). That drives their "+30 min", "Short by", checkout due time, half-day line check and slot picker. They see "Your day 7h 00m" on their attendance card. This is separate from the late buffer, which still only delays the late clock.
51
+ - `EmployeeProfile.day_minutes_for(user_id)` is the one place that works out a person's day.
52
+ - Migration: `hours_allowance_minutes` and `hours_allowance_reason` on `hr_lite_employee_profiles`.
53
+
54
+ ## [0.42.1] - 2026-10-15
55
+
56
+ - "Check in again" works once a day, and only inside the hours HR sets in Settings (default 09:00–20:00). Once it has been used, or outside those hours, the card shows "Check in again is open once a day, 09:00–20:00." with no button, and the server refuses the punch with the same message. This replaces 0.41.0's "as many times as needed".
57
+ - Migration: `hr_lite_attendance_records.rechecked_in_at`, stamped on the re-check-in.
58
+
59
+ ## [0.42.0] - 2026-10-15
60
+
61
+ - HR sets the working day in Admin › Settings. The working day is check-in to check-out with the break included (default 510 min = 8h 30m). Settings also hold the half-day line (default 255 min = 4h 15m) and the hours when "Check in again" is open (default 09:00–20:00). Changes are audited.
62
+ - A short day is measured in hours worked, not by the clock. The checkout due time is the first check-in + the working day + that day's penalties, so someone who comes at 9:00 and leaves at 17:30 has done 8h30 and gets no "+30 min". Before, the due time was the slot start + 8h30, so an early start still counted as short.
63
+ - A day becomes a half day, with the notice, only when the hours worked are under the half-day line ("You worked 4.0h on 06 Jul, under 4h15 — marked half day"). Short of the working day but above the line: only the +30 min on the next working day. Before, anything under 8h was a half day.
64
+ - One working day for everyone: probation staff no longer need 9h.
65
+ - `config.work_hours` still turns the hours rules on (nil = off), and `week` is still read. `day` and `probation_day` are no longer read. `AttendanceSlot::DAY_MINUTES` is removed. The slot picker shows each check-out time from the Settings day.
66
+ - Migration: four columns on `hr_lite_settings`.
67
+
68
+ ## [0.41.0] - 2026-10-15
69
+
70
+ - Check out asks first: "Check out now? Your check-out is saved as soon as you press OK." The check-out is recorded only on OK; Cancel leaves you checked in. This covers both the day's Check out and "Check out yesterday's shift".
71
+ - Check in again: after checking out, the card offers "Check in again", as many times as needed in a day. It reopens the day. The first check-in time stands and the check-out is cleared until the next one. The gap shows as Offline on the work-mode timeline, so working time excludes it, and the new session takes this punch's work mode. Re-checking in does not apply the late-arrival rules again. "Update check-out" is gone: check in again, then check out.
72
+
10
73
  ## [0.40.2] - 2026-10-15
11
74
 
12
75
  - ID card: a long name no longer runs off the card. When someone has no name and their email stands in for it ("sageashish2714@gmail.com"), it breaks before the "@" onto a second line. Any other unbroken word breaks wherever it has to.
@@ -18,7 +18,8 @@
18
18
  var form = event.target;
19
19
  if (!form || form.nodeName !== "FORM") return;
20
20
 
21
- var message = form.getAttribute("data-turbo-confirm");
21
+ // A button can carry its own prompt (one form, several actions).
22
+ var message = (event.submitter && event.submitter.getAttribute("data-turbo-confirm")) || form.getAttribute("data-turbo-confirm");
22
23
  if (message && !window.confirm(message)) {
23
24
  event.preventDefault();
24
25
  event.stopPropagation();
@@ -19,13 +19,16 @@
19
19
  var ua = navigator.userAgent;
20
20
  var ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
21
21
  if (ios) {
22
+ // Location Services can be on while Safari Websites (or the browser app) is set
23
+ // to Never, or this site to Deny — the usual iPhone block.
22
24
  var app = /CriOS/.test(ua) ? "Chrome" : /FxiOS/.test(ua) ? "Firefox" : /EdgiOS/.test(ua) ? "Edge" : null;
23
- return [
25
+ var steps = [
24
26
  "Open Settings → Privacy & Security → Location Services and turn it on.",
25
- app ? "In Settings, open Apps → " + app + " → Location and choose While Using the App."
26
- : "In Settings, open Apps → Safari → Location and choose Allow (or Ask).",
27
- "Come back here, reload the page and tap again."
27
+ "On that screen, scroll down to " + (app || "Safari Websites") + " and choose While Using the App."
28
28
  ];
29
+ if (!app) steps.push("Then open Settings → Apps → Safari → Location and choose Allow (or Ask).");
30
+ steps.push(navigator.standalone ? "Close this app fully, open it again and tap again." : "Reload this page and tap again.");
31
+ return steps;
29
32
  }
30
33
  if (/Android/.test(ua)) {
31
34
  var samsung = /SamsungBrowser/.test(ua);
@@ -49,6 +52,7 @@
49
52
  function showHelp(label, headline) {
50
53
  if (!label) return;
51
54
  label.textContent = headline;
55
+ label.classList.replace("hrl-muted", "hrl-errors") || label.classList.add("hrl-errors"); // red until the next try
52
56
  var list = document.createElement("ol");
53
57
  list.className = "hrl-geo-help";
54
58
  locationSteps().forEach(function (step) {
@@ -69,6 +73,13 @@
69
73
  var button = form.querySelector("[type=submit]");
70
74
  var label = form.querySelector("[data-hrl-geo-status]");
71
75
  var required = form.hasAttribute("data-hrl-geo-required");
76
+ // A choice the punch needs (home or travelling) sits mid-card on a phone and was
77
+ // missed: bring it into view now, and again if the button is tapped without it.
78
+ var choose = form.querySelector("[data-hrl-must-choose]");
79
+ if (choose) {
80
+ choose.scrollIntoView({ block: "center" });
81
+ form.addEventListener("invalid", function () { choose.scrollIntoView({ block: "center" }); }, true);
82
+ }
72
83
  var locating = false;
73
84
 
74
85
  function send(status) {
@@ -88,7 +99,7 @@
88
99
  event.preventDefault();
89
100
  locating = true;
90
101
  if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); }
91
- if (label) { label.hidden = false; label.textContent = "Getting location…"; }
102
+ if (label) { label.hidden = false; label.classList.replace("hrl-errors", "hrl-muted"); label.textContent = "Getting location…"; }
92
103
 
93
104
  if (!navigator.geolocation) {
94
105
  if (required) hold("This browser cannot share your location. Open this page in Chrome or Safari.");
@@ -260,7 +260,7 @@ details > form > button[type=submit], details > form > .hrl-btn { margin-top: .2
260
260
  .hrl-listrow > :first-child { display: inline-flex; align-items: center; gap: var(--hrl-s-1); line-height: var(--hrl-lh-tight); }
261
261
  /* The punch status stays in the accessibility tree (no `hidden`) so its first message is announced; empty, it takes no space. */
262
262
  [data-hrl-geo-status]:empty { margin: 0; }
263
- .hrl-geo-help { margin: var(--hrl-s-2) 0 0; padding-left: var(--hrl-s-4); color: var(--hrl-ink); }
263
+ .hrl-geo-help { margin: var(--hrl-s-2) 0 0; padding-left: var(--hrl-s-4); color: inherit; }
264
264
  .hrl-geo-help li + li { margin-top: var(--hrl-s-1); }
265
265
  .hrl-grid { display: grid; gap: .85rem; grid-template-columns: 1fr; margin-bottom: .85rem; }
266
266
  .hrl-grid > * { min-width: 0; }
@@ -11,6 +11,7 @@ module HrLite
11
11
  @employees = @employees.select { |user| visible.include?(user.id) } if visible
12
12
  @records = AttendanceRecord.for_date(@date).where(user_id: @employees.map(&:id)).index_by(&:user_id)
13
13
  @flagged_count = @records.values.count(&:flagged?)
14
+ @penalty_minutes = AttendancePenalty.held_minutes(@employees.map(&:id))
14
15
  # "How many people punched where": off-site punches count as one place.
15
16
  @offices = OfficeLocation.active.to_a
16
17
  @places = %i[check_in check_out].index_with do |side|
@@ -62,7 +63,8 @@ module HrLite
62
63
 
63
64
  # Regularization: fix punches with a mandatory note, fully audited.
64
65
  # Clearing both punch times deletes the record (that is how an
65
- # erroneous punch is removed).
66
+ # erroneous punch is removed). Reset check-in does the same; reset
67
+ # check-out leaves them checked in, so they can check in or out again.
66
68
  def update
67
69
  @employee = find_manageable_employee
68
70
  date = parse_date_param(params[:date])
@@ -75,7 +77,7 @@ module HrLite
75
77
  end
76
78
 
77
79
  attrs = params.require(:attendance_record).permit(:check_in_at, :check_out_at, :status, :half_day_part)
78
- if attrs[:check_in_at].blank? && attrs[:check_out_at].blank?
80
+ if params[:reset] == "check_in" || (attrs[:check_in_at].blank? && attrs[:check_out_at].blank?)
79
81
  # Nothing to remove used to still write a `destroy` audit row with
80
82
  # subject_id 0 and email the employee that a punch was removed.
81
83
  unless record.persisted?
@@ -84,6 +86,9 @@ module HrLite
84
86
  end
85
87
 
86
88
  record.destroy
89
+ # Its late penalties go too, so checking in again is not charged twice.
90
+ AttendancePenalty.active.where(attendance_record_id: record.id)
91
+ .find_each { |p| p.update!(removed_at: Time.current, removed_by_id: hr_current_user.id, removal_reason: note) }
87
92
  log_regularization(record, note, removed: true)
88
93
  return redirect_to admin_attendance_path(@employee.id, month: date.strftime("%Y-%m")),
89
94
  notice: "Punch removed."
@@ -98,6 +103,7 @@ module HrLite
98
103
  end
99
104
 
100
105
  record.assign_attributes(attrs)
106
+ record.clear_check_out if params[:reset] == "check_out"
101
107
  record.half_day_part = attrs[:half_day_part].presence if attrs.key?(:half_day_part)
102
108
  record.status = record.half_day_part ? "half_day" : "present" if attrs.key?(:half_day_part)
103
109
  record.status = "present" if record.status.blank?
@@ -107,6 +113,10 @@ module HrLite
107
113
 
108
114
  if record.save
109
115
  log_regularization(record, note, removed: false)
116
+ if params[:reset] == "check_out" && date == Date.current
117
+ WorkModeEvent.start!(user_id: @employee.id, mode: record.work_mode || "office", source: "hr",
118
+ actor: hr_current_user, attendance_record: record)
119
+ end
110
120
  redirect_to admin_attendance_path(@employee.id, month: date.strftime("%Y-%m")),
111
121
  notice: "Attendance updated."
112
122
  else
@@ -144,6 +154,25 @@ module HrLite
144
154
  redirect_to admin_attendance_path(@employee.id, month: month), notice: "Penalty removed."
145
155
  end
146
156
 
157
+ # HR settles part or all of a person's penalty hours with a reason; the rest stays.
158
+ def remove_penalty_hours
159
+ @employee = find_manageable_employee
160
+ minutes = (params[:hours].to_f * 60).round
161
+ reason = params[:reason].to_s.strip
162
+ held = AttendancePenalty.held_minutes(@employee.id)[@employee.id].to_i
163
+ back = admin_attendance_path(@employee.id, anchor: "penalties")
164
+ return redirect_to back, alert: "A reason is required." if reason.blank?
165
+ return redirect_to back, alert: "Remove between 0.5h and #{helpers.hrl_days(held / 60.0)}h." unless minutes.positive? && minutes <= held
166
+
167
+ removal = PenaltyRemoval.create!(user_id: @employee.id, minutes: minutes, reason: reason, removed_by_id: hr_current_user.id)
168
+ hours = helpers.hrl_days(minutes / 60.0)
169
+ AuditLog.record!(action: "attendance.penalty_hours_removed", subject: removal, actor: hr_current_user,
170
+ changes: { "minutes" => minutes, "reason" => reason })
171
+ Notifications.publish("attendance.penalty_removed", title: "HR removed #{hours}h of your penalties", body: reason,
172
+ path: "/attendance#penalties", bell_to: [ @employee ], email_to: [ @employee ])
173
+ redirect_to back, notice: "Removed #{hours}h of penalties."
174
+ end
175
+
147
176
  private
148
177
 
149
178
  # Viewing somebody's month and rewriting their punches are different
@@ -116,7 +116,7 @@ module HrLite
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
118
  :fy_opening_gross, :fy_opening_tds, :blood_group, :emergency_contact,
119
- :buffer_minutes, :buffer_reason, :buffer_note, :permanent_wfh, :field_staff, :office_location_id
119
+ :buffer_minutes, :buffer_reason, :buffer_note, :hours_allowance_minutes, :hours_allowance_reason, :permanent_wfh, :field_staff, :office_location_id
120
120
  )
121
121
  end
122
122
 
@@ -7,7 +7,8 @@ module HrLite
7
7
 
8
8
  def update
9
9
  @setting = Setting.instance
10
- if @setting.update(params.require(:setting).permit(:weekend_policy, :employee_code_prefix, :lunch_break_minutes, :short_break_minutes))
10
+ if @setting.update(params.require(:setting).permit(:weekend_policy, :employee_code_prefix, :lunch_break_minutes, :short_break_minutes,
11
+ :day_minutes, :half_day_below_minutes, :recheck_in_from, :recheck_in_until, :penalty_leave_type_id))
11
12
  redirect_to edit_admin_setting_path, notice: "Settings saved."
12
13
  else
13
14
  render :edit, status: :unprocessable_entity
@@ -1,7 +1,7 @@
1
1
  module HrLite
2
2
  module Admin
3
3
  # WFH board (counts, who is home on a date, every schedule) and the
4
- # decision screen. Viewing is attendance.view, deciding attendance.manage.
4
+ # decision screen. Viewing is attendance.view, deciding and changing attendance.manage.
5
5
  class WfhRequestsController < BaseController
6
6
  def index
7
7
  @from = params[:from].present? ? parse_date_param(params[:from]) : Date.current
@@ -64,6 +64,19 @@ module HrLite
64
64
  redirect_to admin_wfh_request_path(request), alert: "Only pending requests can be decided."
65
65
  end
66
66
 
67
+ # HR changes an approved or rejected request: dates (past allowed) and/or decision, with a reason.
68
+ def update
69
+ request = decidable.where(status: %w[approved rejected]).find(params[:id])
70
+ note = params[:decision_note].to_s.strip
71
+ return redirect_to admin_wfh_request_path(request), alert: "A reason is required to change WFH." if note.blank?
72
+
73
+ request.change!(actor: hr_current_user, start_date: params[:start_date], end_date: params[:end_date],
74
+ status: params[:status].presence_in(%w[approved rejected]) || request.status, note: note)
75
+ redirect_to admin_wfh_request_path(request), notice: "WFH changed for #{hr_display_name(request.user)}."
76
+ rescue ActiveRecord::RecordInvalid => e
77
+ redirect_to admin_wfh_request_path(request), alert: "Could not change WFH: #{e.record.errors.full_messages.to_sentence}"
78
+ end
79
+
67
80
  private
68
81
 
69
82
  # Nobody decides their own request; out-of-scope ids are a 404.
@@ -2,7 +2,7 @@ module HrLite
2
2
  # Closes the working day that ended last: a run at 23:55 closes today, and a
3
3
  # retry or late run after midnight (or a morning schedule) closes yesterday.
4
4
  # A punch left open is checked out at the day's end and paid as a half day,
5
- # and so is a closed punch short of config.work_hours.
5
+ # and so is a closed punch under the half-day line HR sets in Settings.
6
6
  # Nobody punched in already reads as absent (DayStatus), so they are only
7
7
  # told. Leave days, and each person's weekends and office holidays, are left alone.
8
8
  class AttendanceCloseJob < ApplicationJob
@@ -22,10 +22,11 @@ module HrLite
22
22
  record.update!(check_out_at: date.end_of_day, status: "half_day",
23
23
  regularization_note: AttendanceRecord::AUTO_CHECKOUT_NOTE)
24
24
  tell(user, date, "attendance.missed_check_out", "You didn't check out on #{day} — marked half day")
25
- elsif !record.regularized? && record.status == "present" && record.short?
25
+ elsif !record.regularized? && record.status == "present" && record.below_half_day?
26
26
  record.update!(status: "half_day", regularization_note: AttendanceRecord::SHORT_DAY_NOTE)
27
+ line = Setting.instance.half_day_below_minutes
27
28
  tell(user, date, "attendance.short_day",
28
- "You worked #{(record.worked_duration / 3600.0).round(1)} of #{record.required_hours}h on #{day} — marked half day")
29
+ "You worked #{(record.worked_duration / 3600.0).round(1)}h on #{day}, under #{line / 60}h#{format('%02d', line % 60)} — marked half day")
29
30
  end
30
31
  penalize_short_day(record) if record&.check_out_at
31
32
  end
@@ -25,6 +25,16 @@ module HrLite
25
25
  ((date + 1)..(date + 31)).select { |d| calendar.working_day?(d) }.first(count)
26
26
  end
27
27
 
28
+ # Penalty minutes still held per person: every active penalty ever, less the
29
+ # hours HR removed. They build up until HR removes them; never below 0.
30
+ def self.held_minutes(user_ids)
31
+ removed = PenaltyRemoval.where(user_id: user_ids).group(:user_id).sum(:minutes)
32
+ active.where(user_id: user_ids).group(:user_id).sum(:minutes).to_h { |id, m| [ id, [ m - removed[id].to_i, 0 ].max ] }
33
+ end
34
+
35
+ # Every 5h held is half a day of leave: 7h = 0.5, 10h = 1, 100h = 10.
36
+ def self.days_for(minutes) = (minutes / 300) * 0.5
37
+
28
38
  def label = REASONS.fetch(reason)
29
39
  end
30
40
  end
@@ -0,0 +1,14 @@
1
+ module HrLite
2
+ # One check-in or check-out as it happened. Append-only: checking in again,
3
+ # a repeat check-out or an HR reset changes the day record, never these.
4
+ class AttendancePunch < ApplicationRecord
5
+ belongs_to :user, class_name: HrLite.config.user_class
6
+
7
+ validates :kind, inclusion: { in: %w[in out] }
8
+
9
+ def readonly? = persisted?
10
+
11
+ # "In 09:30 Office" — the day's punches read as one line.
12
+ def to_s = [ kind == "in" ? "In" : "Out", at.strftime("%H:%M"), AttendanceRecord::PLACES[place] ].compact.join(" ")
13
+ end
14
+ end
@@ -51,27 +51,45 @@ module HrLite
51
51
  check_out_at - check_in_at
52
52
  end
53
53
 
54
- # config.work_hours: probation days need more hours. Nil when the rule is
55
- # off, and on Saturdays (a working Saturday has no hours rule).
56
- def required_hours
57
- rule = HrLite.config.work_hours
58
- return if rule.nil? || date.saturday?
59
-
60
- EmployeeProfile.find_by(user_id: user_id)&.on_probation?(date) ? rule[:probation_day] : rule[:day]
54
+ # The working day HR sets in Settings, less the person's allowance. Nil when
55
+ # the host turns hours rules off (config.work_hours), and on Saturdays.
56
+ def required_minutes
57
+ EmployeeProfile.day_minutes_for(user_id) unless HrLite.config.work_hours.nil? || date.saturday?
61
58
  end
62
59
 
60
+ # Hours worked, not the clock: an early start counts.
63
61
  def short?
64
- hours = required_hours
65
- !!(hours && worked_duration && worked_duration < hours.hours)
62
+ worked_under?(required_minutes)
63
+ end
64
+
65
+ def below_half_day?
66
+ worked_under?(required_minutes && Setting.instance.half_day_below_minutes)
66
67
  end
67
68
 
68
- # config.attendance_slots: slot start + 8h30 + the day's active penalties.
69
+ # config.attendance_slots: first check-in + the working day + the day's active penalties.
69
70
  def required_check_out_at
70
71
  return unless HrLite.config.attendance_slots && check_in_at
71
72
 
72
73
  extra = AttendancePenalty.active.where(user_id: user_id, date: date).sum(:minutes)
73
- date.in_time_zone(HrLite.config.time_zone) +
74
- (AttendanceSlot.start_minute_for(user_id, date) + AttendanceSlot::DAY_MINUTES + extra).minutes
74
+ check_in_at + (EmployeeProfile.day_minutes_for(user_id) + extra).minutes
75
+ end
76
+
77
+ def worked_under?(minutes) = !!(minutes && worked_duration && worked_duration < minutes.minutes)
78
+
79
+ # Check in again after checking out: once a day, inside the hours HR sets in Settings.
80
+ def recheck_in_open?(at = Time.current)
81
+ s = Setting.instance
82
+ now = at.in_time_zone(HrLite.config.time_zone).strftime("%H:%M")
83
+ check_out_at.present? && rechecked_in_at.nil? && now >= s.recheck_in_from && now < s.recheck_in_until
84
+ end
85
+
86
+ # Every check-in and check-out of the day, oldest first, kept even after an HR reset.
87
+ def punches = AttendancePunch.where(user_id: user_id, date: date).order(:at, :id).to_a
88
+
89
+ # Drops the check-out so the person is in again (check in again, HR reset).
90
+ def clear_check_out(**attrs)
91
+ assign_attributes(check_out_at: nil, check_out_lat: nil, check_out_lng: nil, check_out_accuracy_m: nil,
92
+ check_out_place: nil, check_out_home_m: nil, check_out_work_location_id: nil, **attrs)
75
93
  end
76
94
 
77
95
  # Metres between where the day was checked in and checked out.
@@ -7,7 +7,6 @@ module HrLite
7
7
  START_MINUTES = [ 540, 570, 600, 630 ].freeze # 09:00 09:30 10:00 10:30
8
8
  DEFAULT_START_MINUTE = 600
9
9
  PERIODS = { "day" => "Today", "week" => "This week", "month" => "This month" }.freeze
10
- DAY_MINUTES = 510 # 8h30m from slot to checkout
11
10
  GRACE_MINUTES = 5
12
11
  STEP_MINUTES = 30
13
12
  CARRY_AFTER_MINUTE = 660 # 11:00 — +1h on the next 2 working days
@@ -33,6 +33,17 @@ module HrLite
33
33
  validates :buffer_reason, inclusion: { in: BUFFER_REASONS.keys }, if: :buffer_minutes
34
34
  validates :buffer_note, presence: true, if: -> { buffer_minutes && buffer_reason == "other" }
35
35
 
36
+ # HR-granted shorter day (e.g. a long commute): minutes off the Settings
37
+ # day, so 90 off 8h30 is 7h. Only HR sees the reason (encrypted).
38
+ HOURS_ALLOWANCE_MINUTES = [ 30, 60, 90 ].freeze
39
+ encrypts :hours_allowance_reason
40
+ normalizes :hours_allowance_minutes, with: ->(v) { v.presence }
41
+ validates :hours_allowance_minutes, inclusion: { in: HOURS_ALLOWANCE_MINUTES }, allow_nil: true
42
+ validates :hours_allowance_reason, presence: true, if: :hours_allowance_minutes
43
+
44
+ # One person's working day: HR's day in Settings less their allowance.
45
+ def self.day_minutes_for(user_id) = Setting.instance.day_minutes - where(user_id: user_id).pick(:hours_allowance_minutes).to_i
46
+
36
47
  TAX_REGIMES = %w[new old].freeze
37
48
  BLOOD_GROUPS = %w[A+ A- B+ B- AB+ AB- O+ O-].freeze
38
49
  normalizes :blood_group, with: ->(v) { v.presence } # the "Not set" chip posts ""
@@ -71,6 +71,17 @@ module HrLite
71
71
  entitled(as_of: as_of) - used
72
72
  end
73
73
 
74
+ # What can be applied for: available less days held for penalties, in half days.
75
+ def applicable(as_of: Date.current) = ([ available(as_of: as_of) - penalty_days, 0 ].max * 2).floor / 2.0
76
+
77
+ # Days held off the leave type HR picks in Settings. The balance itself is
78
+ # not cut; leave can be applied for only up to available less these.
79
+ def penalty_days
80
+ return 0 unless leave_type_id && leave_type_id == Setting.instance.penalty_leave_type_id
81
+
82
+ AttendancePenalty.days_for(AttendancePenalty.held_minutes(user_id)[user_id].to_i)
83
+ end
84
+
74
85
  private
75
86
 
76
87
  # Quota earned by as_of, before carry/adjustments. Monthly accrual
@@ -362,15 +362,22 @@ module HrLite
362
362
  errors.add(:base, "During probation only 1 day of leave a month is allowed") if taken + days_count > 1
363
363
  end
364
364
 
365
- # Only comp-off is refused past its balance: it is earned credit, never
366
- # borrowed. Any other type's excess becomes loss of pay at approval.
365
+ # Refused past the balance: comp-off (earned credit, never borrowed) and a
366
+ # balance with days held for penalties. Any other excess becomes loss of pay at approval.
367
367
  def sufficient_balance
368
368
  return unless start_date && end_date && leave_type
369
- return if leave_type.unlimited? || !leave_type.comp_off || errors.any?
369
+ return if leave_type.unlimited? || errors.any?
370
370
 
371
- if LeaveDayCounter.count(self) > balance.available(as_of: start_date)
372
- errors.add(:base, "Not enough #{leave_type.name} balance")
373
- end
371
+ held = balance.penalty_days
372
+ return unless leave_type.comp_off || held.positive?
373
+
374
+ left = balance.applicable(as_of: start_date)
375
+ return if LeaveDayCounter.count(self) <= left
376
+
377
+ return errors.add(:base, "Not enough #{leave_type.name} balance") unless held.positive?
378
+
379
+ days = ->(n) { ActiveSupport::NumberHelper.number_to_rounded(n, precision: 1, strip_insignificant_zeros: true) }
380
+ errors.add(:base, "For these dates you can apply for at most #{days.(left)} days of #{leave_type.name} — #{days.(held)} #{held == 1 ? 'day' : 'days'} held for penalties")
374
381
  end
375
382
  end
376
383
  end
@@ -0,0 +1,13 @@
1
+ module HrLite
2
+ # HR took hours off a person's penalty total, with a reason; the rest stays.
3
+ # Append-only, so every settlement stays on the person's record.
4
+ class PenaltyRemoval < ApplicationRecord
5
+ belongs_to :user, class_name: HrLite.config.user_class
6
+ belongs_to :removed_by, class_name: HrLite.config.user_class
7
+
8
+ validates :minutes, numericality: { only_integer: true, greater_than: 0 }
9
+ validates :reason, presence: true
10
+
11
+ def readonly? = persisted?
12
+ end
13
+ end
@@ -5,9 +5,17 @@ module HrLite
5
5
 
6
6
  WEEKEND_POLICIES = %w[sun_only sat_sun second_fourth_sat_sun].freeze
7
7
 
8
+ # Attendance penalties come off this leave type's balance (none: they stay as time).
9
+ belongs_to :penalty_leave_type, class_name: "HrLite::LeaveType", optional: true
10
+
8
11
  validates :weekend_policy, inclusion: { in: WEEKEND_POLICIES }
9
12
  validates :lunch_break_minutes, numericality: { only_integer: true, in: 5..120 }
10
13
  validates :short_break_minutes, numericality: { only_integer: true, in: 1..60 }
14
+ # The working day is check-in to check-out, break included (8h30 = 8h + 30m).
15
+ validates :day_minutes, numericality: { only_integer: true, in: 240..720 }
16
+ validates :half_day_below_minutes, numericality: { only_integer: true, greater_than: 0, less_than: :day_minutes }
17
+ validates :recheck_in_from, :recheck_in_until, format: { with: /\A([01]\d|2[0-3]):[0-5]\d\z/, message: "must be HH:MM" }
18
+ validate { errors.add(:recheck_in_until, "must be after the start") if recheck_in_from.to_s >= recheck_in_until.to_s }
11
19
  validates :employee_code_prefix, presence: true,
12
20
  format: { with: /\A[A-Za-z]{1,10}-?\z/, message: "letters only (max 10), optionally ending in -" }
13
21
 
@@ -12,7 +12,9 @@ module HrLite
12
12
 
13
13
  validates :start_date, :end_date, :reason, presence: true
14
14
  validates :status, inclusion: { in: STATUSES }
15
- validate :dates_in_window, :no_overlap, on: :create
15
+ validate :dates_in_window, on: :create
16
+ validate :no_overlap
17
+ validate :changed_dates, on: :update, if: -> { start_date_changed? || end_date_changed? }
16
18
 
17
19
  scope :pending, -> { where(status: "pending") }
18
20
  scope :approved, -> { where(status: "approved") }
@@ -59,6 +61,16 @@ module HrLite
59
61
  "/admin/wfh_requests/#{id}", HrLite.admin_users)
60
62
  end
61
63
 
64
+ # HR changes a decided request after the fact: dates, decision or both, with a reason.
65
+ def change!(actor:, start_date:, end_date:, status:, note:)
66
+ was = "#{dates_label}, #{self.status}"
67
+ update!(start_date: start_date, end_date: end_date, status: status, decided_by_id: actor.id,
68
+ decided_at: Time.current, decision_note: note)
69
+ AuditLog.record!(action: "wfh.changed", subject: self, actor: actor,
70
+ changes: { "was" => was, "now" => "#{dates_label}, #{status}", "note" => note })
71
+ announce("wfh.#{status}", "HR changed your WFH — #{dates_label}, #{status}", note, "/wfh_requests", [ user ])
72
+ end
73
+
62
74
  def notify_requested
63
75
  AuditLog.record!(action: "wfh.requested", subject: self, actor: user,
64
76
  changes: { "dates" => dates_label, "reason" => reason })
@@ -100,10 +112,18 @@ module HrLite
100
112
  end
101
113
 
102
114
  def no_overlap
103
- return unless start_date && end_date && user_id
115
+ return unless start_date && end_date && user_id && %w[pending approved].include?(status)
104
116
 
105
- clash = self.class.live.where(user_id: user_id).overlapping(start_date, end_date).exists?
117
+ clash = self.class.live.where(user_id: user_id).where.not(id: id).overlapping(start_date, end_date).exists?
106
118
  errors.add(:base, "You already have a pending or approved WFH request on some of these dates") if clash
107
119
  end
120
+
121
+ # HR may move a request into the past to match the days actually worked.
122
+ def changed_dates
123
+ return unless start_date && end_date
124
+
125
+ errors.add(:end_date, "must be on or after the start date") if end_date < start_date
126
+ errors.add(:end_date, "HR can set at most #{HR_MAX_DAYS} days at a time") if days > HR_MAX_DAYS
127
+ end
108
128
  end
109
129
  end
@@ -61,13 +61,24 @@ module HrLite
61
61
  record.with_lock do
62
62
  case @kind
63
63
  when :check_in
64
- return Result.new(record: record, error: already_in_message(record)) if record.check_in_at.present?
65
-
66
- stamp(record, :check_in)
67
- record.work_mode = @mode
68
- record.work_location_id = @location&.id
69
- record.wfh_request_id = @wfh&.id if @mode == "wfh"
70
- record.add_flag!("WFH request pending HR approval: #{@wfh.reason}") if @wfh&.pending?
64
+ if (reopen = record.check_out_at.present?)
65
+ # Checking in again reopens the day: the first check-in stands, the
66
+ # gap stays Offline on the work-mode timeline.
67
+ unless record.recheck_in_open?
68
+ s = Setting.instance
69
+ return Result.new(record: record, error: "Check in again is open once a day, #{s.recheck_in_from}–#{s.recheck_in_until}.")
70
+ end
71
+
72
+ record.clear_check_out(rechecked_in_at: Time.current)
73
+ else
74
+ return Result.new(record: record, error: already_in_message(record)) if record.check_in_at.present?
75
+
76
+ stamp(record, :check_in)
77
+ record.work_mode = @mode
78
+ record.work_location_id = @location&.id
79
+ record.wfh_request_id = @wfh&.id if @mode == "wfh"
80
+ record.add_flag!("WFH request pending HR approval: #{@wfh.reason}") if @wfh&.pending?
81
+ end
71
82
  when :check_out
72
83
  return Result.new(record: record, error: "Check in first.") if record.check_in_at.blank?
73
84
 
@@ -79,7 +90,10 @@ module HrLite
79
90
 
80
91
  apply_geo_flags(record)
81
92
  record.save!
82
- apply_slot_rules(record) if @kind == :check_in && HrLite.config.attendance_slots
93
+ AttendancePunch.create!(user_id: record.user_id, date: record.date, attendance_record_id: record.id,
94
+ kind: @kind == :check_in ? "in" : "out", at: reopen ? record.rechecked_in_at : record.public_send("#{@kind}_at"),
95
+ lat: @lat, lng: @lng, accuracy_m: @accuracy_m, place: punch_place, home_m: home_m, work_location_id: @location&.id)
96
+ apply_slot_rules(record) if @kind == :check_in && !reopen && HrLite.config.attendance_slots
83
97
  track_work_mode(record) if HrLite.config.office_geofence
84
98
  end
85
99
  notify_if_flagged(record)
@@ -193,7 +207,7 @@ module HrLite
193
207
  # opens Offline. The timeline keeps every earlier row.
194
208
  def track_work_mode(record)
195
209
  if @kind == :check_in
196
- WorkModeEvent.start!(user_id: @user.id, mode: record.work_mode || "office", source: "system",
210
+ WorkModeEvent.start!(user_id: @user.id, mode: @mode || record.work_mode || "office", source: "system",
197
211
  attendance_record: record, wfh_request_id: record.wfh_request_id,
198
212
  work_location_id: record.work_location_id, title: @field[:purpose], destination: @field[:place])
199
213
  else
@@ -220,10 +234,12 @@ module HrLite
220
234
  record.public_send("#{side}_lat=", @lat)
221
235
  record.public_send("#{side}_lng=", @lng)
222
236
  record.public_send("#{side}_accuracy_m=", @accuracy_m)
223
- record.public_send("#{side}_place=", @place || ("office" if @lat && @lng && in_office?))
237
+ record.public_send("#{side}_place=", punch_place)
224
238
  record.public_send("#{side}_home_m=", home_m)
225
239
  end
226
240
 
241
+ def punch_place = @place || ("office" if @lat && @lng && in_office?)
242
+
227
243
  def already_in_message(record)
228
244
  "Already checked in at #{record.check_in_at.in_time_zone(HrLite.config.time_zone).strftime('%H:%M')}."
229
245
  end
@@ -41,7 +41,10 @@
41
41
  <% @employees.each do |employee| %>
42
42
  <% record = @records[employee.id] %>
43
43
  <tr>
44
- <td data-label="Employee"><%= hr_display_name(employee) %></td>
44
+ <td data-label="Employee"><%= hr_display_name(employee) %>
45
+ <% if (held = @penalty_minutes[employee.id].to_i).positive? %>
46
+ <br><a class="hrl-card__link hrl-small" href="<%= hr_lite.admin_attendance_path(employee.id, anchor: "penalties") %>">Penalties <%= hrl_duration(held * 60) %> · <%= hrl_days(HrLite::AttendancePenalty.days_for(held)) %> days</a>
47
+ <% end %></td>
45
48
  <td data-label="In"><%= record&.check_in_at&.strftime("%H:%M") || "—" %></td>
46
49
  <td data-label="Out"><%= record&.check_out_at&.strftime("%H:%M") || "—" %></td>
47
50
  <td data-label="Location" class="hrl-small">
@@ -86,6 +86,15 @@
86
86
  <%= f.submit "Save fix", class: "hrl-btn hrl-btn--primary" %>
87
87
  <a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@employee.id, month: @month.strftime("%Y-%m")) %>">Cancel</a>
88
88
  </div>
89
+ <%# A mistaken punch today: put them back in, or clear the day so they check in again. %>
90
+ <% if @edit_date == Date.current && @edit_record.check_in_at %>
91
+ <div class="hrl-form-actions">
92
+ <% if @edit_record.check_out_at %>
93
+ <button type="submit" name="reset" value="check_out" class="hrl-btn" data-turbo-confirm="Reset today's check-out? They are checked in again and can check out later.">Reset check&#8209;out</button>
94
+ <% end %>
95
+ <button type="submit" name="reset" value="check_in" class="hrl-btn hrl-btn--danger" data-turbo-confirm="Reset today's check-in? The whole day is cleared and they check in again.">Reset check&#8209;in</button>
96
+ </div>
97
+ <% end %>
89
98
  <% end %>
90
99
  </section>
91
100
  <% end %>
@@ -90,6 +90,11 @@
90
90
  </div>
91
91
  <div class="hrl-field"><%= f.label :buffer_note, "Buffer note (HR only; required for Other)" %><%= f.text_area :buffer_note, rows: 2 %></div>
92
92
  <% end %>
93
+ <div class="hrl-field">
94
+ <%= f.label :hours_allowance_minutes, "Shorter day (e.g. a long commute; lowers the hours they must work)" %>
95
+ <%= f.select :hours_allowance_minutes, HrLite::EmployeeProfile::HOURS_ALLOWANCE_MINUTES.map { |m| [ "#{m} min less", m ] }, include_blank: "None" %>
96
+ </div>
97
+ <div class="hrl-field"><%= f.label :hours_allowance_reason, "Shorter day reason (HR only; required)" %><%= f.text_area :hours_allowance_reason, rows: 2 %></div>
93
98
  <% if HrLite.config.office_geofence %>
94
99
  <div class="hrl-field hrl-field--check"><%= f.check_box :permanent_wfh %><%= f.label :permanent_wfh, "Permanent work from home (every working day counts as approved WFH; check-in still within 500 m of their approved home location)" %></div>
95
100
  <div class="hrl-field hrl-field--check"><%= f.check_box :field_staff %><%= f.label :field_staff, "Field staff (e.g. sales: may check in from anywhere as Travelling or Client meeting, with purpose and place)" %></div>
@@ -15,7 +15,7 @@
15
15
  <td data-label="Employee"><%= hr_display_name(employee) %><% if @no_joining_date.include?(employee.id) %><br><span class="hrl-badge hrl-badge--bad">No joining date — no leave accrues</span><% end %></td>
16
16
  <% @types.each do |type| %>
17
17
  <% balance = HrLite::LeaveBalance.for(employee, type, @year) %>
18
- <td data-label="<%= type.code %>" class="hrl-num"><span><strong><%= hrl_days(balance.available) %></strong> left<br><span class="hrl-small hrl-muted"><%= hrl_days(balance.used) %>/<%= hrl_days(balance.entitled) %> used</span></span></td>
18
+ <td data-label="<%= type.code %>" class="hrl-num"><span><strong><%= hrl_days(balance.available) %></strong> left<br><span class="hrl-small hrl-muted"><%= hrl_days(balance.used) %>/<%= hrl_days(balance.entitled) %> used</span><% if (held = balance.penalty_days).positive? %><br><span class="hrl-small"><%= hrl_days(held) %> held for penalties</span><% end %></span></td>
19
19
  <% end %>
20
20
  </tr>
21
21
  <% end %>
@@ -20,6 +20,30 @@
20
20
  <%= f.label :short_break_minutes, "Short break limit (minutes)" %>
21
21
  <%= f.number_field :short_break_minutes, min: 1, max: 60 %>
22
22
  </div>
23
+ <div class="hrl-field">
24
+ <%= f.label :day_minutes, "Working day (minutes, break included)" %>
25
+ <%= f.number_field :day_minutes, min: 240, max: 720, step: 15 %>
26
+ <p class="hrl-hint">Check-in to check-out. 510 = 8h 30m (8h work + 30m break). Leaving earlier adds 30 min to the next working day.</p>
27
+ </div>
28
+ <div class="hrl-field">
29
+ <%= f.label :half_day_below_minutes, "Half day below (minutes worked)" %>
30
+ <%= f.number_field :half_day_below_minutes, min: 15, step: 15 %>
31
+ <p class="hrl-hint">Working fewer minutes than this marks the day a half day and tells the person. 255 = 4h 15m.</p>
32
+ </div>
33
+ <div class="hrl-field">
34
+ <%= f.label :recheck_in_from, "Check in again: from" %>
35
+ <%= f.time_field :recheck_in_from %>
36
+ </div>
37
+ <div class="hrl-field">
38
+ <%= f.label :recheck_in_until, "Check in again: until" %>
39
+ <%= f.time_field :recheck_in_until %>
40
+ <p class="hrl-hint">After checking out, a person can check in again once a day inside these hours.</p>
41
+ </div>
42
+ <div class="hrl-field">
43
+ <%= f.label :penalty_leave_type_id, "Penalties come off" %>
44
+ <%= f.collection_select :penalty_leave_type_id, HrLite::LeaveType.active, :id, :name, { include_blank: "No leave (penalties stay as time)" }, data: { hrl_filter: "Type a leave type to filter" } %>
45
+ <p class="hrl-hint">Every working day of penalty time this leave year holds one day of this leave, in half days, and leave past it is refused. Removing a penalty gives the day back.</p>
46
+ </div>
23
47
  <div class="hrl-field">
24
48
  <%= f.label :employee_code_prefix, "Employee code prefix" %>
25
49
  <%= f.text_field :employee_code_prefix %>
@@ -38,3 +38,20 @@
38
38
  <% end %>
39
39
  </section>
40
40
  <% end %>
41
+
42
+ <% if @decidable && (@request.approved? || @request.rejected?) %>
43
+ <section class="hrl-card">
44
+ <h2 class="hrl-card__title">Change this WFH</h2>
45
+ <%= form_with url: hr_lite.admin_wfh_request_path(@request), method: :patch, local: true do |f| %>
46
+ <div class="hrl-field"><%= f.label :start_date, "From" %><%= f.date_field :start_date, value: @request.start_date, required: true %></div>
47
+ <div class="hrl-field"><%= f.label :end_date, "To" %><%= f.date_field :end_date, value: @request.end_date, required: true %></div>
48
+ <fieldset class="hrl-field">
49
+ <legend class="hrl-small">Decision</legend>
50
+ <label class="hrl-field--check"><%= f.radio_button :status, "approved", checked: @request.approved? %> Approved</label>
51
+ <label class="hrl-field--check"><%= f.radio_button :status, "rejected", checked: @request.rejected? %> Rejected</label>
52
+ </fieldset>
53
+ <div class="hrl-field"><%= f.label :decision_note, "Reason for the change (required)", for: "change_note" %><%= f.text_area :decision_note, id: "change_note", required: true %></div>
54
+ <div class="hrl-form-actions"><%= f.button "Save change", type: :submit, class: "hrl-btn hrl-btn--primary" %></div>
55
+ <% end %>
56
+ </section>
57
+ <% end %>
@@ -1,3 +1,21 @@
1
+ <%# The running total: penalties add up until HR removes hours; every 5h holds half a day of leave. %>
2
+ <% held = HrLite::AttendancePenalty.held_minutes(user.id)[user.id].to_i
3
+ leave_type = HrLite::Setting.instance.penalty_leave_type %>
4
+ <section class="hrl-card" id="penalties">
5
+ <h2 class="hrl-card__title">Penalty hours</h2>
6
+ <p><strong><%= hrl_duration(held * 60) %></strong> held · <%= hrl_days(HrLite::AttendancePenalty.days_for(held)) %> days<%= " of #{leave_type.name}" if leave_type %></p>
7
+ <p class="hrl-hint">Penalties add up until HR removes them. Every 5 hours holds half a day of leave, which can't be applied for.</p>
8
+ <% HrLite::PenaltyRemoval.where(user_id: user.id).includes(:removed_by).order(created_at: :desc).each do |removal| %>
9
+ <div class="hrl-feed__item hrl-small"><div class="hrl-feed__body">Removed <%= hrl_duration(removal.minutes * 60) %> on <%= removal.created_at.strftime("%-d %b %Y") %> by <%= hr_display_name(removal.removed_by) %>: <%= removal.reason %></div></div>
10
+ <% end %>
11
+ <% if local_assigns[:removable] && held.positive? %>
12
+ <%= form_with url: hr_lite.remove_penalty_hours_admin_attendance_path(user.id), method: :post, local: true, class: "hrl-mt" do %>
13
+ <div class="hrl-field"><label for="hrl-remove-hours">Hours to remove</label><input type="number" class="hrl-input" id="hrl-remove-hours" name="hours" min="0.5" step="0.5" max="<%= held / 60.0 %>" required></div>
14
+ <div class="hrl-field"><label for="hrl-remove-hours-reason">Reason (required)</label><input type="text" class="hrl-input" id="hrl-remove-hours-reason" name="reason" required></div>
15
+ <div class="hrl-form-actions"><button type="submit" class="hrl-btn">Remove hours</button></div>
16
+ <% end %>
17
+ <% end %>
18
+ </section>
1
19
  <% penalties = HrLite::AttendancePenalty.where(user_id: user.id, date: month.all_month).order(:date, :id) %>
2
20
  <section class="hrl-card">
3
21
  <h2 class="hrl-card__title">Penalties in <%= month.strftime("%B") %></h2>
@@ -1,4 +1,6 @@
1
1
  <% record = HrLite::AttendanceRecord.find_by(user_id: hr_current_user.id, date: Date.current) %>
2
+ <%# Checking out by mistake was common; the OK is the deliberate step. Checking in again reopens the day. %>
3
+ <% checkout_confirm = "Check out now? Your check-out is saved as soon as you press OK." %>
2
4
  <%# A shift that runs past midnight leaves yesterday's record open.
3
5
  AttendancePuncher already closes it, but this card looked only at today,
4
6
  so after midnight it offered "Check in" and the night's punch never
@@ -21,6 +23,9 @@
21
23
  <% if record&.work_mode_label %>
22
24
  <span class="hrl-badge hrl-badge--ok"><%= record.work_mode_label %></span>
23
25
  <% end %>
26
+ <% if HrLite::EmployeeProfile.where(user_id: hr_current_user.id).pick(:hours_allowance_minutes) %>
27
+ <span class="hrl-badge hrl-badge--muted">Your day <%= hrl_duration(HrLite::EmployeeProfile.day_minutes_for(hr_current_user.id) * 60) %></span>
28
+ <% end %>
24
29
  <% if record&.check_in_place == "home" && record.work_location %>
25
30
  <span class="hrl-badge hrl-badge--muted"><%= record.work_location.name %> · <%= HrLite::WorkLocation::FENCE_M %> m</span>
26
31
  <% end %>
@@ -34,6 +39,10 @@
34
39
  <span class="hrl-muted">Not checked in yet.</span>
35
40
  <% end %>
36
41
  </div>
42
+ <%# Checked in again: every earlier in and out stays on show. %>
43
+ <% if record && (punches = record.punches).size > 2 %>
44
+ <p class="hrl-small">All punches: <%= punches.join(" · ") %></p>
45
+ <% end %>
37
46
  <% if HrLite.config.attendance_slots %>
38
47
  <%= render "hr_lite/attendance/slot", record: record %>
39
48
  <% end %>
@@ -46,7 +55,7 @@
46
55
 
47
56
  <% if open_yesterday %>
48
57
  <%= form_with url: hr_lite.check_out_attendance_path, method: :post, local: true,
49
- html: { data: { "hrl-geo-punch": true, "hrl-geo-required": HrLite.config.require_punch_location.presence } } do %>
58
+ html: { data: { "hrl-geo-punch": true, "hrl-geo-required": HrLite.config.require_punch_location.presence, "turbo-confirm": checkout_confirm } } do %>
50
59
  <input type="hidden" name="lat"><input type="hidden" name="lng">
51
60
  <input type="hidden" name="accuracy_m"><input type="hidden" name="geo_status">
52
61
  <button type="submit" class="hrl-btn hrl-btn--big hrl-btn--block">
@@ -54,7 +63,7 @@
54
63
  </button>
55
64
  <p class="hrl-small hrl-muted" data-hrl-geo-status role="status" aria-live="polite"></p>
56
65
  <% end %>
57
- <% elsif record&.check_in_at.blank? %>
66
+ <% elsif record&.check_in_at.blank? || record.recheck_in_open? %>
58
67
  <%= form_with url: hr_lite.check_in_attendance_path, method: :post, local: true,
59
68
  html: { data: { "hrl-geo-punch": true, "hrl-geo-required": HrLite.config.require_punch_location.presence } } do %>
60
69
  <input type="hidden" name="lat"><input type="hidden" name="lng">
@@ -72,32 +81,35 @@
72
81
  </details>
73
82
  <% end %>
74
83
  <% if flash[:punch_offer] == "home" %>
75
- <fieldset class="hrl-field">
76
- <legend class="hrl-small">Is this your home location?</legend>
84
+ <%# A choice the punch needs: red, and geo_punch.js scrolls it into view on a phone. %>
85
+ <div class="hrl-errors" data-hrl-must-choose><fieldset class="hrl-field">
86
+ <legend class="hrl-small">Is this your home location? Choose one to check in.</legend>
77
87
  <% if HrLite::WorkLocation.live.where(user_id: hr_current_user.id).count < HrLite::WorkLocation::MAX_LIVE %>
78
88
  <label class="hrl-field--check"><input type="radio" name="place" value="home" required> Yes, save this place as a home location</label>
79
89
  <% else %>
80
90
  <p class="hrl-small"><%= HrLite::WorkLocationsController::CAP_MESSAGE %></p>
81
91
  <% end %>
82
92
  <label class="hrl-field--check"><input type="radio" name="place" value="travel" required> No, I am travelling</label>
83
- </fieldset>
93
+ </fieldset></div>
84
94
  <% end %>
85
- <button type="submit" class="hrl-btn hrl-btn--primary hrl-btn--big hrl-btn--block">Check in</button>
95
+ <button type="submit" class="hrl-btn hrl-btn--primary hrl-btn--big hrl-btn--block"><%= record&.check_out_at ? "Check in again" : "Check in" %></button>
86
96
  <p class="hrl-small hrl-muted" data-hrl-geo-status role="status" aria-live="polite"></p>
87
97
  <% end %>
88
- <% else %>
98
+ <% elsif record.check_out_at.nil? %>
89
99
  <%= form_with url: hr_lite.check_out_attendance_path, method: :post, local: true,
90
- html: { data: { "hrl-geo-punch": true, "hrl-geo-required": HrLite.config.require_punch_location.presence } } do %>
100
+ html: { data: { "hrl-geo-punch": true, "hrl-geo-required": HrLite.config.require_punch_location.presence, "turbo-confirm": checkout_confirm } } do %>
91
101
  <input type="hidden" name="lat"><input type="hidden" name="lng">
92
102
  <input type="hidden" name="accuracy_m"><input type="hidden" name="geo_status">
93
103
  <% if flash[:punch_offer] == "travel" %>
94
- <div class="hrl-field"><label class="hrl-field--check"><input type="checkbox" name="place" value="travel" required> I am travelling — check out here</label></div>
104
+ <div class="hrl-field hrl-errors" data-hrl-must-choose><label class="hrl-field--check"><input type="checkbox" name="place" value="travel" required> Tick to confirm: I am travelling — check out here</label></div>
95
105
  <% end %>
96
106
  <button type="submit" class="hrl-btn hrl-btn--big hrl-btn--block">
97
- <%= record.check_out_at ? "Update check-out" : "Check out" %>
107
+ Check out
98
108
  </button>
99
109
  <p class="hrl-small hrl-muted" data-hrl-geo-status role="status" aria-live="polite"></p>
100
110
  <% end %>
111
+ <% else %>
112
+ <p class="hrl-small hrl-muted">Check in again is open once a day, <%= HrLite::Setting.instance.recheck_in_from %>–<%= HrLite::Setting.instance.recheck_in_until %>.</p>
101
113
  <% end %>
102
114
 
103
115
  <% if local_assigns[:attention].present? %><p class="hrl-small hrl-muted hrl-mt">Needs a fix</p><% end %>
@@ -21,7 +21,7 @@
21
21
  <%= form_with url: hr_lite.slot_attendance_path, method: :post, local: true, class: "hrl-mt" do %>
22
22
  <div class="hrl-field">
23
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" %>
24
+ <%= select_tag :start_minute, options_for_select(HrLite::AttendanceSlot::START_MINUTES.map { |m| [ "#{HrLite::AttendanceSlot.label(m)} – out #{HrLite::AttendanceSlot.label(m + HrLite::EmployeeProfile.day_minutes_for(hr_current_user.id))}", m ] }, start), id: "hrl-slot-start" %>
25
25
  </div>
26
26
  <div class="hrl-field">
27
27
  <label for="hrl-slot-period">For</label>
@@ -8,6 +8,9 @@
8
8
  <% if home_m %> · <%= number_with_delimiter(home_m) %> m from home<% end %>
9
9
  <% if accuracy %> · ±<%= accuracy %> m<% end %></div>
10
10
  <% end %>
11
+ <% if (punches = record.punches).size > 2 %>
12
+ <div class="hrl-punch-line">All punches: <%= punches.join(" · ") %></div>
13
+ <% end %>
11
14
  <% if (route = record.route_url) %>
12
15
  <div class="hrl-punch-line"><a class="hrl-card__link" href="<%= route %>" target="_blank" rel="noopener noreferrer">View route on Google Maps<span class="hrl-sr-only"> for <%= record.date.strftime("%-d %b") %></span></a></div>
13
16
  <% end %>
@@ -40,6 +40,7 @@
40
40
  <dl class="hrl-deflist">
41
41
  <dt>In</dt><dd><%= rec&.check_in_at&.strftime("%H:%M") || "—" %></dd>
42
42
  <dt>Out</dt><dd><%= rec&.check_out_at&.strftime("%H:%M") || "—" %></dd>
43
+ <% if rec && (punches = rec.punches).size > 2 %><dt>All punches</dt><dd><%= punches.join(" · ") %></dd><% end %>
43
44
  <dt>Worked</dt><dd><%= hrl_duration(rec&.worked_duration) %></dd>
44
45
  <% if part %><dt>Half</dt><dd><%= HrLite::DAY_PARTS.rassoc(part)&.first %></dd><% end %>
45
46
  <% if leave %><dt>Leave</dt><dd><%= leave.leave_type.name %></dd><% end %>
@@ -16,6 +16,7 @@
16
16
  <dt><%= balance.leave_type.comp_off ? "Credited" : "Entitled" %></dt><dd><%= hrl_days(balance.entitled) %></dd>
17
17
  <dt>Used</dt><dd><%= hrl_days(balance.used) %></dd>
18
18
  <dt>Available</dt><dd><strong><%= hrl_days(balance.available) %></strong></dd>
19
+ <% if (held = balance.penalty_days).positive? %><dt>Held for penalties</dt><dd><%= hrl_days(held) %></dd><dt>You can apply</dt><dd><%= hrl_days(balance.applicable) %></dd><% end %>
19
20
  <% if balance.carried_forward.positive? %><dt>Carried forward</dt><dd><%= hrl_days(balance.carried_forward) %></dd><% end %>
20
21
  <% if balance.adjustment.nonzero? %><dt>Adjustments</dt><dd><%= hrl_days(balance.adjustment) %> <span class="hrl-muted hrl-small"><%= balance.adjustment_note %></span></dd><% end %>
21
22
  </dl>
@@ -8,7 +8,7 @@
8
8
  <% types.each do |type| %>
9
9
  <% balance = HrLite::LeaveBalance.for(user, type, HrLite::LeaveYear.current_key) %>
10
10
  <span class="hrl-badge hrl-badge--type" style="--hrl-type: <%= type.color %>">
11
- <%= type.name %> · <%= hrl_days(balance.available) %> of <%= hrl_days(balance.entitled) %> left
11
+ <%= type.name %> · <%= hrl_days(balance.available) %> of <%= hrl_days(balance.entitled) %> left<% if (held = balance.penalty_days).positive? %> · <%= hrl_days(held) %> held for penalties<% end %>
12
12
  </span>
13
13
  <% end %>
14
14
  </div>
@@ -12,10 +12,10 @@
12
12
  <% left = @balances.index_by(&:leave_type_id) %>
13
13
  <div class="hrl-chiprow" role="radiogroup" aria-labelledby="hrl-leave-type">
14
14
  <% HrLite::LeaveType.active.each_with_index do |type, i| %>
15
- <label><%= f.radio_button :leave_type_id, type.id, required: i.zero? %><span><%= type.name.sub(/ leave\z/i, "") %><%= " · #{hrl_days(left[type.id].available)} left" if left[type.id] %><%= " · Unpaid" unless type.paid %></span></label>
15
+ <label><%= f.radio_button :leave_type_id, type.id, required: i.zero? %><span><%= type.name.sub(/ leave\z/i, "") %><%= " · #{hrl_days(left[type.id].available)} left" if left[type.id] %><% if left[type.id] && (held = left[type.id].penalty_days).positive? %> · <%= hrl_days(held) %> held for penalties<% end %><%= " · Unpaid" unless type.paid %></span></label>
16
16
  <% end %>
17
17
  </div>
18
- <p class="hrl-hint">Days beyond your balance are unpaid (loss of pay), cut from that month's salary.</p>
18
+ <p class="hrl-hint">Days beyond your balance are unpaid (loss of pay), cut from that month's salary.<% if (capped = @balances.find { |b| b.penalty_days.positive? }) %> Today you can apply for at most <%= hrl_days(capped.applicable) %> days of <%= capped.leave_type.name %>, <%= hrl_days(capped.penalty_days) %> held for penalties.<% end %></p>
19
19
  </div>
20
20
  <div class="hrl-field">
21
21
  <%= f.label :start_date, "From" %>
@@ -30,11 +30,11 @@
30
30
  <details class="hrl-small">
31
31
  <summary>Show as a table</summary>
32
32
  <table class="hrl-table">
33
- <thead><tr><th><%= period.bucket.to_s.capitalize %></th><th class="hrl-num">Working</th><th class="hrl-num">Breaks</th><th class="hrl-num">Total</th></tr></thead>
33
+ <thead><tr><th><%= period.bucket.to_s.capitalize %></th><th class="hrl-num">Working (breaks incl.)</th><th class="hrl-num">Of which breaks</th><th class="hrl-num">Total</th></tr></thead>
34
34
  <tbody>
35
35
  <% buckets.each do |key, modes| %>
36
36
  <tr><td><%= key.strftime(period.bucket == :day ? "%a %-d %b" : fmt) %></td>
37
- <td class="hrl-num"><%= hrl_duration(HrLite::WorkModeDurations::WORKING.sum { |m| modes[m] }) %></td>
37
+ <td class="hrl-num"><%= hrl_duration((HrLite::WorkModeDurations::WORKING + HrLite::WorkModeDurations::BREAKS).sum { |m| modes[m] }) %></td>
38
38
  <td class="hrl-num"><%= hrl_duration(HrLite::WorkModeDurations::BREAKS.sum { |m| modes[m] }) %></td>
39
39
  <td class="hrl-num"><%= hrl_duration(totals[key]) %></td></tr>
40
40
  <% end %>
@@ -34,12 +34,12 @@
34
34
  <% else %>
35
35
  <% office = result.by_mode["office"]; wfh = result.by_mode["wfh"]; field = result.by_mode["travelling"] + result.by_mode["outside_meeting"] %>
36
36
  <div class="hrl-kpis">
37
- <% [ [ "Attendance (check-in to check-out)", result.attendance ], [ "Working time", result.working ], [ "Breaks", result.breaks ],
37
+ <% [ [ "Attendance (check-in to check-out)", result.attendance ], [ "Working time (breaks included)", result.working + result.breaks ], [ "Of which breaks", result.breaks ],
38
38
  [ "In office", office ], [ "Work from home", wfh ], [ "Travel and client meetings", field ], [ "Out of office", result.other ] ].each do |kpi_label, secs| %>
39
39
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_duration(secs) %></div><div class="hrl-kpi__label"><%= kpi_label %></div></div>
40
40
  <% end %>
41
41
  </div>
42
- <p class="hrl-small hrl-muted">Working time = in office, work from home, travelling, client and other meetings, and busy. It is time in those modes, not a measure of output.
42
+ <p class="hrl-small hrl-muted">Working time = in office, work from home, travelling, client and other meetings, busy, and the breaks taken during the day — an 8h 30m shift includes its break. It is time in those modes, not a measure of output.
43
43
  Recorded on <%= pluralize(result.days_with_data.size, "day") %>.</p>
44
44
 
45
45
  <%= render "hr_lite/work_time/donut", result: result, label: label %>
data/config/routes.rb CHANGED
@@ -73,7 +73,7 @@ HrLite::Engine.routes.draw do
73
73
  get "overview", to: "overview#index"
74
74
  resources :attendances, only: %i[index show update], param: :user_id do
75
75
  collection { get :week }
76
- member { post :slot; post :unlock_slot; post :remove_penalty }
76
+ member { post :slot; post :unlock_slot; post :remove_penalty; post :remove_penalty_hours }
77
77
  end
78
78
  resources :leave_requests, only: %i[index show new create edit update] do
79
79
  member { post :approve; post :reject; post :cancel }
@@ -87,7 +87,7 @@ HrLite::Engine.routes.draw do
87
87
  resources :regularization_requests, only: %i[index show] do
88
88
  member { post :approve; post :reject }
89
89
  end
90
- resources :wfh_requests, only: %i[index show create] do
90
+ resources :wfh_requests, only: %i[index show create update] do
91
91
  member { post :approve; post :reject }
92
92
  end
93
93
  resources :work_locations, only: :index do
@@ -0,0 +1,8 @@
1
+ class AddWorkingDayToHrLiteSettings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hr_lite_settings, :day_minutes, :integer, null: false, default: 510
4
+ add_column :hr_lite_settings, :half_day_below_minutes, :integer, null: false, default: 255
5
+ add_column :hr_lite_settings, :recheck_in_from, :string, null: false, default: "09:00"
6
+ add_column :hr_lite_settings, :recheck_in_until, :string, null: false, default: "20:00"
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ class AddRecheckedInAtToHrLiteAttendanceRecords < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hr_lite_attendance_records, :rechecked_in_at, :datetime
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class AddHoursAllowanceToHrLiteEmployeeProfiles < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hr_lite_employee_profiles, :hours_allowance_minutes, :integer
4
+ add_column :hr_lite_employee_profiles, :hours_allowance_reason, :text
5
+ end
6
+ end
@@ -0,0 +1,20 @@
1
+ class CreateHrLiteAttendancePunches < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :hr_lite_attendance_punches do |t|
4
+ t.bigint :user_id, null: false
5
+ t.date :date, null: false
6
+ t.bigint :attendance_record_id
7
+ t.string :kind, null: false
8
+ t.datetime :at, null: false
9
+ t.decimal :lat, precision: 9, scale: 6
10
+ t.decimal :lng, precision: 9, scale: 6
11
+ t.integer :accuracy_m
12
+ t.string :place
13
+ t.integer :home_m
14
+ t.bigint :work_location_id
15
+
16
+ t.timestamps
17
+ end
18
+ add_index :hr_lite_attendance_punches, [ :user_id, :date ]
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ class AddPenaltyLeaveTypeToHrLiteSettings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hr_lite_settings, :penalty_leave_type_id, :bigint
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+ class CreateHrLitePenaltyRemovals < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :hr_lite_penalty_removals do |t|
4
+ t.bigint :user_id, null: false
5
+ t.integer :minutes, null: false
6
+ t.text :reason, null: false
7
+ t.bigint :removed_by_id, null: false
8
+
9
+ t.timestamps
10
+ end
11
+ add_index :hr_lite_penalty_removals, :user_id
12
+ end
13
+ end
@@ -65,9 +65,9 @@ module HrLite
65
65
  @self_regularization = value
66
66
  end
67
67
 
68
- # nil = no hours rule. { day: 8, probation_day: 9, week: 40 } = a closed
69
- # Mon–Fri punch short of its day's hours is closed as a half day, and a
70
- # week under `week` hours (pro rata for holidays and leave) is flagged.
68
+ # nil = no hours rule. Set = the day rules run on the working day and the
69
+ # half-day line HR sets in Settings (`day` and `probation_day` here are no
70
+ # longer read), and a week under `week` hours (pro rata) is flagged.
71
71
  def work_hours=(value)
72
72
  ok = value.nil? || (value.is_a?(Hash) && %i[day probation_day week].all? { |k| value[k].is_a?(Numeric) && value[k].positive? })
73
73
  raise ArgumentError, "work_hours must be nil or { day:, probation_day:, week: } (positive numbers), got #{value.inspect}" unless ok
@@ -21,6 +21,7 @@ module HrLite
21
21
  "attendance.missed_check_in" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
22
22
  "attendance.missed_check_out" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
23
23
  "attendance.short_day" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
24
+ "attendance.penalty_removed" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
24
25
  "payroll.finalized" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
25
26
  "ca.flagged" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
26
27
  "ca.reminder" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.40.2"
2
+ VERSION = "0.48.2"
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.40.2
4
+ version: 0.48.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -184,6 +184,7 @@ files:
184
184
  - app/models/hr_lite/asset.rb
185
185
  - app/models/hr_lite/asset_assignment.rb
186
186
  - app/models/hr_lite/attendance_penalty.rb
187
+ - app/models/hr_lite/attendance_punch.rb
187
188
  - app/models/hr_lite/attendance_record.rb
188
189
  - app/models/hr_lite/attendance_slot.rb
189
190
  - app/models/hr_lite/audit_log.rb
@@ -211,6 +212,7 @@ files:
211
212
  - app/models/hr_lite/office_location.rb
212
213
  - app/models/hr_lite/payroll_line_item.rb
213
214
  - app/models/hr_lite/payroll_run.rb
215
+ - app/models/hr_lite/penalty_removal.rb
214
216
  - app/models/hr_lite/performance_plan.rb
215
217
  - app/models/hr_lite/policy.rb
216
218
  - app/models/hr_lite/policy_acknowledgement.rb
@@ -486,6 +488,12 @@ files:
486
488
  - db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb
487
489
  - db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb
488
490
  - db/migrate/20261014031000_add_punch_places_to_hr_lite_attendance_records.rb
491
+ - db/migrate/20261015090000_add_working_day_to_hr_lite_settings.rb
492
+ - db/migrate/20261015091000_add_rechecked_in_at_to_hr_lite_attendance_records.rb
493
+ - db/migrate/20261015092000_add_hours_allowance_to_hr_lite_employee_profiles.rb
494
+ - db/migrate/20261015100000_create_hr_lite_attendance_punches.rb
495
+ - db/migrate/20261016090000_add_penalty_leave_type_to_hr_lite_settings.rb
496
+ - db/migrate/20261016091000_create_hr_lite_penalty_removals.rb
489
497
  - lib/generators/hr_lite/install/install_generator.rb
490
498
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
491
499
  - lib/generators/hr_lite/install/templates/initializer.rb