hr_lite 0.31.0 → 0.35.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/app/assets/javascripts/hr_lite/chart_tip.js +33 -0
  4. data/app/assets/javascripts/hr_lite/countdown.js +12 -0
  5. data/app/assets/javascripts/hr_lite/crm_picker.js +27 -0
  6. data/app/assets/javascripts/hr_lite/emoji_pick.js +11 -0
  7. data/app/assets/stylesheets/hr_lite/hr_lite.css +47 -0
  8. data/app/controllers/hr_lite/admin/attendances_controller.rb +8 -5
  9. data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
  10. data/app/controllers/hr_lite/admin/holidays_controller.rb +17 -9
  11. data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
  12. data/app/controllers/hr_lite/admin/work_locations_controller.rb +41 -0
  13. data/app/controllers/hr_lite/admin/work_modes_controller.rb +72 -0
  14. data/app/controllers/hr_lite/admin/work_time_controller.rb +39 -0
  15. data/app/controllers/hr_lite/attendance_controller.rb +2 -1
  16. data/app/controllers/hr_lite/calendar_controller.rb +3 -2
  17. data/app/controllers/hr_lite/comp_off_requests_controller.rb +1 -1
  18. data/app/controllers/hr_lite/holidays_controller.rb +3 -2
  19. data/app/controllers/hr_lite/team_controller.rb +13 -0
  20. data/app/controllers/hr_lite/work_locations_controller.rb +44 -0
  21. data/app/controllers/hr_lite/work_modes_controller.rb +40 -0
  22. data/app/controllers/hr_lite/work_time_controller.rb +14 -0
  23. data/app/helpers/hr_lite/application_helper.rb +43 -2
  24. data/app/jobs/hr_lite/attendance_close_job.rb +12 -5
  25. data/app/jobs/hr_lite/calendar_meeting_start_job.rb +31 -0
  26. data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +55 -0
  27. data/app/jobs/hr_lite/work_mode_expire_job.rb +12 -0
  28. data/app/models/hr_lite/attendance_penalty.rb +2 -2
  29. data/app/models/hr_lite/attendance_record.rb +7 -0
  30. data/app/models/hr_lite/comp_off_request.rb +2 -2
  31. data/app/models/hr_lite/employee_profile.rb +2 -0
  32. data/app/models/hr_lite/holiday.rb +11 -5
  33. data/app/models/hr_lite/leave_balance.rb +1 -1
  34. data/app/models/hr_lite/leave_request.rb +1 -1
  35. data/app/models/hr_lite/regularization_request.rb +1 -1
  36. data/app/models/hr_lite/setting.rb +2 -0
  37. data/app/models/hr_lite/work_location.rb +62 -0
  38. data/app/models/hr_lite/work_mode_event.rb +84 -0
  39. data/app/services/hr_lite/attendance_puncher.rb +60 -19
  40. data/app/services/hr_lite/day_status.rb +1 -1
  41. data/app/services/hr_lite/leave_day_counter.rb +5 -7
  42. data/app/services/hr_lite/team_day.rb +6 -6
  43. data/app/services/hr_lite/today_checkins.rb +41 -0
  44. data/app/services/hr_lite/work_mode_durations.rb +237 -0
  45. data/app/services/hr_lite/work_mode_switch.rb +144 -0
  46. data/app/services/hr_lite/work_status.rb +60 -0
  47. data/app/services/hr_lite/working_calendar.rb +11 -2
  48. data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
  49. data/app/views/hr_lite/admin/employees/_form.html.erb +7 -1
  50. data/app/views/hr_lite/admin/holidays/index.html.erb +10 -1
  51. data/app/views/hr_lite/admin/settings/edit.html.erb +9 -0
  52. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +4 -1
  53. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +2 -2
  54. data/app/views/hr_lite/admin/work_locations/index.html.erb +42 -0
  55. data/app/views/hr_lite/admin/work_modes/index.html.erb +105 -0
  56. data/app/views/hr_lite/admin/work_time/index.html.erb +42 -0
  57. data/app/views/hr_lite/attendance/_calendar_connection.html.erb +15 -0
  58. data/app/views/hr_lite/attendance/_crm_picker.html.erb +9 -0
  59. data/app/views/hr_lite/attendance/_custom_status.html.erb +26 -0
  60. data/app/views/hr_lite/attendance/_punch_card.html.erb +19 -2
  61. data/app/views/hr_lite/attendance/_work_mode.html.erb +92 -0
  62. data/app/views/hr_lite/attendance/show.html.erb +4 -1
  63. data/app/views/hr_lite/holidays/_next_holiday.html.erb +1 -1
  64. data/app/views/hr_lite/home/_today_checkins.html.erb +31 -0
  65. data/app/views/hr_lite/home/index.html.erb +5 -0
  66. data/app/views/hr_lite/team/show.html.erb +25 -4
  67. data/app/views/hr_lite/work_locations/index.html.erb +52 -0
  68. data/app/views/hr_lite/work_time/_bars.html.erb +44 -0
  69. data/app/views/hr_lite/work_time/_donut.html.erb +36 -0
  70. data/app/views/hr_lite/work_time/_report.html.erb +74 -0
  71. data/app/views/hr_lite/work_time/_timeline.html.erb +26 -0
  72. data/app/views/hr_lite/work_time/show.html.erb +10 -0
  73. data/config/routes.rb +16 -0
  74. data/db/migrate/20261013110000_create_hr_lite_work_locations.rb +23 -0
  75. data/db/migrate/20261013110100_add_work_mode_to_hr_lite_attendance_records.rb +7 -0
  76. data/db/migrate/20261013110200_add_field_staff_to_hr_lite_employee_profiles.rb +5 -0
  77. data/db/migrate/20261013110300_create_hr_lite_work_mode_events.rb +30 -0
  78. data/db/migrate/20261013110400_add_break_minutes_to_hr_lite_settings.rb +6 -0
  79. data/db/migrate/20261013120000_grant_hr_lite_work_analytics.rb +14 -0
  80. data/db/migrate/20261013130000_add_office_location_to_hr_lite_holidays.rb +12 -0
  81. data/db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb +6 -0
  82. data/db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb +9 -0
  83. data/db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb +9 -0
  84. data/lib/hr_lite/configuration.rb +13 -1
  85. data/lib/hr_lite/notifications.rb +9 -4
  86. data/lib/hr_lite/permissions.rb +1 -0
  87. data/lib/hr_lite/role_seeds.rb +4 -2
  88. data/lib/hr_lite/seeds.rb +1 -1
  89. data/lib/hr_lite/version.rb +1 -1
  90. metadata +44 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc6f0b0eab85d612fdadaffc907d6b8d64eda04ed9fba81c5611ef37266916cf
