hr_lite 0.31.0 → 0.35.1
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 +30 -0
- data/app/assets/javascripts/hr_lite/chart_tip.js +33 -0
- data/app/assets/javascripts/hr_lite/countdown.js +12 -0
- data/app/assets/javascripts/hr_lite/crm_picker.js +27 -0
- data/app/assets/javascripts/hr_lite/emoji_pick.js +11 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +59 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +8 -5
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/holidays_controller.rb +17 -9
- data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/work_locations_controller.rb +41 -0
- data/app/controllers/hr_lite/admin/work_modes_controller.rb +72 -0
- data/app/controllers/hr_lite/admin/work_time_controller.rb +39 -0
- data/app/controllers/hr_lite/attendance_controller.rb +2 -1
- data/app/controllers/hr_lite/calendar_controller.rb +3 -2
- data/app/controllers/hr_lite/comp_off_requests_controller.rb +1 -1
- data/app/controllers/hr_lite/holidays_controller.rb +3 -2
- data/app/controllers/hr_lite/team_controller.rb +13 -0
- data/app/controllers/hr_lite/work_locations_controller.rb +44 -0
- data/app/controllers/hr_lite/work_modes_controller.rb +40 -0
- data/app/controllers/hr_lite/work_time_controller.rb +14 -0
- data/app/helpers/hr_lite/application_helper.rb +43 -2
- data/app/jobs/hr_lite/attendance_close_job.rb +12 -5
- data/app/jobs/hr_lite/calendar_meeting_start_job.rb +31 -0
- data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +55 -0
- data/app/jobs/hr_lite/work_mode_expire_job.rb +12 -0
- data/app/models/hr_lite/attendance_penalty.rb +2 -2
- data/app/models/hr_lite/attendance_record.rb +7 -0
- data/app/models/hr_lite/comp_off_request.rb +2 -2
- data/app/models/hr_lite/employee_profile.rb +2 -0
- data/app/models/hr_lite/holiday.rb +11 -5
- data/app/models/hr_lite/leave_balance.rb +1 -1
- data/app/models/hr_lite/leave_request.rb +1 -1
- data/app/models/hr_lite/regularization_request.rb +1 -1
- data/app/models/hr_lite/setting.rb +2 -0
- data/app/models/hr_lite/work_location.rb +62 -0
- data/app/models/hr_lite/work_mode_event.rb +84 -0
- data/app/services/hr_lite/attendance_puncher.rb +60 -19
- data/app/services/hr_lite/day_status.rb +1 -1
- data/app/services/hr_lite/leave_day_counter.rb +5 -7
- data/app/services/hr_lite/team_day.rb +6 -6
- data/app/services/hr_lite/today_checkins.rb +41 -0
- data/app/services/hr_lite/work_mode_durations.rb +237 -0
- data/app/services/hr_lite/work_mode_switch.rb +144 -0
- data/app/services/hr_lite/work_status.rb +60 -0
- data/app/services/hr_lite/working_calendar.rb +11 -2
- data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
- data/app/views/hr_lite/admin/employees/_form.html.erb +7 -1
- data/app/views/hr_lite/admin/holidays/index.html.erb +10 -1
- data/app/views/hr_lite/admin/settings/edit.html.erb +9 -0
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +4 -1
- data/app/views/hr_lite/admin/wfh_requests/index.html.erb +2 -2
- data/app/views/hr_lite/admin/work_locations/index.html.erb +42 -0
- data/app/views/hr_lite/admin/work_modes/index.html.erb +105 -0
- data/app/views/hr_lite/admin/work_time/index.html.erb +42 -0
- data/app/views/hr_lite/attendance/_calendar_connection.html.erb +15 -0
- data/app/views/hr_lite/attendance/_crm_picker.html.erb +9 -0
- data/app/views/hr_lite/attendance/_custom_status.html.erb +26 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +26 -6
- data/app/views/hr_lite/attendance/_work_mode.html.erb +92 -0
- data/app/views/hr_lite/attendance/show.html.erb +4 -1
- data/app/views/hr_lite/holidays/_next_holiday.html.erb +1 -1
- data/app/views/hr_lite/home/_today_checkins.html.erb +31 -0
- data/app/views/hr_lite/home/index.html.erb +5 -0
- data/app/views/hr_lite/team/show.html.erb +25 -4
- data/app/views/hr_lite/work_locations/index.html.erb +52 -0
- data/app/views/hr_lite/work_time/_bars.html.erb +44 -0
- data/app/views/hr_lite/work_time/_donut.html.erb +36 -0
- data/app/views/hr_lite/work_time/_report.html.erb +74 -0
- data/app/views/hr_lite/work_time/_timeline.html.erb +26 -0
- data/app/views/hr_lite/work_time/show.html.erb +10 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20261013110000_create_hr_lite_work_locations.rb +23 -0
- data/db/migrate/20261013110100_add_work_mode_to_hr_lite_attendance_records.rb +7 -0
- data/db/migrate/20261013110200_add_field_staff_to_hr_lite_employee_profiles.rb +5 -0
- data/db/migrate/20261013110300_create_hr_lite_work_mode_events.rb +30 -0
- data/db/migrate/20261013110400_add_break_minutes_to_hr_lite_settings.rb +6 -0
- data/db/migrate/20261013120000_grant_hr_lite_work_analytics.rb +14 -0
- data/db/migrate/20261013130000_add_office_location_to_hr_lite_holidays.rb +12 -0
- data/db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb +6 -0
- data/db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb +9 -0
- data/db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb +9 -0
- data/lib/hr_lite/configuration.rb +13 -1
- data/lib/hr_lite/notifications.rb +9 -4
- data/lib/hr_lite/permissions.rb +1 -0
- data/lib/hr_lite/role_seeds.rb +4 -2
- data/lib/hr_lite/seeds.rb +1 -1
- data/lib/hr_lite/version.rb +1 -1
- metadata +44 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b89cc7943db74414a4d788a6455c972cbc114f786a1f4f9441908dba5fe9a0a4
|
|
4
|
+
data.tar.gz: 7a80d0e103a2dab3720ba5b3f3abbc861e1b0133d85edd20035314667b325928
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b0d4fa99d60e1c3b4d15275ab7309032922c384d64aaa9af950980427ade931dc91c369f78b8870708f04b1284cedbe89c990a9705a4b28c22fd74a0ffa7ca9
|
|
7
|
+
data.tar.gz: 17f0cacac2684fd53c56d2faffb8846531529e3e97f723cd4d9845eb78ed1bc5d687dfd1116b2aeda08dc3c30faf36cfdd1e5a42b976fd3593595817330c15b6
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.35.1] - 2026-10-13
|
|
11
|
+
|
|
12
|
+
- Spacing: forms, buttons and expandable sections stacked in a card no longer touch (only form fields carried spacing, so "Set a status" sat on "Change my mode", "Save status" on "Check in", "Out of office" on "Check out"). One card-level rule gives them a 12px gap. The emoji grid, lead picker, check-in badges, punch-card links (now wrapping as whole items, no dangling dots) and the admin month's In/Out map links all keep at least the 8px gap between touch targets. Measured at 360 px and 1280 px across Home, Attendance, WFH, WFH locations, My work time, Team and the HR work-mode, WFH, locations, work-time, attendance, holidays and employee screens: no stacked targets closer than 8px, no horizontal scroll.
|
|
13
|
+
|
|
14
|
+
## [0.35.0] - 2026-10-13
|
|
15
|
+
|
|
16
|
+
- Today's check-ins on Home: everyone in today, split into on time, late and not in yet (leave, holidays and weekends left out). "On time" uses each person's own slot plus their HR buffer and the grace minutes, the same rule as the late penalty. Hover or tap a person for their check-in, how late, check-out and hours worked (or hours so far).
|
|
17
|
+
- Custom status, Slack-style: your own text and an emoji (pick from a grid or type one), clearing after 30 minutes, 1 hour, 4 hours, today, or never. It works without a check-in, survives check-out and the nightly close, shows beside your name on the team board, and is never counted in work-time analytics.
|
|
18
|
+
- Meetings from the calendar. With `config.calendar_events` and `config.calendar_users` set, Meeting mode starts on its own when a calendar meeting begins and ends with it. `CalendarMeetingSyncJob` runs on a schedule and can sync one person instantly (for calendar change notifications): a cancelled or declined invite ends the meeting, a moved one moves it, and nothing overrides a break or a mode the person chose.
|
|
19
|
+
- One-click "Connect Google Calendar" on the punch card (`config.calendar_connection`): the host supplies the connect and disconnect links, so anyone can link a personal Gmail calendar alongside the organisation's Workspace calendars.
|
|
20
|
+
- Do not disturb: while someone is Busy or in a meeting, their HR emails wait until it ends; the bell still lands.
|
|
21
|
+
- Link a meeting, client meeting or trip to a CRM lead (`config.crm_search`, `config.crm_find`): a type-ahead picker in the forms, and the HR timeline links to the lead.
|
|
22
|
+
- Work time for a finished period is cached, keyed on the newest change to its events and check-ins, so corrections show at once. Today is always worked out live.
|
|
23
|
+
- Tap a chart segment on a phone to see its details (no hover there); segments are keyboard-focusable.
|
|
24
|
+
|
|
25
|
+
## [0.34.0] - 2026-10-13
|
|
26
|
+
|
|
27
|
+
- Holidays per office. A holiday applies to every office or to one (Admin → Holidays: "Applies to", also for bulk paste), and each employee can belong to an office (Admin → Employee: Office). Everything that decides whether a day is a working day now uses that person's calendar: attendance and absences, the nightly close, leave day counts and balances, loss of pay and payslips, comp-off, self-fix tickets, late carry-over, the team board, work status and weekly hours. Employees see their own office's holidays; announcements go only to the office concerned. People with no office get the all-office holidays, exactly as before. Two offices may now close on the same date.
|
|
28
|
+
|
|
29
|
+
## [0.33.0] - 2026-10-13
|
|
30
|
+
|
|
31
|
+
- Work time. Time per work mode, worked out from the mode timeline on every request (a correction shows at once; a break on top of a work mode counts once, as the break). Periods: day, week, month, quarter, year, custom (up to 366 days). Working time = in office, WFH, travelling, client and other meetings, busy; breaks = lunch and short breaks; out of office and offline are shown apart and never counted as working. Employees see their own under My work time; others need the new `work_analytics.view` permission (HR, Leadership and Super Admin get it; Manager does not by default) on Admin → Work time, where opening one person's breakdown is audited. Charts are server-drawn SVG: summary cards, a share donut with its denominator stated, stacked bars that open a day, and an hour-by-hour day strip. HR also gets a "worth a look" list: missing check-outs, worked days with no modes, modes with no check-in.
|
|
32
|
+
|
|
33
|
+
## [0.32.0] - 2026-10-13
|
|
34
|
+
|
|
35
|
+
- Work modes (with `office_geofence`). Check-in sets the day's mode automatically: inside an office radius it is In office; on an approved WFH day (dated or permanent) it must be within 500 m of the person's active approved home location; field staff (a profile switch) may check in from anywhere as Travelling or Client meeting, saying why and where. Check-out ends everything and sets Offline. Employees switch to In office, WFH, Travelling, Client meeting, Meeting, Lunch break (one a day, ends itself at the limit), Short break, Busy or Out of office; a mode is a status and never changes where the day's attendance was authorised. Timed modes end on the server (`WorkModeExpireJob`), with or without the page open. Holidays, leave and sick leave show automatically from their own records and take precedence. Every change is kept in a timeline with its source (system, employee, HR) and audited.
|
|
36
|
+
- WFH locations: employees register up to three places from the device ("Register here", never typed); HR approves each from Approvals → Locations. Teammates see "Home", never the address or coordinates.
|
|
37
|
+
- Mode icons beside names on the punch card and team board (a dot marks a system-set mode), team filters by department, mode and availability, and an HR Work modes board: live counts per mode, who needs review, field check-ins, a person's day timeline, a Sales view, and HR override with a reason. Lunch and short-break limits live in Admin → Settings.
|
|
38
|
+
- Permanent WFH now means standing WFH approval for every working day; check-in still has to be at an approved home location.
|
|
39
|
+
|
|
10
40
|
## [0.31.0] - 2026-10-13
|
|
11
41
|
|
|
12
42
|
- Office geofence (`config.office_geofence = true`, off by default). A check-in must come from inside an active office's radius (Admin → Offices), with a GPS fix at least that precise; otherwise it is refused with "You are not within the office location. Please request Work From Home (WFH) approval from HR." A denied or failed location says why. Check-out is unchanged.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Phones have no hover, so a title never shows there. Tapping (or tabbing to)
|
|
2
|
+
// a [data-hrl-tip] element shows its tip text — or an SVG segment's <title> —
|
|
3
|
+
// in a small box.
|
|
4
|
+
(function () {
|
|
5
|
+
"use strict";
|
|
6
|
+
var tip;
|
|
7
|
+
function hide() { if (tip) tip.hidden = true; }
|
|
8
|
+
function show(el) {
|
|
9
|
+
var title = el.dataset.hrlTip ? { textContent: el.dataset.hrlTip } : el.querySelector("title");
|
|
10
|
+
if (!title) return;
|
|
11
|
+
if (!tip) {
|
|
12
|
+
tip = document.createElement("div");
|
|
13
|
+
tip.className = "hrl-chart-tip";
|
|
14
|
+
tip.setAttribute("role", "status");
|
|
15
|
+
document.body.appendChild(tip);
|
|
16
|
+
}
|
|
17
|
+
tip.textContent = title.textContent;
|
|
18
|
+
tip.hidden = false;
|
|
19
|
+
var box = el.getBoundingClientRect();
|
|
20
|
+
var left = Math.min(Math.max(8, box.left + window.scrollX), window.scrollX + document.documentElement.clientWidth - tip.offsetWidth - 8);
|
|
21
|
+
tip.style.left = left + "px";
|
|
22
|
+
// Below the element, so the label above it stays readable.
|
|
23
|
+
tip.style.top = (box.bottom + window.scrollY + 6) + "px";
|
|
24
|
+
}
|
|
25
|
+
document.addEventListener("click", function (e) {
|
|
26
|
+
var el = e.target.closest && e.target.closest("[data-hrl-tip]");
|
|
27
|
+
el ? show(el) : hide();
|
|
28
|
+
});
|
|
29
|
+
document.addEventListener("focusin", function (e) {
|
|
30
|
+
if (e.target.closest && e.target.closest("[data-hrl-tip]")) show(e.target.closest("[data-hrl-tip]"));
|
|
31
|
+
});
|
|
32
|
+
document.addEventListener("keydown", function (e) { if (e.key === "Escape") hide(); });
|
|
33
|
+
})();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Ticks "Xh YYm left" on [data-hrl-countdown="<iso time>"]. Display only:
|
|
2
|
+
// the server ends the mode itself, with or without this page open.
|
|
3
|
+
(function () {
|
|
4
|
+
"use strict";
|
|
5
|
+
function tick() {
|
|
6
|
+
document.querySelectorAll("[data-hrl-countdown]").forEach(function (el) {
|
|
7
|
+
var left = Math.max(0, Math.round((Date.parse(el.dataset.hrlCountdown) - Date.now()) / 60000));
|
|
8
|
+
el.textContent = left === 0 ? "ended" : Math.floor(left / 60) + "h " + String(left % 60).padStart(2, "0") + "m left";
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
document.addEventListener("DOMContentLoaded", function () { tick(); setInterval(tick, 30000); });
|
|
12
|
+
})();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Type-ahead for [data-hrl-crm="<search url>"]: typing fills the <select
|
|
2
|
+
// name="crm_ref"> with matching leads. Without JS the select just stays empty.
|
|
3
|
+
(function () {
|
|
4
|
+
"use strict";
|
|
5
|
+
function setup(box) {
|
|
6
|
+
var input = box.querySelector("input[type=search]");
|
|
7
|
+
var select = box.querySelector("select");
|
|
8
|
+
var timer;
|
|
9
|
+
input.addEventListener("input", function () {
|
|
10
|
+
clearTimeout(timer);
|
|
11
|
+
var q = input.value.trim();
|
|
12
|
+
if (q.length < 2) return;
|
|
13
|
+
timer = setTimeout(function () {
|
|
14
|
+
fetch(box.dataset.hrlCrm + "?q=" + encodeURIComponent(q), { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
|
15
|
+
.then(function (r) { return r.ok ? r.json() : []; })
|
|
16
|
+
.then(function (leads) {
|
|
17
|
+
select.length = 1;
|
|
18
|
+
leads.forEach(function (lead) { select.add(new Option(lead.label, lead.id)); });
|
|
19
|
+
if (leads.length) select.selectedIndex = 1;
|
|
20
|
+
});
|
|
21
|
+
}, 250);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
25
|
+
document.querySelectorAll("[data-hrl-crm]").forEach(setup);
|
|
26
|
+
});
|
|
27
|
+
})();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Emoji grid for the status form: a tap puts that emoji in the input.
|
|
2
|
+
(function () {
|
|
3
|
+
"use strict";
|
|
4
|
+
document.addEventListener("click", function (e) {
|
|
5
|
+
var b = e.target.closest && e.target.closest("[data-hrl-emoji]");
|
|
6
|
+
if (!b) return;
|
|
7
|
+
var input = b.closest("form").querySelector("input[name=emoji]");
|
|
8
|
+
input.value = b.dataset.hrlEmoji;
|
|
9
|
+
input.focus();
|
|
10
|
+
});
|
|
11
|
+
})();
|
|
@@ -234,6 +234,18 @@ html:has(.hrl-body) { scroll-padding-top: 4rem; scroll-padding-bottom: calc(4.4r
|
|
|
234
234
|
.hrl-card--empty { text-align: center; color: var(--hrl-muted); padding: 1.8rem 1rem; }
|
|
235
235
|
.hrl-card--warn { background: var(--hrl-warn-bg); border-color: transparent; }
|
|
236
236
|
.hrl-card > p:first-child { margin-top: 0; }
|
|
237
|
+
/* Vertical rhythm: forms, buttons and disclosures stacked in a card never
|
|
238
|
+
touch (only .hrl-field carried spacing, so a button above a form, or an
|
|
239
|
+
open disclosure above the next control, sat flush). 12px >= the 8px
|
|
240
|
+
minimum gap between touch targets. */
|
|
241
|
+
.hrl-card > :is(form, details, .button_to) + *,
|
|
242
|
+
.hrl-card > * + :is(form, details, .button_to) { margin-top: .75rem; }
|
|
243
|
+
details[open] > summary.hrl-btn { margin-bottom: .75rem; }
|
|
244
|
+
.hrl-links { display: flex; flex-wrap: wrap; gap: var(--hrl-gap-target) 1rem; margin-bottom: var(--hrl-gap-target); }
|
|
245
|
+
[data-hrl-crm] select { margin-top: var(--hrl-gap-target); }
|
|
246
|
+
.hrl-checkins + .hrl-card__link { margin-top: var(--hrl-gap-target); }
|
|
247
|
+
.hrl-punch-line + .hrl-punch-line { margin-top: var(--hrl-gap-target); }
|
|
248
|
+
details > form > button[type=submit], details > form > .hrl-btn { margin-top: .25rem; }
|
|
237
249
|
.hrl-card__link, .hrl-backlink { display: inline-flex; gap: var(--hrl-s-1); align-items: center; min-height: var(--hrl-target); min-width: var(--hrl-target); font-size: var(--hrl-t-small); font-weight: 600; }
|
|
238
250
|
.hrl-listrow { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: var(--hrl-s-1) var(--hrl-s-3); padding: var(--hrl-s-3) 0; border-bottom: 1px solid var(--hrl-line); min-height: var(--hrl-target); color: var(--hrl-ink); overflow-wrap: anywhere; }
|
|
239
251
|
.hrl-listrow:last-child { border-bottom: 0; }
|
|
@@ -569,3 +581,50 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
|
|
|
569
581
|
/* Notifications link, shown only when the host configures one. */
|
|
570
582
|
.hrl-bell { display: inline-flex; align-items: center; gap: .4rem; min-height: var(--hrl-target); font-size: var(--hrl-t-small); font-weight: 600; }
|
|
571
583
|
.hrl-bell__count { min-width: 1.4rem; padding: 0 .4rem; border-radius: var(--hrl-radius-pill); background: var(--hrl-accent); color: var(--hrl-accent-ink); font-size: var(--hrl-t-micro); text-align: center; }
|
|
584
|
+
|
|
585
|
+
/* Work-mode icons. A small dot marks a mode the system set. */
|
|
586
|
+
.hrl-modes { display: inline-flex; gap: .25rem; vertical-align: middle; }
|
|
587
|
+
.hrl-mode { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; border-radius: var(--hrl-radius-pill); background: var(--hrl-bg); color: var(--hrl-ink); flex: none; }
|
|
588
|
+
.hrl-mode--office, .hrl-mode--wfh { background: var(--hrl-ok-bg); color: var(--hrl-ok); }
|
|
589
|
+
.hrl-mode--travelling, .hrl-mode--outside_meeting, .hrl-mode--meeting { background: var(--hrl-info-bg); color: var(--hrl-accent); }
|
|
590
|
+
.hrl-mode--lunch, .hrl-mode--short_break, .hrl-mode--busy, .hrl-mode--out_of_office { background: var(--hrl-warn-bg); color: var(--hrl-warn); }
|
|
591
|
+
.hrl-mode--sick_leave, .hrl-mode--on_leave, .hrl-mode--holiday { background: var(--hrl-warn-bg); color: var(--hrl-warn); }
|
|
592
|
+
.hrl-mode--auto::after { content: ""; position: absolute; right: -1px; bottom: -1px; width: 7px; height: 7px; border-radius: var(--hrl-radius-pill); background: var(--hrl-muted); border: 1.5px solid var(--hrl-card); }
|
|
593
|
+
.hrl-mode-switch { display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: var(--hrl-gap-target); margin-top: .5rem; }
|
|
594
|
+
.hrl-mode-switch > details { grid-column: 1 / -1; }
|
|
595
|
+
|
|
596
|
+
/* Work-time charts: one colour per mode, server-drawn SVG. */
|
|
597
|
+
:root { --hrl-mode-office: #047857; --hrl-mode-wfh: #0d9488; --hrl-mode-travelling: #2563eb; --hrl-mode-outside-meeting: #0891b2; --hrl-mode-meeting: #4f46e5; --hrl-mode-busy: #b45309; --hrl-mode-lunch: #f59e0b; --hrl-mode-short-break: #fcd34d; --hrl-mode-out-of-office: #78716c; --hrl-mode-offline: #d1d5db; }
|
|
598
|
+
.hrl-fill--office { fill: var(--hrl-mode-office); } .hrl-stroke--office { stroke: var(--hrl-mode-office); } .hrl-fill-bg--office { background: var(--hrl-mode-office); }
|
|
599
|
+
.hrl-fill--wfh { fill: var(--hrl-mode-wfh); } .hrl-stroke--wfh { stroke: var(--hrl-mode-wfh); } .hrl-fill-bg--wfh { background: var(--hrl-mode-wfh); }
|
|
600
|
+
.hrl-fill--travelling { fill: var(--hrl-mode-travelling); } .hrl-stroke--travelling { stroke: var(--hrl-mode-travelling); } .hrl-fill-bg--travelling { background: var(--hrl-mode-travelling); }
|
|
601
|
+
.hrl-fill--outside_meeting { fill: var(--hrl-mode-outside-meeting); } .hrl-stroke--outside_meeting { stroke: var(--hrl-mode-outside-meeting); } .hrl-fill-bg--outside_meeting { background: var(--hrl-mode-outside-meeting); }
|
|
602
|
+
.hrl-fill--meeting { fill: var(--hrl-mode-meeting); } .hrl-stroke--meeting { stroke: var(--hrl-mode-meeting); } .hrl-fill-bg--meeting { background: var(--hrl-mode-meeting); }
|
|
603
|
+
.hrl-fill--busy { fill: var(--hrl-mode-busy); } .hrl-stroke--busy { stroke: var(--hrl-mode-busy); } .hrl-fill-bg--busy { background: var(--hrl-mode-busy); }
|
|
604
|
+
.hrl-fill--lunch { fill: var(--hrl-mode-lunch); } .hrl-stroke--lunch { stroke: var(--hrl-mode-lunch); } .hrl-fill-bg--lunch { background: var(--hrl-mode-lunch); }
|
|
605
|
+
.hrl-fill--short_break { fill: var(--hrl-mode-short-break); } .hrl-stroke--short_break { stroke: var(--hrl-mode-short-break); } .hrl-fill-bg--short_break { background: var(--hrl-mode-short-break); }
|
|
606
|
+
.hrl-fill--out_of_office { fill: var(--hrl-mode-out-of-office); } .hrl-stroke--out_of_office { stroke: var(--hrl-mode-out-of-office); } .hrl-fill-bg--out_of_office { background: var(--hrl-mode-out-of-office); }
|
|
607
|
+
.hrl-fill--offline { fill: var(--hrl-mode-offline); } .hrl-stroke--offline { stroke: var(--hrl-mode-offline); } .hrl-fill-bg--offline { background: var(--hrl-mode-offline); }
|
|
608
|
+
.hrl-chart-row { display: grid; gap: var(--hrl-gap-target); }
|
|
609
|
+
@media (min-width: 481px) { .hrl-chart-row { grid-template-columns: 10rem 1fr; align-items: center; } }
|
|
610
|
+
.hrl-donut { width: 10rem; max-width: 100%; transform: rotate(0deg); }
|
|
611
|
+
.hrl-donut__track { stroke: var(--hrl-bg); }
|
|
612
|
+
.hrl-swatch { display: inline-block; width: .7rem; height: .7rem; border-radius: var(--hrl-radius-pill); vertical-align: middle; }
|
|
613
|
+
.hrl-chart-scroll { overflow-x: auto; }
|
|
614
|
+
.hrl-bars { width: 100%; min-width: 16rem; height: 10rem; }
|
|
615
|
+
.hrl-bars__label { font-size: 5px; fill: var(--hrl-muted); }
|
|
616
|
+
.hrl-timeline { width: 100%; height: 2rem; background: var(--hrl-bg); border-radius: var(--hrl-radius-sm); }
|
|
617
|
+
.hrl-timeline__auto { stroke: var(--hrl-card); stroke-width: .3; stroke-dasharray: .6 .4; }
|
|
618
|
+
.hrl-timeline__axis { display: flex; justify-content: space-between; }
|
|
619
|
+
.hrl-timeline__list { margin: .5rem 0 0; padding-left: 1.2rem; display: grid; gap: .25rem; }
|
|
620
|
+
.hrl-chart-tip { position: absolute; z-index: 30; max-width: 16rem; padding: .4rem .6rem; border-radius: var(--hrl-radius-sm); background: var(--hrl-ink); color: var(--hrl-card); font-size: var(--hrl-t-small); pointer-events: none; }
|
|
621
|
+
[data-hrl-tip]:focus { outline: 2px solid var(--hrl-accent); outline-offset: 1px; }
|
|
622
|
+
.hrl-custom-status { display: inline-flex; gap: .3rem; align-items: center; overflow-wrap: anywhere; }
|
|
623
|
+
.hrl-emoji-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(2.75rem, 1fr)); gap: var(--hrl-gap-target); margin-top: var(--hrl-gap-target); }
|
|
624
|
+
.hrl-emoji { min-height: var(--hrl-target); border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius-sm); background: var(--hrl-card); font-size: 1.25rem; cursor: pointer; }
|
|
625
|
+
/* Today's check-ins: one initials badge per person; hover/tap shows times. */
|
|
626
|
+
.hrl-checkins + .hrl-checkins { margin-top: .6rem; }
|
|
627
|
+
.hrl-checkins__people { display: flex; flex-wrap: wrap; gap: var(--hrl-gap-target); margin: .4rem 0 0; padding: 0; list-style: none; }
|
|
628
|
+
.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; }
|
|
629
|
+
.hrl-person--ok { border-color: var(--hrl-ok); background: var(--hrl-ok-bg); color: var(--hrl-ok); }
|
|
630
|
+
.hrl-person--warn { border-color: var(--hrl-warn); background: var(--hrl-warn-bg); color: var(--hrl-warn); }
|
|
@@ -25,17 +25,20 @@ module HrLite
|
|
|
25
25
|
# The current week counts only the days already over.
|
|
26
26
|
def week
|
|
27
27
|
@monday = parse_date_param(params[:date]).beginning_of_week(:monday)
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
span = @monday..[ @monday + 4, Date.current - 1 ].min
|
|
29
|
+
calendars = WorkingCalendar.by_office(@monday..(@monday + 4))
|
|
30
30
|
visible = hr_access.visible_user_ids("attendance.view")
|
|
31
31
|
employees = HrLite.active_employees(on: @monday)
|
|
32
32
|
employees = employees.select { |user| visible.include?(user.id) } if visible
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
offices = EmployeeProfile.where(user_id: employees.map(&:id)).pluck(:user_id, :office_location_id).to_h
|
|
34
|
+
records = AttendanceRecord.where(user_id: employees.map(&:id), date: span).group_by(&:user_id)
|
|
35
|
+
on_leave = span.index_with { |d| LeaveRequest.active_on(d).where(half_day: false).pluck(:user_id) }
|
|
35
36
|
week_hours = HrLite.config.work_hours&.dig(:week)
|
|
36
37
|
|
|
37
38
|
@rows = employees.map do |user|
|
|
38
|
-
|
|
39
|
+
# Each person's own working days: their office's holidays are off.
|
|
40
|
+
days = span.select { |d| calendars[offices[user.id]].working_day?(d) }
|
|
41
|
+
worked = records.fetch(user.id, []).select { |r| days.include?(r.date) }.filter_map(&:worked_duration)
|
|
39
42
|
hours = worked.sum / 3600.0
|
|
40
43
|
target = week_hours && week_hours * days.count { |d| on_leave[d].exclude?(user.id) } / 5.0
|
|
41
44
|
{ user: user, hours: hours, avg: worked.any? ? hours / worked.size : nil, target: target,
|
|
@@ -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
|
|
119
|
+
:buffer_minutes, :buffer_reason, :buffer_note, :permanent_wfh, :field_staff, :office_location_id
|
|
120
120
|
)
|
|
121
121
|
end
|
|
122
122
|
|
|
@@ -4,14 +4,15 @@ module HrLite
|
|
|
4
4
|
def index
|
|
5
5
|
year = params[:year].to_i
|
|
6
6
|
@year = year.between?(2000, 2100) ? year : Date.current.year
|
|
7
|
-
@holidays = Holiday.where(date: Date.new(@year, 1, 1)..Date.new(@year, 12, 31)).order(:date)
|
|
7
|
+
@holidays = Holiday.includes(:office_location).where(date: Date.new(@year, 1, 1)..Date.new(@year, 12, 31)).order(:date)
|
|
8
8
|
@holiday = Holiday.new
|
|
9
|
+
@offices = OfficeLocation.order(:name).pluck(:name, :id)
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def create
|
|
12
13
|
@holiday = Holiday.new(holiday_params)
|
|
13
14
|
if @holiday.save
|
|
14
|
-
announce("Holiday added: #{@holiday.name} on #{@holiday.date.strftime('%a %d %b %Y')}")
|
|
15
|
+
announce("Holiday added: #{@holiday.name} on #{@holiday.date.strftime('%a %d %b %Y')}", @holiday.office_location_id)
|
|
15
16
|
redirect_to admin_holidays_path(year: @holiday.date.year), notice: "Holiday added."
|
|
16
17
|
else
|
|
17
18
|
redirect_to admin_holidays_path, alert: @holiday.errors.full_messages.to_sentence
|
|
@@ -34,9 +35,11 @@ module HrLite
|
|
|
34
35
|
notice: "Holiday removed.", status: :see_other
|
|
35
36
|
end
|
|
36
37
|
|
|
37
|
-
# Bulk paste: one "YYYY-MM-DD, Name[, optional]" per line
|
|
38
|
-
# per-line problems, skips
|
|
38
|
+
# Bulk paste: one "YYYY-MM-DD, Name[, optional]" per line, all for the
|
|
39
|
+
# chosen office (blank = every office). Reports per-line problems, skips
|
|
40
|
+
# duplicates silently.
|
|
39
41
|
def bulk_create
|
|
42
|
+
office_id = OfficeLocation.find_by(id: params[:office_location_id])&.id
|
|
40
43
|
created = 0
|
|
41
44
|
problems = []
|
|
42
45
|
|
|
@@ -50,13 +53,13 @@ module HrLite
|
|
|
50
53
|
problems << "Line #{number}: expected YYYY-MM-DD, Name"
|
|
51
54
|
next
|
|
52
55
|
end
|
|
53
|
-
next if Holiday.exists?(date: date)
|
|
56
|
+
next if Holiday.exists?(date: date, office_location_id: office_id)
|
|
54
57
|
|
|
55
|
-
Holiday.create!(date: date, name: name_part, optional: flag.to_s.casecmp?("optional"))
|
|
58
|
+
Holiday.create!(date: date, name: name_part, optional: flag.to_s.casecmp?("optional"), office_location_id: office_id)
|
|
56
59
|
created += 1
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
announce("#{created} holiday#{'s' unless created == 1} added to the calendar") if created.positive?
|
|
62
|
+
announce("#{created} holiday#{'s' unless created == 1} added to the calendar", office_id) if created.positive?
|
|
60
63
|
message = "#{created} holiday#{'s' unless created == 1} added."
|
|
61
64
|
message += " Problems: #{problems.join('; ')}" if problems.any?
|
|
62
65
|
redirect_to admin_holidays_path, problems.any? ? { alert: message } : { notice: message }
|
|
@@ -64,13 +67,18 @@ module HrLite
|
|
|
64
67
|
|
|
65
68
|
private
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
# An office's holiday goes to that office's people; an all-office one to everyone.
|
|
71
|
+
def announce(title, office_id)
|
|
68
72
|
staff = HrLite.active_employees
|
|
73
|
+
if office_id
|
|
74
|
+
in_office = EmployeeProfile.where(office_location_id: office_id).pluck(:user_id).to_set
|
|
75
|
+
staff = staff.select { |u| in_office.include?(u.id) }
|
|
76
|
+
end
|
|
69
77
|
Notifications.publish("holiday.published", title: title, path: "/holidays", bell_to: staff, email_to: staff)
|
|
70
78
|
end
|
|
71
79
|
|
|
72
80
|
def holiday_params
|
|
73
|
-
params.require(:holiday).permit(:date, :name, :optional)
|
|
81
|
+
params.require(:holiday).permit(:date, :name, :optional, :office_location_id)
|
|
74
82
|
end
|
|
75
83
|
end
|
|
76
84
|
end
|
|
@@ -7,7 +7,7 @@ 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))
|
|
10
|
+
if @setting.update(params.require(:setting).permit(:weekend_policy, :employee_code_prefix, :lunch_break_minutes, :short_break_minutes))
|
|
11
11
|
redirect_to edit_admin_setting_path, notice: "Settings saved."
|
|
12
12
|
else
|
|
13
13
|
render :edit, status: :unprocessable_entity
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# HR approves the WFH locations employees register. Coordinates are only
|
|
4
|
+
# shown here, inside attendance.view reach.
|
|
5
|
+
class WorkLocationsController < BaseController
|
|
6
|
+
def index
|
|
7
|
+
@status = params[:status].presence_in(WorkLocation::STATUSES) || "pending"
|
|
8
|
+
@locations = paginate(hr_scope(WorkLocation.where(status: @status), "attendance.view")
|
|
9
|
+
.includes(:user).order(created_at: :desc))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def approve
|
|
13
|
+
decide("approved", params[:decision_note].to_s.strip.presence)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def reject
|
|
17
|
+
note = params[:decision_note].to_s.strip
|
|
18
|
+
return redirect_to admin_work_locations_path, alert: "A note is required to reject." if note.blank?
|
|
19
|
+
|
|
20
|
+
decide("rejected", note)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def decide(status, note)
|
|
26
|
+
location = hr_scope(WorkLocation.all, "attendance.manage").where.not(user_id: hr_current_user.id).find(params[:id])
|
|
27
|
+
location.decide!(status, actor: hr_current_user, note: note)
|
|
28
|
+
begin
|
|
29
|
+
Notifications.publish("work_location.#{status}", title: "WFH location #{status} — #{location.name}",
|
|
30
|
+
body: note, path: "/work_locations",
|
|
31
|
+
bell_to: [ location.user ], email_to: [ location.user ])
|
|
32
|
+
rescue => e
|
|
33
|
+
Rails.logger.error("[hr_lite] work location notification failed: #{e.class}: #{e.message}")
|
|
34
|
+
end
|
|
35
|
+
redirect_to admin_work_locations_path, notice: "Location #{status}."
|
|
36
|
+
rescue ActiveRecord::RecordInvalid
|
|
37
|
+
redirect_to admin_work_locations_path, alert: "Only pending locations can be decided."
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Who is in which mode (live today, "during the day" for past dates), who
|
|
4
|
+
# needs a look, field check-ins, one person's timeline, and HR override.
|
|
5
|
+
# Locations and trip details show here only, inside attendance.view reach.
|
|
6
|
+
class WorkModesController < BaseController
|
|
7
|
+
def index
|
|
8
|
+
@date = params[:date].present? ? parse_date_param(params[:date]) : Date.current
|
|
9
|
+
visible = hr_access.visible_user_ids("attendance.view")
|
|
10
|
+
employees = HrLite.active_employees(on: @date)
|
|
11
|
+
employees = employees.select { |u| visible.include?(u.id) } if visible
|
|
12
|
+
if (dept = params[:department].presence)
|
|
13
|
+
in_dept = EmployeeProfile.where(department: dept).pluck(:user_id).to_set
|
|
14
|
+
employees = employees.select { |u| in_dept.include?(u.id) }
|
|
15
|
+
end
|
|
16
|
+
@departments = EmployeeProfile.where.not(department: [ nil, "" ]).distinct.order(:department).pluck(:department)
|
|
17
|
+
ids = employees.map(&:id)
|
|
18
|
+
@people = employees.index_by(&:id)
|
|
19
|
+
|
|
20
|
+
@by_mode = @date == Date.current ? live_modes(ids) : day_modes(ids)
|
|
21
|
+
records = AttendanceRecord.for_date(@date).where(user_id: ids).index_by(&:user_id)
|
|
22
|
+
on_leave = LeaveRequest.active_on(@date).where(user_id: ids, half_day: false).pluck(:user_id).to_set
|
|
23
|
+
calendars = WorkingCalendar.by_office(@date..@date)
|
|
24
|
+
offices = EmployeeProfile.where(user_id: ids).pluck(:user_id, :office_location_id).to_h
|
|
25
|
+
@needs_review = @date > Date.current ? [] : ids.select do |id|
|
|
26
|
+
calendars[offices[id]].working_day?(@date) && !on_leave.include?(id) && !records[id]&.check_in_at
|
|
27
|
+
end
|
|
28
|
+
@field = records.values.select { |r| %w[travelling outside_meeting].include?(r.work_mode) }
|
|
29
|
+
@field_events = WorkModeEvent.where(attendance_record_id: @field.map(&:id), source: "system", mode: %w[travelling outside_meeting])
|
|
30
|
+
.index_by(&:attendance_record_id)
|
|
31
|
+
|
|
32
|
+
if (@person = params[:user_id].presence && @people[params[:user_id].to_i])
|
|
33
|
+
day = @date.in_time_zone(HrLite.config.time_zone).all_day
|
|
34
|
+
@timeline = WorkModeEvent.where(user_id: @person.id).where("started_at <= ? AND (ended_at IS NULL OR ended_at >= ?)", day.last, day.first)
|
|
35
|
+
.order(:started_at, :id).limit(200)
|
|
36
|
+
@locations = WorkLocation.where(id: @timeline.filter_map(&:work_location_id)).index_by(&:id)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def override
|
|
41
|
+
employee = HrLite.user_klass.find(params[:user_id])
|
|
42
|
+
hr_require_reach!("attendance.manage", employee)
|
|
43
|
+
raise ActiveRecord::RecordNotFound if employee.id == hr_current_user.id
|
|
44
|
+
|
|
45
|
+
result = WorkModeSwitch.call(user: employee, mode: params[:mode], source: "hr", actor: hr_current_user,
|
|
46
|
+
params: params.permit(:title, :client, :destination, :reason, :minutes, :until, :category, :crm_ref))
|
|
47
|
+
flash_for = result.ok? ? { notice: "#{hr_display_name(employee)} is now: #{result.event.label}." } : { alert: result.error }
|
|
48
|
+
redirect_back fallback_location: admin_attendance_path(employee.id), **flash_for
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# mode => user ids, from the live status (primary and overlay both count).
|
|
54
|
+
def live_modes(ids)
|
|
55
|
+
WorkStatus.for_users(ids).each_with_object(Hash.new { |h, k| h[k] = [] }) do |(id, st), out|
|
|
56
|
+
out[st.primary.mode] << id
|
|
57
|
+
out[st.overlay.mode] << id if st.overlay
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A past date: everyone who spent any time in each mode that day.
|
|
62
|
+
def day_modes(ids)
|
|
63
|
+
day = @date.in_time_zone(HrLite.config.time_zone).all_day
|
|
64
|
+
rows = WorkModeEvent.work.where(user_id: ids).where("started_at <= ? AND (ended_at IS NULL OR ended_at >= ?)", day.last, day.first)
|
|
65
|
+
.distinct.pluck(:mode, :user_id)
|
|
66
|
+
out = rows.group_by(&:first).transform_values { |pairs| pairs.map(&:last).uniq }
|
|
67
|
+
out["on_leave"] = LeaveRequest.active_on(@date).where(user_id: ids, half_day: false).pluck(:user_id).uniq
|
|
68
|
+
out
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Work-time analytics for HR: one person (audited) or a department
|
|
4
|
+
# aggregate. Needs work_analytics.view; its scope decides whose rows.
|
|
5
|
+
class WorkTimeController < BaseController
|
|
6
|
+
before_action do
|
|
7
|
+
raise ActiveRecord::RecordNotFound unless HrLite.config.office_geofence
|
|
8
|
+
hr_access_denied unless hr_can?("work_analytics.view", scope: :team)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
@period, @period_error = WorkModeDurations::Period.from_params(params, Date.current)
|
|
13
|
+
visible = hr_access.visible_user_ids("work_analytics.view")
|
|
14
|
+
@employees = HrLite.employees.reject { |u| visible && !visible.include?(u.id) }
|
|
15
|
+
@departments = EmployeeProfile.where(user_id: @employees.map(&:id)).where.not(department: [ nil, "" ])
|
|
16
|
+
.distinct.order(:department).pluck(:department)
|
|
17
|
+
if (dept = params[:department].presence)
|
|
18
|
+
in_dept = EmployeeProfile.where(department: dept).pluck(:user_id).to_set
|
|
19
|
+
@employees = @employees.select { |u| in_dept.include?(u.id) }
|
|
20
|
+
end
|
|
21
|
+
@names = @employees.to_h { |u| [ u.id, hr_display_name(u) ] }
|
|
22
|
+
|
|
23
|
+
if params[:user_id].present?
|
|
24
|
+
@person = HrLite.user_klass.find(params[:user_id])
|
|
25
|
+
hr_require_reach!("work_analytics.view", @person)
|
|
26
|
+
@names[@person.id] = hr_display_name(@person)
|
|
27
|
+
ids = [ @person.id ]
|
|
28
|
+
AuditLog.record!(action: "work_analytics.viewed", subject: @person, actor: hr_current_user,
|
|
29
|
+
changes: { "period" => @period.label })
|
|
30
|
+
else
|
|
31
|
+
ids = @employees.map(&:id)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@result = WorkModeDurations.call(ids, @period)
|
|
35
|
+
@records = @person ? AttendanceRecord.where(user_id: @person.id, date: @period.from..@period.to).where.not(check_in_at: nil).order(:date).limit(400) : []
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -51,7 +51,8 @@ module HrLite
|
|
|
51
51
|
private
|
|
52
52
|
|
|
53
53
|
def punch(kind)
|
|
54
|
-
|
|
54
|
+
field = params.fetch(:field, {}).permit(:mode, :purpose, :place).to_h
|
|
55
|
+
result = AttendancePuncher.call(user: hr_current_user, kind: kind, field: field, **punch_params.to_h.symbolize_keys)
|
|
55
56
|
if result.ok?
|
|
56
57
|
time = Time.current.strftime("%H:%M")
|
|
57
58
|
redirect_to attendance_path, notice: "#{kind == :check_in ? 'Checked in' : 'Checked out'} at #{time}."
|
|
@@ -6,8 +6,9 @@ module HrLite
|
|
|
6
6
|
def show
|
|
7
7
|
@month = parse_month_param(params[:month])
|
|
8
8
|
range = @month.beginning_of_month..@month.end_of_month
|
|
9
|
-
|
|
10
|
-
@
|
|
9
|
+
office = WorkingCalendar.office_for(hr_current_user.id) # the viewer's office's holidays
|
|
10
|
+
@calendar = WorkingCalendar.new(range, office_id: office)
|
|
11
|
+
@holidays = Holiday.in_range(range).for_office(office).order(:date).group_by(&:date)
|
|
11
12
|
@leaves = LeaveRequest.approved.includes(:leave_type, :user)
|
|
12
13
|
.overlapping_range(range.first, range.last)
|
|
13
14
|
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |request, acc|
|
|
@@ -39,7 +39,7 @@ module HrLite
|
|
|
39
39
|
|
|
40
40
|
# Most recent non-working day — the day they most likely worked extra.
|
|
41
41
|
def default_date
|
|
42
|
-
calendar = WorkingCalendar.
|
|
42
|
+
calendar = WorkingCalendar.for_user(hr_current_user.id, (Date.current - 14)..Date.current)
|
|
43
43
|
(0..14).map { |i| Date.current - i }.find { |d| !calendar.working_day?(d) } || Date.current
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -3,8 +3,9 @@ module HrLite
|
|
|
3
3
|
def index
|
|
4
4
|
year = params[:year].to_i
|
|
5
5
|
@year = year.between?(2000, 2100) ? year : Date.current.year
|
|
6
|
-
|
|
7
|
-
@
|
|
6
|
+
office = WorkingCalendar.office_for(hr_current_user.id) # yours plus the all-office ones
|
|
7
|
+
@holidays = Holiday.for_office(office).where(date: Date.new(@year, 1, 1)..Date.new(@year, 12, 31)).order(:date)
|
|
8
|
+
@next_holiday_id = Holiday.company_wide.for_office(office).upcoming.pick(:id)
|
|
8
9
|
end
|
|
9
10
|
end
|
|
10
11
|
end
|
|
@@ -6,6 +6,19 @@ module HrLite
|
|
|
6
6
|
def show
|
|
7
7
|
@date = parse_date_param(params[:date])
|
|
8
8
|
@board = TeamDay.new(date: @date)
|
|
9
|
+
return unless HrLite.config.office_geofence && @date == Date.current
|
|
10
|
+
|
|
11
|
+
# Live work modes: labels only (Office / Home / …), never where home is.
|
|
12
|
+
@statuses = WorkStatus.for_users(@board.rows.map { |r| r.user.id })
|
|
13
|
+
@departments = @board.rows.filter_map { |r| r.profile&.department.presence }.uniq.sort
|
|
14
|
+
@mode = params[:mode].presence_in(WorkModeEvent::MODES.keys + WorkStatus::DERIVED.keys)
|
|
15
|
+
@availability = params[:availability].presence_in(%w[available unavailable])
|
|
16
|
+
@rows = @board.rows.select do |r|
|
|
17
|
+
st = @statuses[r.user.id]
|
|
18
|
+
(params[:department].blank? || r.profile&.department == params[:department]) &&
|
|
19
|
+
(@mode.nil? || [ st.primary.mode, st.overlay&.mode ].include?(@mode)) &&
|
|
20
|
+
(@availability.nil? || st.available? == (@availability == "available"))
|
|
21
|
+
end
|
|
9
22
|
end
|
|
10
23
|
end
|
|
11
24
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Employee's own WFH locations. Scoping to hr_current_user is the
|
|
3
|
+
# authorization; coordinates arrive only from the device capture.
|
|
4
|
+
class WorkLocationsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@locations = WorkLocation.live.where(user_id: hr_current_user.id).order(:created_at)
|
|
7
|
+
@location = WorkLocation.new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
@location = WorkLocation.new(params.permit(:name, :address, :lat, :lng, :accuracy_m)
|
|
12
|
+
.merge(user_id: hr_current_user.id, status: "pending"))
|
|
13
|
+
saved = WorkLocation.transaction { HrLite.user_klass.lock.find(hr_current_user.id) && @location.save }
|
|
14
|
+
unless saved
|
|
15
|
+
@locations = WorkLocation.live.where(user_id: hr_current_user.id).order(:created_at)
|
|
16
|
+
return render :index, status: :unprocessable_entity
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
AuditLog.record!(action: "work_location.requested", subject: @location, actor: hr_current_user, changes: { "name" => @location.name })
|
|
20
|
+
notify("work_location.requested", "#{HrLite.display_name(hr_current_user)} registered a WFH location: #{@location.name}",
|
|
21
|
+
"/admin/work_locations", HrLite.admin_users)
|
|
22
|
+
redirect_to work_locations_path, notice: "Location sent to HR for approval."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def activate
|
|
26
|
+
location = WorkLocation.approved.where(user_id: hr_current_user.id).find(params[:id])
|
|
27
|
+
location.make_active!
|
|
28
|
+
redirect_to work_locations_path, notice: "#{location.name} is now your WFH location."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def remove
|
|
32
|
+
WorkLocation.live.where(user_id: hr_current_user.id).find(params[:id]).remove!(actor: hr_current_user)
|
|
33
|
+
redirect_to work_locations_path, notice: "Location removed."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def notify(event, title, path, to)
|
|
39
|
+
Notifications.publish(event, title: title, path: path, bell_to: to)
|
|
40
|
+
rescue => e
|
|
41
|
+
Rails.logger.error("[hr_lite] work location notification failed: #{e.class}: #{e.message}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|