hr_lite 0.43.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/app/assets/javascripts/hr_lite/confirm.js +2 -1
- data/app/assets/javascripts/hr_lite/geo_punch.js +16 -5
- data/app/assets/javascripts/hr_lite/search.js +48 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +6 -1
- data/app/controllers/hr_lite/admin/attendances_controller.rb +70 -2
- data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +14 -1
- data/app/controllers/hr_lite/search_controller.rb +42 -0
- data/app/helpers/hr_lite/application_helper.rb +2 -1
- data/app/models/hr_lite/attendance_penalty.rb +10 -0
- data/app/models/hr_lite/attendance_punch.rb +14 -0
- data/app/models/hr_lite/attendance_record.rb +9 -0
- data/app/models/hr_lite/leave_balance.rb +11 -0
- data/app/models/hr_lite/leave_request.rb +13 -6
- data/app/models/hr_lite/penalty_removal.rb +13 -0
- data/app/models/hr_lite/setting.rb +3 -0
- data/app/models/hr_lite/wfh_request.rb +23 -3
- data/app/services/hr_lite/attendance_puncher.rb +7 -3
- data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
- data/app/views/hr_lite/admin/attendances/show.html.erb +9 -0
- data/app/views/hr_lite/admin/attendances/summary.html.erb +73 -0
- data/app/views/hr_lite/admin/leave_balances/index.html.erb +1 -1
- data/app/views/hr_lite/admin/settings/edit.html.erb +5 -0
- data/app/views/hr_lite/admin/wfh_requests/show.html.erb +17 -0
- data/app/views/hr_lite/attendance/_penalties.html.erb +18 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +9 -4
- data/app/views/hr_lite/attendance/_trail.html.erb +3 -0
- data/app/views/hr_lite/attendance/show.html.erb +1 -0
- data/app/views/hr_lite/leave_balances/index.html.erb +1 -0
- data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +1 -1
- data/app/views/hr_lite/leave_requests/new.html.erb +2 -2
- data/app/views/hr_lite/search/_results.html.erb +14 -0
- data/app/views/hr_lite/search/show.html.erb +9 -0
- data/app/views/hr_lite/shared/_person_tabs.html.erb +1 -0
- data/app/views/hr_lite/work_time/_bars.html.erb +2 -2
- data/app/views/hr_lite/work_time/_report.html.erb +2 -2
- data/app/views/layouts/hr_lite/application.html.erb +11 -1
- data/config/routes.rb +3 -2
- data/db/migrate/20261015100000_create_hr_lite_attendance_punches.rb +20 -0
- data/db/migrate/20261016090000_add_penalty_leave_type_to_hr_lite_settings.rb +5 -0
- data/db/migrate/20261016091000_create_hr_lite_penalty_removals.rb +13 -0
- data/lib/hr_lite/notifications.rb +1 -0
- data/lib/hr_lite/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01cbb25a2be4121524133e4ae3cc27c20e703edbe2ad51c41830f89a6223b5d6
|
|
4
|
+
data.tar.gz: ba0ed1ceeda7d1c25eec0a9607e773b260cd255eadee1f68b4d35667ab0de9d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a50669d8acec885fcaa6304e071785a34432c1bd8314edf57faac5f5436651b186e17c226e3413f50bb872f38fc52859dd61c0f248bd2ab09f03215c9a9d962f
|
|
7
|
+
data.tar.gz: 92b42d0e6301c22de05a6eed335f84065ad81eb5fd8ae3d3887dae0f38c66b485bcec3f9b13b7ea957ebf3dda4b16a38d891d69e351356bd91b50bb69d9a1877
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.50.0] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
- Global search: one box for screens and people. It opens with ⌘K or Ctrl+K anywhere, from **Search** at the top of the sidebar, or from **Search** in the phone's More sheet. Typing (2 letters or more) lists matching screens and people; arrows move, Enter opens, and Escape closes. With JavaScript off, Search opens the `/search` page.
|
|
13
|
+
- Screens come from the menu's own gates, so a person only finds what they can already open. A screen matches on its place, its tab and its paths ("wfh" finds My attendance through `/wfh_requests`), plus a few everyday words such as "payslip" or "penalty".
|
|
14
|
+
- People are listed only for those with a team screen, and only people whose attendance they may open: a manager finds their team, HR finds everyone, and an employee finds no one. Search covers name, email and employee code. A person opens on their This week screen. A page outside someone's reach still answers "not allowed", exactly as before.
|
|
15
|
+
- The endpoint has a named rate limit (60 a minute per person).
|
|
16
|
+
|
|
17
|
+
## [0.49.0] - 2026-10-16
|
|
18
|
+
|
|
19
|
+
- "This week": one screen per employee for HR, admin and managers. It's the first tab in the person's tabs, at `/admin/attendances/:user_id/summary`. Day, Week (the default) or Month, with ‹ › and a "Go to date" calendar. It shows:
|
|
20
|
+
- every day's status, check-in and check-out (all punches when there was more than one pair), hours worked, penalty minutes and flags
|
|
21
|
+
- every request in the period: leave, comp-off, WFH, attendance fixes, expense claims and uploaded documents, each with its status and an "Open" link to where it's decided
|
|
22
|
+
- leave balances, with any days held for penalties
|
|
23
|
+
- the penalty-hours section, with Remove hours for those who may manage the person
|
|
24
|
+
- Each past or current day has a "Fix" button that goes straight to that day's Fix card, including reset and penalties.
|
|
25
|
+
- Access is unchanged. The screen needs attendance.view for the person; a manager sees only their team. Each request type shows only if the viewer may open that type's own page for this person: leave and comp-off need leave.view, WFH and fixes need attendance.view, claims need expense.approve, documents need document.view. So HR, which can't approve claims, sees no claims here. Fix and Remove hours need attendance.manage, and never for your own record.
|
|
26
|
+
|
|
27
|
+
## [0.48.2] - 2026-10-16
|
|
28
|
+
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
31
|
+
## [0.48.1] - 2026-10-16
|
|
32
|
+
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
35
|
+
## [0.48.0] - 2026-10-16
|
|
36
|
+
|
|
37
|
+
- 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.
|
|
38
|
+
- 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.
|
|
39
|
+
|
|
40
|
+
## [0.47.0] - 2026-10-16
|
|
41
|
+
|
|
42
|
+
- 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.
|
|
43
|
+
- 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.
|
|
44
|
+
- 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.
|
|
45
|
+
- HR's team attendance view shows "Penalties 100h 00m · 10 days" on each person who has any, linking to their section.
|
|
46
|
+
- "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.
|
|
47
|
+
- Migrations: `hr_lite_settings.penalty_leave_type_id` and `hr_lite_penalty_removals`.
|
|
48
|
+
|
|
49
|
+
## [0.46.0] - 2026-10-15
|
|
50
|
+
|
|
51
|
+
- 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.
|
|
52
|
+
- 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.
|
|
53
|
+
- Migration: `hr_lite_attendance_punches`. Days punched before this release have no punch rows and show as before.
|
|
54
|
+
|
|
55
|
+
## [0.45.0] - 2026-10-15
|
|
56
|
+
|
|
57
|
+
- 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.
|
|
58
|
+
|
|
59
|
+
## [0.44.0] - 2026-10-15
|
|
60
|
+
|
|
61
|
+
- 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.
|
|
62
|
+
- 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.
|
|
63
|
+
- `data-turbo-confirm` can sit on a button as well as a form, so one form can hold several confirmed actions.
|
|
64
|
+
|
|
10
65
|
## [0.43.0] - 2026-10-15
|
|
11
66
|
|
|
12
67
|
- 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.
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
var form = event.target;
|
|
19
19
|
if (!form || form.nodeName !== "FORM") return;
|
|
20
20
|
|
|
21
|
-
|
|
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
|
-
|
|
25
|
+
var steps = [
|
|
24
26
|
"Open Settings → Privacy & Security → Location Services and turn it on.",
|
|
25
|
-
|
|
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.");
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The search palette: ⌘K / Ctrl+K or any [data-hrl-search-open] link opens it.
|
|
2
|
+
// Typing loads the short results from /search?frame=1 after a pause; Enter
|
|
3
|
+
// opens the full page; arrows move through results; Escape closes (native).
|
|
4
|
+
// Without JS (or <dialog>) the links simply open the search page.
|
|
5
|
+
(function () {
|
|
6
|
+
var dialog = document.querySelector("[data-hrl-search]");
|
|
7
|
+
if (!dialog || !dialog.showModal) return;
|
|
8
|
+
var form = dialog.querySelector("form");
|
|
9
|
+
var input = form.querySelector("input");
|
|
10
|
+
var out = dialog.querySelector("[data-hrl-search-results]");
|
|
11
|
+
var timer;
|
|
12
|
+
|
|
13
|
+
function open(event) {
|
|
14
|
+
if (event) event.preventDefault();
|
|
15
|
+
var more = document.querySelector(".hrl-tabbar__more[open]");
|
|
16
|
+
if (more) more.open = false; // opened from the phone's More sheet
|
|
17
|
+
if (!dialog.open) dialog.showModal();
|
|
18
|
+
input.focus();
|
|
19
|
+
input.select();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
document.addEventListener("keydown", function (event) {
|
|
23
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") open(event);
|
|
24
|
+
});
|
|
25
|
+
document.querySelectorAll("[data-hrl-search-open]").forEach(function (link) {
|
|
26
|
+
link.addEventListener("click", open);
|
|
27
|
+
});
|
|
28
|
+
dialog.addEventListener("click", function (event) { if (event.target === dialog) dialog.close(); });
|
|
29
|
+
|
|
30
|
+
input.addEventListener("input", function () {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
timer = setTimeout(function () {
|
|
33
|
+
fetch(form.action + "?frame=1&q=" + encodeURIComponent(input.value), { credentials: "same-origin", cache: "no-store" })
|
|
34
|
+
.then(function (response) { return response.ok ? response.text() : ""; })
|
|
35
|
+
.then(function (html) { out.innerHTML = html; });
|
|
36
|
+
}, 180);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
dialog.addEventListener("keydown", function (event) {
|
|
40
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
|
41
|
+
var links = Array.prototype.slice.call(out.querySelectorAll("a"));
|
|
42
|
+
if (!links.length) return;
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
var at = links.indexOf(document.activeElement);
|
|
45
|
+
at = event.key === "ArrowDown" ? Math.min(at + 1, links.length - 1) : at - 1;
|
|
46
|
+
(at < 0 ? input : links[at]).focus();
|
|
47
|
+
});
|
|
48
|
+
})();
|
|
@@ -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:
|
|
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; }
|
|
@@ -638,3 +638,8 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
|
|
|
638
638
|
.hrl-person { width: var(--hrl-target); height: var(--hrl-target); border-radius: var(--hrl-radius-pill); border: 2px solid var(--hrl-line); background: var(--hrl-bg); color: var(--hrl-ink); font-size: var(--hrl-t-small); font-weight: 600; cursor: pointer; }
|
|
639
639
|
.hrl-person--ok { border-color: var(--hrl-ok); background: var(--hrl-ok-bg); color: var(--hrl-ok); }
|
|
640
640
|
.hrl-person--warn { border-color: var(--hrl-warn); background: var(--hrl-warn-bg); color: var(--hrl-warn); }
|
|
641
|
+
|
|
642
|
+
/* Search palette (search.js): one box for screens and people, ⌘K anywhere. */
|
|
643
|
+
.hrl-search { width: min(36rem, calc(100% - 2rem)); margin: 10vh auto auto; padding: var(--hrl-s-4); border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius); background: var(--hrl-card); color: var(--hrl-ink); }
|
|
644
|
+
.hrl-search::backdrop { background: rgb(15 23 42 / .4); }
|
|
645
|
+
.hrl-search__list { list-style: none; margin: var(--hrl-s-1) 0 0; padding: 0; display: grid; gap: var(--hrl-s-2); }
|
|
@@ -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
|
|
@@ -115,6 +125,19 @@ module HrLite
|
|
|
115
125
|
end
|
|
116
126
|
end
|
|
117
127
|
|
|
128
|
+
# One screen per person for a day, week (default) or month: each day, leave,
|
|
129
|
+
# penalties and every request, each linking to where it is fixed or decided.
|
|
130
|
+
def summary
|
|
131
|
+
@employee = find_visible_employee
|
|
132
|
+
@period = WorkModeDurations::Period.build(params[:period].presence_in(%w[day week month]) || "week", parse_date_param(params[:date]))
|
|
133
|
+
range = @period.from..@period.to
|
|
134
|
+
@days = DayStatus.new(user: @employee, range: range)
|
|
135
|
+
@punches = AttendancePunch.where(user_id: @employee.id, date: range).order(:at, :id).group_by(&:date)
|
|
136
|
+
@penalties = AttendancePenalty.active.where(user_id: @employee.id, date: range).group(:date).sum(:minutes)
|
|
137
|
+
@balances = LeaveType.active.where(paid: true).map { |type| LeaveBalance.for(@employee, type, LeaveYear.current_key) }
|
|
138
|
+
@requests = summary_requests(@employee, range)
|
|
139
|
+
end
|
|
140
|
+
|
|
118
141
|
# HR sets a locked slot from today for the period.
|
|
119
142
|
def slot
|
|
120
143
|
@employee = find_manageable_employee
|
|
@@ -144,8 +167,53 @@ module HrLite
|
|
|
144
167
|
redirect_to admin_attendance_path(@employee.id, month: month), notice: "Penalty removed."
|
|
145
168
|
end
|
|
146
169
|
|
|
170
|
+
# HR settles part or all of a person's penalty hours with a reason; the rest stays.
|
|
171
|
+
def remove_penalty_hours
|
|
172
|
+
@employee = find_manageable_employee
|
|
173
|
+
minutes = (params[:hours].to_f * 60).round
|
|
174
|
+
reason = params[:reason].to_s.strip
|
|
175
|
+
held = AttendancePenalty.held_minutes(@employee.id)[@employee.id].to_i
|
|
176
|
+
back = admin_attendance_path(@employee.id, anchor: "penalties")
|
|
177
|
+
return redirect_to back, alert: "A reason is required." if reason.blank?
|
|
178
|
+
return redirect_to back, alert: "Remove between 0.5h and #{helpers.hrl_days(held / 60.0)}h." unless minutes.positive? && minutes <= held
|
|
179
|
+
|
|
180
|
+
removal = PenaltyRemoval.create!(user_id: @employee.id, minutes: minutes, reason: reason, removed_by_id: hr_current_user.id)
|
|
181
|
+
hours = helpers.hrl_days(minutes / 60.0)
|
|
182
|
+
AuditLog.record!(action: "attendance.penalty_hours_removed", subject: removal, actor: hr_current_user,
|
|
183
|
+
changes: { "minutes" => minutes, "reason" => reason })
|
|
184
|
+
Notifications.publish("attendance.penalty_removed", title: "HR removed #{hours}h of your penalties", body: reason,
|
|
185
|
+
path: "/attendance#penalties", bell_to: [ @employee ], email_to: [ @employee ])
|
|
186
|
+
redirect_to back, notice: "Removed #{hours}h of penalties."
|
|
187
|
+
end
|
|
188
|
+
|
|
147
189
|
private
|
|
148
190
|
|
|
191
|
+
# [what, when, status, link] per request in the range, only for the types
|
|
192
|
+
# whose own page this viewer may open for this person.
|
|
193
|
+
def summary_requests(user, range)
|
|
194
|
+
id, from, to = user.id, range.first, range.last
|
|
195
|
+
day = ->(d) { d.strftime("%a %-d %b") }
|
|
196
|
+
{
|
|
197
|
+
"leave.view" => -> {
|
|
198
|
+
LeaveRequest.where(user_id: id).overlapping_range(from, to).includes(:leave_type)
|
|
199
|
+
.map { |r| [ r.leave_type.name, r.date_range_label, r.status, admin_leave_request_path(r) ] } +
|
|
200
|
+
CompOffRequest.where(user_id: id, date_worked: range).map { |r| [ "Comp-off", day.(r.date_worked), r.status, admin_comp_off_request_path(r) ] }
|
|
201
|
+
},
|
|
202
|
+
"attendance.view" => -> {
|
|
203
|
+
WfhRequest.where(user_id: id).overlapping(from, to).map { |r| [ "WFH", r.dates_label, r.status, admin_wfh_request_path(r) ] } +
|
|
204
|
+
RegularizationRequest.where(user_id: id, date: range).map { |r| [ "Attendance fix", day.(r.date), r.status, admin_regularization_request_path(r) ] }
|
|
205
|
+
},
|
|
206
|
+
"expense.approve" => -> {
|
|
207
|
+
Expense.where(user_id: id, spent_on: range).includes(:category)
|
|
208
|
+
.map { |r| [ "Claim · #{r.category.name}", day.(r.spent_on), r.status, admin_expenses_path ] }
|
|
209
|
+
},
|
|
210
|
+
"document.view" => -> {
|
|
211
|
+
Document.where(user_id: id, created_at: from.beginning_of_day..to.end_of_day)
|
|
212
|
+
.map { |r| [ "Document · #{r.title}", day.(r.created_at.to_date), r.verification, admin_employee_documents_path(id) ] }
|
|
213
|
+
}
|
|
214
|
+
}.select { |perm, _| hr_reaches?(perm, user) }.flat_map { |_, rows| rows.call }
|
|
215
|
+
end
|
|
216
|
+
|
|
149
217
|
# Viewing somebody's month and rewriting their punches are different
|
|
150
218
|
# authorities, so they resolve through different permissions — a role
|
|
151
219
|
# can be given the board without being given the pencil.
|
|
@@ -8,7 +8,7 @@ module HrLite
|
|
|
8
8
|
def update
|
|
9
9
|
@setting = Setting.instance
|
|
10
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))
|
|
11
|
+
:day_minutes, :half_day_below_minutes, :recheck_in_from, :recheck_in_until, :penalty_leave_type_id))
|
|
12
12
|
redirect_to edit_admin_setting_path, notice: "Settings saved."
|
|
13
13
|
else
|
|
14
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.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One box for the whole HRMS: the screens this person may open (the menu's
|
|
3
|
+
# own gates) and the people they may see. Nothing outside their reach is
|
|
4
|
+
# ever listed; a page they still try to open answers as it always does.
|
|
5
|
+
class SearchController < ApplicationController
|
|
6
|
+
MIN_CHARS = 2
|
|
7
|
+
# Words people type that are in neither a tab's label nor its paths (which also match).
|
|
8
|
+
WORDS = {
|
|
9
|
+
"My attendance" => "check in check out punch penalty penalties regularization fix late",
|
|
10
|
+
"My leave" => "apply leave holiday comp-off balance", "Calendar" => "holidays",
|
|
11
|
+
"WFH" => "work from home", "Home locations" => "wfh location", "Slips" => "payslip salary slip",
|
|
12
|
+
"Expenses" => "claim reimbursement", "Employees" => "people staff", "Profile" => "my details",
|
|
13
|
+
"Settings" => "working day half day penalties leave type check in again"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
rate_limit to: 60, within: 1.minute, by: -> { hr_current_user.id }, name: "hr_search"
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
@q = params[:q].to_s.strip
|
|
20
|
+
words = @q.downcase.split
|
|
21
|
+
limit = params[:frame] ? 5 : 10
|
|
22
|
+
@features = @people = []
|
|
23
|
+
if @q.length >= MIN_CHARS
|
|
24
|
+
@features = helpers.hrl_sections.flat_map { |s| s[:tabs].map { |t| [ "#{s[:label]} · #{t[:label]}", public_send(t[:path]), "#{s[:label]} #{t[:label]} #{WORDS[t[:label]]} #{Array(t[:match]).join(' ').tr('/_', ' ')}".downcase ] } }
|
|
25
|
+
.select { |_, _, text| words.all? { |w| text.include?(w) } }.first(limit)
|
|
26
|
+
@people = people_matching(words).first(limit) if hr_operations?
|
|
27
|
+
end
|
|
28
|
+
params[:frame] ? render(partial: "results") : render(:show)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Only people whose attendance this viewer may open; the result opens their This week.
|
|
34
|
+
def people_matching(words)
|
|
35
|
+
codes = EmployeeProfile.pluck(:user_id, :employee_code).to_h
|
|
36
|
+
HrLite.employees.select do |user|
|
|
37
|
+
text = [ HrLite.display_name(user), user.email, codes[user.id] ].join(" ").downcase
|
|
38
|
+
words.all? { |w| text.include?(w) } && hr_reaches?("attendance.view", user)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -170,7 +170,8 @@ module HrLite
|
|
|
170
170
|
"Me" => '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
|
|
171
171
|
"Help" => '<circle cx="12" cy="12" r="9"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/>',
|
|
172
172
|
"Admin" => '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
|
173
|
-
"More" => '<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>'
|
|
173
|
+
"More" => '<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>',
|
|
174
|
+
"Search" => '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'
|
|
174
175
|
}.freeze
|
|
175
176
|
|
|
176
177
|
def hrl_icon(name)
|
|
@@ -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
|
|
@@ -83,6 +83,15 @@ module HrLite
|
|
|
83
83
|
check_out_at.present? && rechecked_in_at.nil? && now >= s.recheck_in_from && now < s.recheck_in_until
|
|
84
84
|
end
|
|
85
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)
|
|
93
|
+
end
|
|
94
|
+
|
|
86
95
|
# Metres between where the day was checked in and checked out.
|
|
87
96
|
def punch_gap_m
|
|
88
97
|
return unless check_in_lat && check_in_lng && check_out_lat && check_out_lng
|
|
@@ -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
|
-
#
|
|
366
|
-
#
|
|
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? ||
|
|
369
|
+
return if leave_type.unlimited? || errors.any?
|
|
370
370
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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,6 +5,9 @@ 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 }
|
|
@@ -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,
|
|
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
|
|
@@ -69,8 +69,7 @@ module HrLite
|
|
|
69
69
|
return Result.new(record: record, error: "Check in again is open once a day, #{s.recheck_in_from}–#{s.recheck_in_until}.")
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
record.
|
|
73
|
-
check_out_place: nil, check_out_home_m: nil, check_out_work_location_id: nil, rechecked_in_at: Time.current)
|
|
72
|
+
record.clear_check_out(rechecked_in_at: Time.current)
|
|
74
73
|
else
|
|
75
74
|
return Result.new(record: record, error: already_in_message(record)) if record.check_in_at.present?
|
|
76
75
|
|
|
@@ -91,6 +90,9 @@ module HrLite
|
|
|
91
90
|
|
|
92
91
|
apply_geo_flags(record)
|
|
93
92
|
record.save!
|
|
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)
|
|
94
96
|
apply_slot_rules(record) if @kind == :check_in && !reopen && HrLite.config.attendance_slots
|
|
95
97
|
track_work_mode(record) if HrLite.config.office_geofence
|
|
96
98
|
end
|
|
@@ -232,10 +234,12 @@ module HrLite
|
|
|
232
234
|
record.public_send("#{side}_lat=", @lat)
|
|
233
235
|
record.public_send("#{side}_lng=", @lng)
|
|
234
236
|
record.public_send("#{side}_accuracy_m=", @accuracy_m)
|
|
235
|
-
record.public_send("#{side}_place=",
|
|
237
|
+
record.public_send("#{side}_place=", punch_place)
|
|
236
238
|
record.public_send("#{side}_home_m=", home_m)
|
|
237
239
|
end
|
|
238
240
|
|
|
241
|
+
def punch_place = @place || ("office" if @lat && @lng && in_office?)
|
|
242
|
+
|
|
239
243
|
def already_in_message(record)
|
|
240
244
|
"Already checked in at #{record.check_in_at.in_time_zone(HrLite.config.time_zone).strftime('%H:%M')}."
|
|
241
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)
|
|
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‑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‑in</button>
|
|
96
|
+
</div>
|
|
97
|
+
<% end %>
|
|
89
98
|
<% end %>
|
|
90
99
|
</section>
|
|
91
100
|
<% end %>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<% content_for(:page_title) { "#{hr_display_name(@employee)} — #{@period.label}" } %>
|
|
2
|
+
<% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @employee } %>
|
|
3
|
+
<% at = ->(args) { hr_lite.summary_admin_attendance_path(@employee.id, args) }
|
|
4
|
+
fix = @employee.id != hr_current_user.id && hr_reaches?("attendance.manage", @employee)
|
|
5
|
+
tones = { present: "hrl-badge--ok", half_day: "hrl-badge--warn", absent: "hrl-badge--bad" } %>
|
|
6
|
+
<div class="hrl-page__head">
|
|
7
|
+
<h1 class="hrl-page__title"><%= hr_display_name(@employee) %></h1>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<section class="hrl-card">
|
|
11
|
+
<div class="hrl-row hrl-row--tabs hrl-row--mb">
|
|
12
|
+
<% %w[day week month].each do |kind| %>
|
|
13
|
+
<a class="hrl-btn <%= 'hrl-btn--primary' if @period.kind == kind %>" <%= 'aria-current="page"'.html_safe if @period.kind == kind %> href="<%= at.(period: kind, date: @period.from) %>"><%= kind.capitalize %></a>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="hrl-row">
|
|
17
|
+
<a class="hrl-btn" href="<%= at.(period: @period.kind, date: @period.shift(-1)) %>" aria-label="Previous <%= @period.kind %>">‹</a>
|
|
18
|
+
<strong><%= @period.label %></strong>
|
|
19
|
+
<a class="hrl-btn" href="<%= at.(period: @period.kind, date: @period.shift(1)) %>" aria-label="Next <%= @period.kind %>">›</a>
|
|
20
|
+
</div>
|
|
21
|
+
<%= form_with url: hr_lite.summary_admin_attendance_path(@employee.id), method: :get, local: true, class: "hrl-row hrl-mt" do %>
|
|
22
|
+
<input type="hidden" name="period" value="<%= @period.kind %>">
|
|
23
|
+
<div class="hrl-field"><label for="pw-date">Go to date</label><%= date_field_tag :date, @period.from, id: "pw-date" %></div>
|
|
24
|
+
<div class="hrl-form-actions"><button type="submit" class="hrl-btn">Show</button></div>
|
|
25
|
+
<% end %>
|
|
26
|
+
</section>
|
|
27
|
+
|
|
28
|
+
<section class="hrl-card">
|
|
29
|
+
<h2 class="hrl-card__title">Days</h2>
|
|
30
|
+
<% (@period.from..@period.to).each do |date| %>
|
|
31
|
+
<% day = @days.for(date); rec = day.record; punches = @punches[date].to_a %>
|
|
32
|
+
<div class="hrl-feed__item hrl-row">
|
|
33
|
+
<div class="hrl-feed__body hrl-small">
|
|
34
|
+
<strong><%= date.strftime("%a %-d %b") %></strong>
|
|
35
|
+
<span class="hrl-badge <%= tones[day.kind] %>"><%= HrLite::ApplicationHelper::DAY_STATES[day.kind][1] %></span>
|
|
36
|
+
<% if rec&.check_in_at %>
|
|
37
|
+
<div>In <%= rec.check_in_at.strftime("%H:%M") %><% if rec.check_out_at %> · Out <%= rec.check_out_at.strftime("%H:%M") %> · <%= hrl_duration(rec.worked_duration) %> worked<% end %></div>
|
|
38
|
+
<% end %>
|
|
39
|
+
<% if punches.size > 2 %><div>All punches: <%= punches.join(" · ") %></div><% end %>
|
|
40
|
+
<% if (minutes = @penalties[date]) %><div><span class="hrl-badge hrl-badge--warn">+<%= minutes %> min</span> penalty</div><% end %>
|
|
41
|
+
<% if rec&.flagged? %><div>Flagged: <%= rec.flag_note %></div><% end %>
|
|
42
|
+
</div>
|
|
43
|
+
<% if fix && date <= Date.current %>
|
|
44
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@employee.id, date: date, month: date.strftime("%Y-%m"), anchor: "regularize") %>">Fix<span class="hrl-sr-only"> <%= date.strftime("%-d %b") %></span></a>
|
|
45
|
+
<% end %>
|
|
46
|
+
</div>
|
|
47
|
+
<% end %>
|
|
48
|
+
</section>
|
|
49
|
+
|
|
50
|
+
<section class="hrl-card">
|
|
51
|
+
<h2 class="hrl-card__title">Requests</h2>
|
|
52
|
+
<% if @requests.empty? %><p class="hrl-card--empty">No requests in this <%= @period.kind %>.</p><% end %>
|
|
53
|
+
<% @requests.each do |what, dates, status, url| %>
|
|
54
|
+
<div class="hrl-feed__item hrl-row">
|
|
55
|
+
<div class="hrl-feed__body hrl-small"><strong><%= what %></strong> · <%= dates %> <%= hrl_request_status_badge(status) %></div>
|
|
56
|
+
<a class="hrl-btn" href="<%= url %>">Open<span class="hrl-sr-only"> <%= what %>, <%= dates %></span></a>
|
|
57
|
+
</div>
|
|
58
|
+
<% end %>
|
|
59
|
+
</section>
|
|
60
|
+
|
|
61
|
+
<% if hr_reaches?("leave.view", @employee) %>
|
|
62
|
+
<section class="hrl-card">
|
|
63
|
+
<h2 class="hrl-card__title">Leave balance</h2>
|
|
64
|
+
<% @balances.each do |balance| %>
|
|
65
|
+
<div class="hrl-feed__item hrl-small"><strong><%= balance.leave_type.name %></strong> · <%= hrl_days(balance.available) %> left
|
|
66
|
+
<% if (held = balance.penalty_days).positive? %> · <%= hrl_days(held) %> held for penalties · can apply <%= hrl_days(balance.applicable) %><% end %></div>
|
|
67
|
+
<% end %>
|
|
68
|
+
</section>
|
|
69
|
+
<% end %>
|
|
70
|
+
|
|
71
|
+
<% if HrLite.config.attendance_slots %>
|
|
72
|
+
<%= render "hr_lite/attendance/penalties", user: @employee, month: @period.from, removable: fix %>
|
|
73
|
+
<% end %>
|
|
@@ -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
|
|
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 %>
|
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
<%= f.time_field :recheck_in_until %>
|
|
40
40
|
<p class="hrl-hint">After checking out, a person can check in again once a day inside these hours.</p>
|
|
41
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>
|
|
42
47
|
<div class="hrl-field">
|
|
43
48
|
<%= f.label :employee_code_prefix, "Employee code prefix" %>
|
|
44
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>
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
<span class="hrl-muted">Not checked in yet.</span>
|
|
40
40
|
<% end %>
|
|
41
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 %>
|
|
42
46
|
<% if HrLite.config.attendance_slots %>
|
|
43
47
|
<%= render "hr_lite/attendance/slot", record: record %>
|
|
44
48
|
<% end %>
|
|
@@ -77,15 +81,16 @@
|
|
|
77
81
|
</details>
|
|
78
82
|
<% end %>
|
|
79
83
|
<% if flash[:punch_offer] == "home" %>
|
|
80
|
-
|
|
81
|
-
|
|
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>
|
|
82
87
|
<% if HrLite::WorkLocation.live.where(user_id: hr_current_user.id).count < HrLite::WorkLocation::MAX_LIVE %>
|
|
83
88
|
<label class="hrl-field--check"><input type="radio" name="place" value="home" required> Yes, save this place as a home location</label>
|
|
84
89
|
<% else %>
|
|
85
90
|
<p class="hrl-small"><%= HrLite::WorkLocationsController::CAP_MESSAGE %></p>
|
|
86
91
|
<% end %>
|
|
87
92
|
<label class="hrl-field--check"><input type="radio" name="place" value="travel" required> No, I am travelling</label>
|
|
88
|
-
</fieldset>
|
|
93
|
+
</fieldset></div>
|
|
89
94
|
<% end %>
|
|
90
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>
|
|
91
96
|
<p class="hrl-small hrl-muted" data-hrl-geo-status role="status" aria-live="polite"></p>
|
|
@@ -96,7 +101,7 @@
|
|
|
96
101
|
<input type="hidden" name="lat"><input type="hidden" name="lng">
|
|
97
102
|
<input type="hidden" name="accuracy_m"><input type="hidden" name="geo_status">
|
|
98
103
|
<% if flash[:punch_offer] == "travel" %>
|
|
99
|
-
<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>
|
|
100
105
|
<% end %>
|
|
101
106
|
<button type="submit" class="hrl-btn hrl-btn--big hrl-btn--block">
|
|
102
107
|
Check out
|
|
@@ -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
|
|
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" %>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<% if @q.length < HrLite::SearchController::MIN_CHARS %>
|
|
2
|
+
<p class="hrl-small hrl-muted">Type at least <%= HrLite::SearchController::MIN_CHARS %> letters — a screen (payslip, WFH, penalty) or a person.</p>
|
|
3
|
+
<% elsif @features.empty? && @people.empty? %>
|
|
4
|
+
<p class="hrl-small hrl-muted">Nothing you can open matches “<%= @q %>”.</p>
|
|
5
|
+
<% else %>
|
|
6
|
+
<% [ [ "Screens", @features.map { |label, url, _| [ label, url ] } ],
|
|
7
|
+
[ "People", @people.map { |user| [ hr_display_name(user), hr_lite.summary_admin_attendance_path(user.id) ] } ] ].each do |title, rows| %>
|
|
8
|
+
<% next if rows.empty? %>
|
|
9
|
+
<h2 class="hrl-small hrl-muted hrl-mt"><%= title %></h2>
|
|
10
|
+
<ul class="hrl-search__list">
|
|
11
|
+
<% rows.each do |label, url| %><li><a class="hrl-nav__link" href="<%= url %>"><%= label %></a></li><% end %>
|
|
12
|
+
</ul>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% end %>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Search" } %>
|
|
2
|
+
<div class="hrl-page__head"><h1 class="hrl-page__title">Search</h1></div>
|
|
3
|
+
<section class="hrl-card">
|
|
4
|
+
<form action="<%= hr_lite.search_path %>" method="get" role="search" class="hrl-field">
|
|
5
|
+
<label for="search-page-q">Search screens and people</label>
|
|
6
|
+
<input id="search-page-q" class="hrl-input" type="search" name="q" value="<%= @q %>" autocomplete="off" autofocus>
|
|
7
|
+
</form>
|
|
8
|
+
<%= render "results" %>
|
|
9
|
+
</section>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<% ops = hr_operations?
|
|
3
3
|
profile = HrLite::EmployeeProfile.find_by(user_id: user.id)
|
|
4
4
|
tabs = [
|
|
5
|
+
[ "This week", (hr_lite.summary_admin_attendance_path(user.id) if ops && hr_reaches?("attendance.view", user)) ],
|
|
5
6
|
[ "Overview", (hr_lite.admin_employee_path(profile) if profile && hr_leadership?) ],
|
|
6
7
|
[ "Attendance", (hr_lite.admin_attendance_path(user.id) if ops && hr_reaches?("attendance.view", user)) ],
|
|
7
8
|
[ "Work time", (hr_lite.admin_work_time_path(user_id: user.id) if HrLite.config.office_geofence && ops && hr_can?("work_analytics.view", scope: :team) && hr_reaches?("work_analytics.view", user)) ],
|
|
@@ -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">
|
|
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 ], [ "
|
|
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
|
|
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 %>
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
</a>
|
|
27
27
|
|
|
28
28
|
<nav class="hrl-side__nav">
|
|
29
|
+
<a class="hrl-nav__link" href="<%= hr_lite.search_path %>" data-hrl-search-open><%= hrl_icon("Search") %>Search<span class="hrl-sr-only"> (Ctrl K)</span></a>
|
|
29
30
|
<% hrl_sections.each do |section| %>
|
|
30
31
|
<% if section[:divider] %><span class="hrl-side__divider" aria-hidden="true"></span><% end %>
|
|
31
32
|
<%= hrl_nav_link(section) %>
|
|
@@ -94,6 +95,7 @@
|
|
|
94
95
|
<% if HrLite.config.back_link %>
|
|
95
96
|
<a class="hrl-nav__link hrl-side__back" href="<%= HrLite.config.back_link[:url] %>"><%= HrLite.config.back_link[:label] %></a>
|
|
96
97
|
<% end %>
|
|
98
|
+
<a class="hrl-nav__link" href="<%= hr_lite.search_path %>" data-hrl-search-open><%= hrl_icon("Search") %>Search<span class="hrl-sr-only"> (Ctrl K)</span></a>
|
|
97
99
|
<%# The second line names the place's tabs, so a phone user knows what is inside before opening it. %>
|
|
98
100
|
<% rest.each do |section| %>
|
|
99
101
|
<%= hrl_nav_link(section, hint: section[:tabs].map { |t| t[:label] }.join(", ")) %>
|
|
@@ -105,7 +107,15 @@
|
|
|
105
107
|
<%= javascript_include_tag "hr_lite/confirm", defer: true %>
|
|
106
108
|
<%= javascript_include_tag "hr_lite/mention", defer: true %>
|
|
107
109
|
<%= javascript_include_tag "hr_lite/nav", defer: true %>
|
|
108
|
-
|
|
110
|
+
<%= javascript_include_tag "hr_lite/select_filter", defer: true %>
|
|
111
|
+
<%= javascript_include_tag "hr_lite/search", defer: true %>
|
|
112
|
+
<dialog class="hrl-search" data-hrl-search aria-label="Search">
|
|
113
|
+
<form action="<%= hr_lite.search_path %>" method="get" role="search">
|
|
114
|
+
<label class="hrl-sr-only" for="hrl-search-q">Search screens and people</label>
|
|
115
|
+
<input id="hrl-search-q" class="hrl-input" type="search" name="q" placeholder="Search screens and people" autocomplete="off">
|
|
116
|
+
</form>
|
|
117
|
+
<div data-hrl-search-results aria-live="polite"></div>
|
|
118
|
+
</dialog>
|
|
109
119
|
<%= yield :scripts %>
|
|
110
120
|
</body>
|
|
111
121
|
</html>
|
data/config/routes.rb
CHANGED
|
@@ -58,6 +58,7 @@ HrLite::Engine.routes.draw do
|
|
|
58
58
|
get "people/:user_id/timeline", to: "timelines#show", as: :person_timeline
|
|
59
59
|
resources :holidays, only: :index
|
|
60
60
|
get "calendar", to: "calendar#show"
|
|
61
|
+
get "search", to: "search#show", as: :search
|
|
61
62
|
resources :salary_slips, only: %i[index show]
|
|
62
63
|
resource :employee_profile, only: :show, path: "profile"
|
|
63
64
|
patch "id_card/photo", to: "id_cards#photo", as: :id_card_photo
|
|
@@ -73,7 +74,7 @@ HrLite::Engine.routes.draw do
|
|
|
73
74
|
get "overview", to: "overview#index"
|
|
74
75
|
resources :attendances, only: %i[index show update], param: :user_id do
|
|
75
76
|
collection { get :week }
|
|
76
|
-
member { post :slot; post :unlock_slot; post :remove_penalty }
|
|
77
|
+
member { get :summary; post :slot; post :unlock_slot; post :remove_penalty; post :remove_penalty_hours }
|
|
77
78
|
end
|
|
78
79
|
resources :leave_requests, only: %i[index show new create edit update] do
|
|
79
80
|
member { post :approve; post :reject; post :cancel }
|
|
@@ -87,7 +88,7 @@ HrLite::Engine.routes.draw do
|
|
|
87
88
|
resources :regularization_requests, only: %i[index show] do
|
|
88
89
|
member { post :approve; post :reject }
|
|
89
90
|
end
|
|
90
|
-
resources :wfh_requests, only: %i[index show create] do
|
|
91
|
+
resources :wfh_requests, only: %i[index show create update] do
|
|
91
92
|
member { post :approve; post :reject }
|
|
92
93
|
end
|
|
93
94
|
resources :work_locations, only: :index do
|
|
@@ -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,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
|
|
@@ -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 },
|
data/lib/hr_lite/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hr_lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.50.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -74,6 +74,7 @@ files:
|
|
|
74
74
|
- app/assets/javascripts/hr_lite/nav.js
|
|
75
75
|
- app/assets/javascripts/hr_lite/photo_crop.js
|
|
76
76
|
- app/assets/javascripts/hr_lite/print.js
|
|
77
|
+
- app/assets/javascripts/hr_lite/search.js
|
|
77
78
|
- app/assets/javascripts/hr_lite/select_filter.js
|
|
78
79
|
- app/assets/javascripts/hr_lite/share.js
|
|
79
80
|
- app/assets/javascripts/hr_lite/tax_preview.js
|
|
@@ -147,6 +148,7 @@ files:
|
|
|
147
148
|
- app/controllers/hr_lite/regularization_requests_controller.rb
|
|
148
149
|
- app/controllers/hr_lite/resignations_controller.rb
|
|
149
150
|
- app/controllers/hr_lite/salary_slips_controller.rb
|
|
151
|
+
- app/controllers/hr_lite/search_controller.rb
|
|
150
152
|
- app/controllers/hr_lite/tax_declarations_controller.rb
|
|
151
153
|
- app/controllers/hr_lite/team_controller.rb
|
|
152
154
|
- app/controllers/hr_lite/timelines_controller.rb
|
|
@@ -184,6 +186,7 @@ files:
|
|
|
184
186
|
- app/models/hr_lite/asset.rb
|
|
185
187
|
- app/models/hr_lite/asset_assignment.rb
|
|
186
188
|
- app/models/hr_lite/attendance_penalty.rb
|
|
189
|
+
- app/models/hr_lite/attendance_punch.rb
|
|
187
190
|
- app/models/hr_lite/attendance_record.rb
|
|
188
191
|
- app/models/hr_lite/attendance_slot.rb
|
|
189
192
|
- app/models/hr_lite/audit_log.rb
|
|
@@ -211,6 +214,7 @@ files:
|
|
|
211
214
|
- app/models/hr_lite/office_location.rb
|
|
212
215
|
- app/models/hr_lite/payroll_line_item.rb
|
|
213
216
|
- app/models/hr_lite/payroll_run.rb
|
|
217
|
+
- app/models/hr_lite/penalty_removal.rb
|
|
214
218
|
- app/models/hr_lite/performance_plan.rb
|
|
215
219
|
- app/models/hr_lite/policy.rb
|
|
216
220
|
- app/models/hr_lite/policy_acknowledgement.rb
|
|
@@ -261,6 +265,7 @@ files:
|
|
|
261
265
|
- app/views/hr_lite/admin/attendances/_slot.html.erb
|
|
262
266
|
- app/views/hr_lite/admin/attendances/index.html.erb
|
|
263
267
|
- app/views/hr_lite/admin/attendances/show.html.erb
|
|
268
|
+
- app/views/hr_lite/admin/attendances/summary.html.erb
|
|
264
269
|
- app/views/hr_lite/admin/attendances/week.html.erb
|
|
265
270
|
- app/views/hr_lite/admin/audit_logs/index.html.erb
|
|
266
271
|
- app/views/hr_lite/admin/awards/_form.html.erb
|
|
@@ -398,6 +403,8 @@ files:
|
|
|
398
403
|
- app/views/hr_lite/salary_slips/index.html.erb
|
|
399
404
|
- app/views/hr_lite/salary_slips/pdf.html.erb
|
|
400
405
|
- app/views/hr_lite/salary_slips/show.html.erb
|
|
406
|
+
- app/views/hr_lite/search/_results.html.erb
|
|
407
|
+
- app/views/hr_lite/search/show.html.erb
|
|
401
408
|
- app/views/hr_lite/shared/_bell.html.erb
|
|
402
409
|
- app/views/hr_lite/shared/_form_errors.html.erb
|
|
403
410
|
- app/views/hr_lite/shared/_pagination.html.erb
|
|
@@ -489,6 +496,9 @@ files:
|
|
|
489
496
|
- db/migrate/20261015090000_add_working_day_to_hr_lite_settings.rb
|
|
490
497
|
- db/migrate/20261015091000_add_rechecked_in_at_to_hr_lite_attendance_records.rb
|
|
491
498
|
- db/migrate/20261015092000_add_hours_allowance_to_hr_lite_employee_profiles.rb
|
|
499
|
+
- db/migrate/20261015100000_create_hr_lite_attendance_punches.rb
|
|
500
|
+
- db/migrate/20261016090000_add_penalty_leave_type_to_hr_lite_settings.rb
|
|
501
|
+
- db/migrate/20261016091000_create_hr_lite_penalty_removals.rb
|
|
492
502
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
493
503
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
494
504
|
- lib/generators/hr_lite/install/templates/initializer.rb
|