4
- data.tar.gz: 81a344480832392ca76230a465597bc085e6e155b063f7f823fc2348de9891cf
3
+ metadata.gz: 874b64768a50e28c793779db9c5ff36e35f0b266cb318f34ca94df74e4de4bd7
4
+ data.tar.gz: f959b7a94d511c5ebf1f712cb29c1261df6207dc0c9a3bc6938dffad170900f8
5
5
  SHA512:
6
- metadata.gz: e9dba6adb15c59d153c44e29e5bbba3e24df80b6c2f8caaa4383bc49b98ba052a30780a51fe70bbfaf989188b8642bdb3de096214595dbeb511c3da6faf4a642
7
- data.tar.gz: b8c776a6b48436ba780f09f948a5233d7372dd5abb5ed540c10507cb26de5a0cd7ec4f0f1a7cd40c528c5af18c0c8bc49e0d6959418d3b0c0b2a769450703011
6
+ metadata.gz: 482b65659ee54aec1e96bb34972028e428839f09925c7278cc97d3652a5ac54a622fcceb43575810561e1fd4dcb66037e2083d7f8856ae29ba71404c976d4dc6
7
+ data.tar.gz: 8d3540851748ca4677db0503a4f47e140bc7e27aa60ab7816280acfc317ae3fbc9ed0f7028719edf6a209932743aa24fb13ea94036b9f120296b0e576a089bac
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.35.0] - 2026-10-13
11
+
12
+ - 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).
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - Do not disturb: while someone is Busy or in a meeting, their HR emails wait until it ends; the bell still lands.
17
+ - 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.
18
+ - 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.
19
+ - Tap a chart segment on a phone to see its details (no hover there); segments are keyboard-focusable.
20
+
21
+ ## [0.34.0] - 2026-10-13
22
+
23
+ - 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.
24
+
25
+ ## [0.33.0] - 2026-10-13
26
+
27
+ - 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.
28
+
29
+ ## [0.32.0] - 2026-10-13
30
+
31
+ - 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.
32
+ - 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.
33
+ - 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.
34
+ - Permanent WFH now means standing WFH approval for every working day; check-in still has to be at an approved home location.
35
+
10
36
  ## [0.31.0] - 2026-10-13
11
37
 
12
38
  - 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
+ })();
@@ -569,3 +569,50 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
569
569
  /* Notifications link, shown only when the host configures one. */
570
570
  .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
571
  .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; }
572
+
573
+ /* Work-mode icons. A small dot marks a mode the system set. */
574
+ .hrl-modes { display: inline-flex; gap: .25rem; vertical-align: middle; }
575
+ .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; }
576
+ .hrl-mode--office, .hrl-mode--wfh { background: var(--hrl-ok-bg); color: var(--hrl-ok); }
577
+ .hrl-mode--travelling, .hrl-mode--outside_meeting, .hrl-mode--meeting { background: var(--hrl-info-bg); color: var(--hrl-accent); }
578
+ .hrl-mode--lunch, .hrl-mode--short_break, .hrl-mode--busy, .hrl-mode--out_of_office { background: var(--hrl-warn-bg); color: var(--hrl-warn); }
579
+ .hrl-mode--sick_leave, .hrl-mode--on_leave, .hrl-mode--holiday { background: var(--hrl-warn-bg); color: var(--hrl-warn); }
580
+ .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); }
581
+ .hrl-mode-switch { display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: var(--hrl-gap-target); margin-top: .5rem; }
582
+ .hrl-mode-switch > details { grid-column: 1 / -1; }
583
+
584
+ /* Work-time charts: one colour per mode, server-drawn SVG. */
585
+ :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; }
586
+ .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); }
587
+ .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); }
588
+ .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); }
589
+ .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); }
590
+ .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); }
591
+ .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); }
592
+ .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); }
593
+ .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); }
594
+ .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); }
595
+ .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); }
596
+ .hrl-chart-row { display: grid; gap: var(--hrl-gap-target); }
597
+ @media (min-width: 481px) { .hrl-chart-row { grid-template-columns: 10rem 1fr; align-items: center; } }
598
+ .hrl-donut { width: 10rem; max-width: 100%; transform: rotate(0deg); }
599
+ .hrl-donut__track { stroke: var(--hrl-bg); }
600
+ .hrl-swatch { display: inline-block; width: .7rem; height: .7rem; border-radius: var(--hrl-radius-pill); vertical-align: middle; }
601
+ .hrl-chart-scroll { overflow-x: auto; }
602
+ .hrl-bars { width: 100%; min-width: 16rem; height: 10rem; }
603
+ .hrl-bars__label { font-size: 5px; fill: var(--hrl-muted); }
604
+ .hrl-timeline { width: 100%; height: 2rem; background: var(--hrl-bg); border-radius: var(--hrl-radius-sm); }
605
+ .hrl-timeline__auto { stroke: var(--hrl-card); stroke-width: .3; stroke-dasharray: .6 .4; }
606
+ .hrl-timeline__axis { display: flex; justify-content: space-between; }
607
+ .hrl-timeline__list { margin: .5rem 0 0; padding-left: 1.2rem; display: grid; gap: .25rem; }
608
+ .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; }
609
+ [data-hrl-tip]:focus { outline: 2px solid var(--hrl-accent); outline-offset: 1px; }
610
+ .hrl-custom-status { display: inline-flex; gap: .3rem; align-items: center; overflow-wrap: anywhere; }
611
+ .hrl-emoji-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(2.75rem, 1fr)); gap: .25rem; margin-top: .4rem; }
612
+ .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; }
613
+ /* Today's check-ins: one initials badge per person; hover/tap shows times. */
614
+ .hrl-checkins + .hrl-checkins { margin-top: .6rem; }
615
+ .hrl-checkins__people { display: flex; flex-wrap: wrap; gap: var(--hrl-gap-target); margin: .4rem 0 0; padding: 0; list-style: none; }
616
+ .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; }
617
+ .hrl-person--ok { border-color: var(--hrl-ok); background: var(--hrl-ok-bg); color: var(--hrl-ok); }
618
+ .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
- calendar = WorkingCalendar.new(@monday..(@monday + 4))
29
- days = (@monday..[ @monday + 4, Date.current - 1 ].min).select { |d| calendar.working_day?(d) }
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
- records = AttendanceRecord.where(user_id: employees.map(&:id), date: days).group_by(&:user_id)
34
- on_leave = days.index_with { |d| LeaveRequest.active_on(d).where(half_day: false).pluck(:user_id) }
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
- worked = records.fetch(user.id, []).filter_map(&:worked_duration)
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. Reports
38
- # per-line problems, skips duplicates silently.
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
- def announce(title)
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
- result = AttendancePuncher.call(user: hr_current_user, kind: kind, **punch_params.to_h.symbolize_keys)
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
- @calendar = WorkingCalendar.new(range)
10
- @holidays = Holiday.in_range(range).order(:date).group_by(&:date)
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.new((Date.current - 14)..Date.current)
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
- @holidays = Holiday.where(date: Date.new(@year, 1, 1)..Date.new(@year, 12, 31)).order(:date)
7
- @next_holiday_id = Holiday.company_wide.upcoming.pick(:id)
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
@@ -0,0 +1,40 @@
1
+ module HrLite
2
+ # Employee switches their own mode. hr_current_user is the only subject.
3
+ class WorkModesController < ApplicationController
4
+ before_action { raise ActiveRecord::RecordNotFound unless HrLite.config.office_geofence }
5
+
6
+ def create
7
+ result = WorkModeSwitch.call(user: hr_current_user, mode: params[:mode], source: "employee", actor: hr_current_user,
8
+ params: params.permit(:title, :client, :destination, :reason, :minutes, :until, :category, :crm_ref))
9
+ if result.ok?
10
+ redirect_to attendance_path, notice: "You are now: #{result.event.label}."
11
+ else
12
+ redirect_to attendance_path, alert: result.error
13
+ end
14
+ end
15
+
16
+ def status
17
+ result = WorkModeSwitch.set_status(user: hr_current_user, text: params[:text], emoji: params[:emoji], clear_after: params[:clear_after])
18
+ result.ok? ? redirect_to(attendance_path, notice: "Status set.") : redirect_to(attendance_path, alert: result.error)
19
+ end
20
+
21
+ def clear_status
22
+ WorkModeEvent.open.where(user_id: hr_current_user.id, layer: "status").find_each { |e| e.close!("ended") }
23
+ redirect_to attendance_path, notice: "Status cleared."
24
+ end
25
+
26
+ # Lead type-ahead for the meeting forms; the host decides who sees what.
27
+ def crm_search
28
+ query = params[:q].to_s.strip
29
+ hook = HrLite.config.crm_search
30
+ leads = hook && query.length >= 2 ? Array(hook.call(hr_current_user, query)).first(20) : []
31
+ render json: leads.map { |l| { id: l[:id].to_s, label: l[:label].to_s } }
32
+ end
33
+
34
+ # Ends the current break / busy / meeting early.
35
+ def end_overlay
36
+ WorkModeEvent.open.overlay.where(user_id: hr_current_user.id).find_each { |e| e.close!("ended") }
37
+ redirect_to attendance_path, notice: "Back to work."
38
+ end
39
+ end
40
+ end