hr_lite 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/app/assets/javascripts/hr_lite/photo_crop.js +71 -0
- data/app/assets/javascripts/hr_lite/share.js +34 -0
- data/app/assets/javascripts/hr_lite/vendor/html_to_image.js +2 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +1 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +29 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +2 -1
- data/app/controllers/hr_lite/attendance_controller.rb +23 -0
- data/app/jobs/hr_lite/attendance_close_job.rb +12 -0
- data/app/models/hr_lite/attendance_penalty.rb +30 -0
- data/app/models/hr_lite/attendance_record.rb +12 -2
- data/app/models/hr_lite/attendance_slot.rb +38 -0
- data/app/models/hr_lite/employee_profile.rb +11 -0
- data/app/services/hr_lite/attendance_puncher.rb +20 -0
- data/app/views/hr_lite/admin/attendances/_slot.html.erb +24 -0
- data/app/views/hr_lite/admin/attendances/show.html.erb +5 -0
- data/app/views/hr_lite/admin/employees/_form.html.erb +11 -0
- data/app/views/hr_lite/attendance/_penalties.html.erb +25 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +3 -0
- data/app/views/hr_lite/attendance/_slot.html.erb +33 -0
- data/app/views/hr_lite/attendance/show.html.erb +4 -0
- data/app/views/hr_lite/id_cards/show.html.erb +8 -2
- data/app/views/hr_lite/team/show.html.erb +2 -1
- data/config/routes.rb +2 -0
- data/db/migrate/20261013090000_create_hr_lite_attendance_slots.rb +16 -0
- data/db/migrate/20261013090100_create_hr_lite_attendance_penalties.rb +19 -0
- data/db/migrate/20261013090200_add_buffer_to_hr_lite_employee_profiles.rb +7 -0
- data/lib/hr_lite/configuration.rb +4 -1
- data/lib/hr_lite/version.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f6f41c24e52098ad42a4af7b5827a3fb2fc09a56e5d46d2194122506faa088b
|
|
4
|
+
data.tar.gz: 6c2323f7e9db6bf95db8f28fac7261b7a670dbdb486b902e30809f5331c95a58
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c62ee058dcc94b38c24976b2120d7adcb4df515796c92ccb6201b655ea5ee1e54b5b5b8e45c83c34ec811463c50fbc35c651c4d1ae0940317cdb76bf4773bfbc
|
|
7
|
+
data.tar.gz: 5d130419f330d6aa715f4c43d36e8d7acf10be7df700930514666600659ab2cb241b70311df969aefbc1ab3e879729680b85b4ea9740311b78b305c4a82a0dc4
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.30.0] - 2026-10-13
|
|
11
|
+
|
|
12
|
+
- Attendance slots (`config.attendance_slots = true`, off by default). Employees pick a check-in slot (09:00, 09:30, 10:00 or 10:30; checkout 8h30 later) for today, this week or this month. Saving locks it. HR sets or unlocks a slot from the employee's attendance page. No slot means 10:00.
|
|
13
|
+
- Late penalties. After 5 minutes of grace, each started half hour late adds 30 minutes to that day's checkout. A check-in after 11:00 also adds 1 hour to each of the next two working days, and one after 11:30 makes the day a half day (an approved regularization ticket lifts it). Leaving before the required checkout adds 30 minutes to the next working day (nightly close job). The punch card shows the slot, the required checkout, time left and any shortage, and My attendance lists the month's penalties. HR removes a penalty with a reason; the row is kept.
|
|
14
|
+
- Late buffer. HR gives an employee 1h or 2h with a reason (parental care, medical, health, other) on their profile. It delays the penalty clock. Colleagues see the category on the team board; the note is HR-only and encrypted.
|
|
15
|
+
|
|
16
|
+
## [0.29.0] - 2026-10-11
|
|
17
|
+
|
|
18
|
+
- ID card: Share button. Draws front and back side by side into one PNG and opens the phone's share sheet (WhatsApp, mail…); desktops download the PNG. The card photo is now served through the Active Storage proxy (same site), so it shows up in the image when storage redirects to a cloud bucket. Vendors html-to-image 1.11.11 (MIT).
|
|
19
|
+
- ID card: adjust the photo before uploading. The chosen picture previews inside the card's frame; drag to move it, slide to zoom, and the upload is exactly what the frame shows (a 560 px wide JPEG).
|
|
20
|
+
|
|
10
21
|
## [0.28.0] - 2026-10-10
|
|
11
22
|
|
|
12
23
|
- HR and leadership can cancel any approved or pending leave at any time, past dates included. A reason is required; it is kept as the decision note and in the audit log. Employees still cancel their own leave only before it starts. A cancelled past day with no punch shows as absent.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ID card photo: the chosen picture previews inside the card's own frame.
|
|
2
|
+
// Drag to move it, slide to zoom, and the upload is exactly what the frame shows.
|
|
3
|
+
(function () {
|
|
4
|
+
const input = document.querySelector("[data-hrl-photo-input]");
|
|
5
|
+
if (!input) return;
|
|
6
|
+
const frame = document.querySelector(".hrl-idcard__photo");
|
|
7
|
+
const zoom = document.querySelector("[data-hrl-photo-zoom]");
|
|
8
|
+
let img = null, base = 1, scale = 1, x = 0, y = 0, cropped = false;
|
|
9
|
+
|
|
10
|
+
const size = () => [ img.naturalWidth * base * scale, img.naturalHeight * base * scale ];
|
|
11
|
+
function place() {
|
|
12
|
+
const [ w, h ] = size();
|
|
13
|
+
x = Math.min(0, Math.max(frame.clientWidth - w, x));
|
|
14
|
+
y = Math.min(0, Math.max(frame.clientHeight - h, y));
|
|
15
|
+
img.style.cssText = `position:absolute;left:0;top:0;max-width:none;width:${w}px;height:${h}px;transform:translate(${x}px,${y}px);cursor:grab`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
input.addEventListener("change", function () {
|
|
19
|
+
const file = input.files[0];
|
|
20
|
+
if (!file) return;
|
|
21
|
+
img = new Image();
|
|
22
|
+
img.onload = function () {
|
|
23
|
+
base = Math.max(frame.clientWidth / img.naturalWidth, frame.clientHeight / img.naturalHeight);
|
|
24
|
+
scale = 1;
|
|
25
|
+
zoom.value = 1;
|
|
26
|
+
x = (frame.clientWidth - img.naturalWidth * base) / 2;
|
|
27
|
+
y = (frame.clientHeight - img.naturalHeight * base) / 2;
|
|
28
|
+
frame.replaceChildren(img);
|
|
29
|
+
frame.style.touchAction = "none";
|
|
30
|
+
zoom.closest("[data-hrl-photo-adjust]").hidden = false;
|
|
31
|
+
place();
|
|
32
|
+
};
|
|
33
|
+
img.src = URL.createObjectURL(file);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
zoom.addEventListener("input", function () {
|
|
37
|
+
const ratio = zoom.value / scale, cx = frame.clientWidth / 2, cy = frame.clientHeight / 2;
|
|
38
|
+
x = cx - (cx - x) * ratio;
|
|
39
|
+
y = cy - (cy - y) * ratio;
|
|
40
|
+
scale = Number(zoom.value);
|
|
41
|
+
place();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
frame.addEventListener("pointerdown", function (down) {
|
|
45
|
+
if (!img) return;
|
|
46
|
+
frame.setPointerCapture(down.pointerId);
|
|
47
|
+
let lastX = down.clientX, lastY = down.clientY;
|
|
48
|
+
const move = function (event) {
|
|
49
|
+
x += event.clientX - lastX; y += event.clientY - lastY;
|
|
50
|
+
lastX = event.clientX; lastY = event.clientY;
|
|
51
|
+
place();
|
|
52
|
+
};
|
|
53
|
+
frame.addEventListener("pointermove", move);
|
|
54
|
+
frame.addEventListener("pointerup", () => frame.removeEventListener("pointermove", move), { once: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
input.form.addEventListener("submit", function (event) {
|
|
58
|
+
if (!img || cropped) return;
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
const k = base * scale, canvas = document.createElement("canvas");
|
|
61
|
+
canvas.width = 560; canvas.height = Math.round(560 * frame.clientHeight / frame.clientWidth);
|
|
62
|
+
canvas.getContext("2d").drawImage(img, -x / k, -y / k, frame.clientWidth / k, frame.clientHeight / k, 0, 0, canvas.width, canvas.height);
|
|
63
|
+
canvas.toBlob(function (blob) {
|
|
64
|
+
const files = new DataTransfer();
|
|
65
|
+
files.items.add(new File([ blob ], "photo.jpg", { type: "image/jpeg" }));
|
|
66
|
+
input.files = files.files;
|
|
67
|
+
cropped = true;
|
|
68
|
+
input.form.requestSubmit();
|
|
69
|
+
}, "image/jpeg", 0.9);
|
|
70
|
+
});
|
|
71
|
+
})();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The ID card's Share button: draws both faces into one PNG and hands it to
|
|
2
|
+
// the phone's share sheet (WhatsApp, mail…). Desktops download it instead.
|
|
3
|
+
document.addEventListener("click", async function (event) {
|
|
4
|
+
const button = event.target.closest("[data-hrl-share]");
|
|
5
|
+
if (!button) return;
|
|
6
|
+
|
|
7
|
+
button.disabled = true;
|
|
8
|
+
try {
|
|
9
|
+
// Front and back side by side in one picture, even where the page stacks them.
|
|
10
|
+
const faces = document.querySelector(".hrl-idcard-faces");
|
|
11
|
+
const card = faces.querySelector(".hrl-idcard").getBoundingClientRect();
|
|
12
|
+
const pad = 24;
|
|
13
|
+
const blob = await window.htmlToImage.toBlob(faces, {
|
|
14
|
+
width: card.width * 2 + pad * 3,
|
|
15
|
+
height: card.height + pad * 2,
|
|
16
|
+
style: { flexWrap: "nowrap", gap: `${pad}px`, padding: `${pad}px`, margin: "0", justifyContent: "flex-start" },
|
|
17
|
+
pixelRatio: 2,
|
|
18
|
+
backgroundColor: "#ffffff",
|
|
19
|
+
filter: (node) => !node.classList?.contains("hrl-idcard__label")
|
|
20
|
+
});
|
|
21
|
+
const file = new File([blob], button.dataset.hrlShare, { type: "image/png" });
|
|
22
|
+
if (navigator.canShare?.({ files: [file] })) {
|
|
23
|
+
await navigator.share({ files: [file], title: "ID card" });
|
|
24
|
+
} else {
|
|
25
|
+
const link = Object.assign(document.createElement("a"), { href: URL.createObjectURL(file), download: file.name });
|
|
26
|
+
link.click();
|
|
27
|
+
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.name !== "AbortError") alert("Could not make the image. Use Print instead.");
|
|
31
|
+
} finally {
|
|
32
|
+
button.disabled = false;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/* html-to-image 1.11.11 — MIT, (c) bubkoo — https://github.com/bubkoo/html-to-image */
|
|
2
|
+
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).htmlToImage={})}(this,(function(t){"use strict";function e(t,e,n,r){return new(n||(n=Promise))((function(i,o){function u(t){try{a(r.next(t))}catch(t){o(t)}}function c(t){try{a(r.throw(t))}catch(t){o(t)}}function a(t){var e;t.done?i(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(u,c)}a((r=r.apply(t,e||[])).next())}))}function n(t,e){var n,r,i,o,u={label:0,sent:function(){if(1&i[0])throw i[1];return i[1]},trys:[],ops:[]};return o={next:c(0),throw:c(1),return:c(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function c(c){return function(a){return function(c){if(n)throw new TypeError("Generator is already executing.");for(;o&&(o=0,c[0]&&(u=0)),u;)try{if(n=1,r&&(i=2&c[0]?r.return:c[0]?r.throw||((i=r.return)&&i.call(r),0):r.next)&&!(i=i.call(r,c[1])).done)return i;switch(r=0,i&&(c=[2&c[0],i.value]),c[0]){case 0:case 1:i=c;break;case 4:return u.label++,{value:c[1],done:!1};case 5:u.label++,r=c[1],c=[0];continue;case 7:c=u.ops.pop(),u.trys.pop();continue;default:if(!(i=u.trys,(i=i.length>0&&i[i.length-1])||6!==c[0]&&2!==c[0])){u=0;continue}if(3===c[0]&&(!i||c[1]>i[0]&&c[1]<i[3])){u.label=c[1];break}if(6===c[0]&&u.label<i[1]){u.label=i[1],i=c;break}if(i&&u.label<i[2]){u.label=i[2],u.ops.push(c);break}i[2]&&u.ops.pop(),u.trys.pop();continue}c=e.call(t,u)}catch(t){c=[6,t],r=0}finally{n=i=0}if(5&c[0])throw c[1];return{value:c[0]?c[1]:void 0,done:!0}}([c,a])}}}var r,i=(r=0,function(){return r+=1,"u".concat("0000".concat((Math.random()*Math.pow(36,4)<<0).toString(36)).slice(-4)).concat(r)});function o(t){for(var e=[],n=0,r=t.length;n<r;n++)e.push(t[n]);return e}function u(t,e){var n=(t.ownerDocument.defaultView||window).getComputedStyle(t).getPropertyValue(e);return n?parseFloat(n.replace("px","")):0}function c(t,e){void 0===e&&(e={});var n,r,i,o=e.width||(r=u(n=t,"border-left-width"),i=u(n,"border-right-width"),n.clientWidth+r+i),c=e.height||function(t){var e=u(t,"border-top-width"),n=u(t,"border-bottom-width");return t.clientHeight+e+n}(t);return{width:o,height:c}}var a=16384;function s(t,e){return void 0===e&&(e={}),t.toBlob?new Promise((function(n){t.toBlob(n,e.type?e.type:"image/png",e.quality?e.quality:1)})):new Promise((function(n){for(var r=window.atob(t.toDataURL(e.type?e.type:void 0,e.quality?e.quality:void 0).split(",")[1]),i=r.length,o=new Uint8Array(i),u=0;u<i;u+=1)o[u]=r.charCodeAt(u);n(new Blob([o],{type:e.type?e.type:"image/png"}))}))}function l(t){return new Promise((function(e,n){var r=new Image;r.decode=function(){return e(r)},r.onload=function(){return e(r)},r.onerror=n,r.crossOrigin="anonymous",r.decoding="async",r.src=t}))}function f(t){return e(this,void 0,void 0,(function(){return n(this,(function(e){return[2,Promise.resolve().then((function(){return(new XMLSerializer).serializeToString(t)})).then(encodeURIComponent).then((function(t){return"data:image/svg+xml;charset=utf-8,".concat(t)}))]}))}))}function h(t,r,i){return e(this,void 0,void 0,(function(){var e,o,u;return n(this,(function(n){return e="http://www.w3.org/2000/svg",o=document.createElementNS(e,"svg"),u=document.createElementNS(e,"foreignObject"),o.setAttribute("width","".concat(r)),o.setAttribute("height","".concat(i)),o.setAttribute("viewBox","0 0 ".concat(r," ").concat(i)),u.setAttribute("width","100%"),u.setAttribute("height","100%"),u.setAttribute("x","0"),u.setAttribute("y","0"),u.setAttribute("externalResourcesRequired","true"),o.appendChild(u),u.appendChild(t),[2,f(o)]}))}))}var d=function(t,e){if(t instanceof e)return!0;var n=Object.getPrototypeOf(t);return null!==n&&(n.constructor.name===e.name||d(n,e))};function v(t,e,n){var r=".".concat(t,":").concat(e),i=n.cssText?function(t){var e=t.getPropertyValue("content");return"".concat(t.cssText," content: '").concat(e.replace(/'|"/g,""),"';")}(n):function(t){return o(t).map((function(e){var n=t.getPropertyValue(e),r=t.getPropertyPriority(e);return"".concat(e,": ").concat(n).concat(r?" !important":"",";")})).join(" ")}(n);return document.createTextNode("".concat(r,"{").concat(i,"}"))}function p(t,e,n){var r=window.getComputedStyle(t,n),o=r.getPropertyValue("content");if(""!==o&&"none"!==o){var u=i();try{e.className="".concat(e.className," ").concat(u)}catch(t){return}var c=document.createElement("style");c.appendChild(v(u,n,r)),e.appendChild(c)}}var g="application/font-woff",m="image/jpeg",w={woff:g,woff2:g,ttf:"application/font-truetype",eot:"application/vnd.ms-fontobject",png:"image/png",jpg:m,jpeg:m,gif:"image/gif",tiff:"image/tiff",svg:"image/svg+xml",webp:"image/webp"};function b(t){var e=function(t){var e=/\.([^./]*?)$/g.exec(t);return e?e[1]:""}(t).toLowerCase();return w[e]||""}function y(t){return-1!==t.search(/^(data:)/)}function x(t,e){return"data:".concat(e,";base64,").concat(t)}function S(t,r,i){return e(this,void 0,void 0,(function(){var e,o;return n(this,(function(n){switch(n.label){case 0:return[4,fetch(t,r)];case 1:if(404===(e=n.sent()).status)throw new Error('Resource "'.concat(e.url,'" not found'));return[4,e.blob()];case 2:return o=n.sent(),[2,new Promise((function(t,n){var r=new FileReader;r.onerror=n,r.onloadend=function(){try{t(i({res:e,result:r.result}))}catch(t){n(t)}},r.readAsDataURL(o)}))]}}))}))}var E={};function C(t,r,i){return e(this,void 0,void 0,(function(){var e,o,u,c,a;return n(this,(function(n){switch(n.label){case 0:if(e=function(t,e,n){var r=t.replace(/\?.*/,"");return n&&(r=t),/ttf|otf|eot|woff2?/i.test(r)&&(r=r.replace(/.*\//,"")),e?"[".concat(e,"]").concat(r):r}(t,r,i.includeQueryParams),null!=E[e])return[2,E[e]];i.cacheBust&&(t+=(/\?/.test(t)?"&":"?")+(new Date).getTime()),n.label=1;case 1:return n.trys.push([1,3,,4]),[4,S(t,i.fetchRequestInit,(function(t){var e=t.res,n=t.result;return r||(r=e.headers.get("Content-Type")||""),function(t){return t.split(/,/)[1]}(n)}))];case 2:return u=n.sent(),o=x(u,r),[3,4];case 3:return c=n.sent(),o=i.imagePlaceholder||"",a="Failed to fetch resource: ".concat(t),c&&(a="string"==typeof c?c:c.message),a&&console.warn(a),[3,4];case 4:return E[e]=o,[2,o]}}))}))}function P(t){return e(this,void 0,void 0,(function(){var e;return n(this,(function(n){return"data:,"===(e=t.toDataURL())?[2,t.cloneNode(!1)]:[2,l(e)]}))}))}function R(t,r){return e(this,void 0,void 0,(function(){var e,i,o,u;return n(this,(function(n){switch(n.label){case 0:return t.currentSrc?(e=document.createElement("canvas"),i=e.getContext("2d"),e.width=t.clientWidth,e.height=t.clientHeight,null==i||i.drawImage(t,0,0,e.width,e.height),[2,l(e.toDataURL())]):(o=t.poster,u=b(o),[4,C(o,u,r)]);case 1:return[2,l(n.sent())]}}))}))}function T(t){var r;return e(this,void 0,void 0,(function(){return n(this,(function(e){switch(e.label){case 0:return e.trys.push([0,3,,4]),(null===(r=null==t?void 0:t.contentDocument)||void 0===r?void 0:r.body)?[4,L(t.contentDocument.body,{},!0)]:[3,2];case 1:return[2,e.sent()];case 2:return[3,4];case 3:return e.sent(),[3,4];case 4:return[2,t.cloneNode(!1)]}}))}))}function A(t,e){return d(e,Element)&&(function(t,e){var n=e.style;if(n){var r=window.getComputedStyle(t);r.cssText?(n.cssText=r.cssText,n.transformOrigin=r.transformOrigin):o(r).forEach((function(i){var o=r.getPropertyValue(i);if("font-size"===i&&o.endsWith("px")){var u=Math.floor(parseFloat(o.substring(0,o.length-2)))-.1;o="".concat(u,"px")}d(t,HTMLIFrameElement)&&"display"===i&&"inline"===o&&(o="block"),"d"===i&&e.getAttribute("d")&&(o="path(".concat(e.getAttribute("d"),")")),n.setProperty(i,o,r.getPropertyPriority(i))}))}}(t,e),function(t,e){p(t,e,":before"),p(t,e,":after")}(t,e),function(t,e){d(t,HTMLTextAreaElement)&&(e.innerHTML=t.value),d(t,HTMLInputElement)&&e.setAttribute("value",t.value)}(t,e),function(t,e){if(d(t,HTMLSelectElement)){var n=e,r=Array.from(n.children).find((function(e){return t.value===e.getAttribute("value")}));r&&r.setAttribute("selected","")}}(t,e)),e}function L(t,r,i){return e(this,void 0,void 0,(function(){return n(this,(function(u){return i||!r.filter||r.filter(t)?[2,Promise.resolve(t).then((function(t){return function(t,r){return e(this,void 0,void 0,(function(){return n(this,(function(e){return d(t,HTMLCanvasElement)?[2,P(t)]:d(t,HTMLVideoElement)?[2,R(t,r)]:d(t,HTMLIFrameElement)?[2,T(t)]:[2,t.cloneNode(!1)]}))}))}(t,r)})).then((function(i){return function(t,r,i){var u,c;return e(this,void 0,void 0,(function(){var e;return n(this,(function(n){switch(n.label){case 0:return e=[],0===(e=null!=(a=t).tagName&&"SLOT"===a.tagName.toUpperCase()&&t.assignedNodes?o(t.assignedNodes()):d(t,HTMLIFrameElement)&&(null===(u=t.contentDocument)||void 0===u?void 0:u.body)?o(t.contentDocument.body.childNodes):o((null!==(c=t.shadowRoot)&&void 0!==c?c:t).childNodes)).length||d(t,HTMLVideoElement)?[2,r]:[4,e.reduce((function(t,e){return t.then((function(){return L(e,i)})).then((function(t){t&&r.appendChild(t)}))}),Promise.resolve())];case 1:return n.sent(),[2,r]}var a}))}))}(t,i,r)})).then((function(e){return A(t,e)})).then((function(t){return function(t,r){return e(this,void 0,void 0,(function(){var e,i,o,u,c,a,s,l,f,h,d,v,p;return n(this,(function(n){switch(n.label){case 0:if(0===(e=t.querySelectorAll?t.querySelectorAll("use"):[]).length)return[2,t];i={},p=0,n.label=1;case 1:return p<e.length?(o=e[p],(u=o.getAttribute("xlink:href"))?(c=t.querySelector(u),a=document.querySelector(u),c||!a||i[u]?[3,3]:(s=i,l=u,[4,L(a,r,!0)])):[3,3]):[3,4];case 2:s[l]=n.sent(),n.label=3;case 3:return p++,[3,1];case 4:if((f=Object.values(i)).length){for(h="http://www.w3.org/1999/xhtml",(d=document.createElementNS(h,"svg")).setAttribute("xmlns",h),d.style.position="absolute",d.style.width="0",d.style.height="0",d.style.overflow="hidden",d.style.display="none",v=document.createElementNS(h,"defs"),d.appendChild(v),p=0;p<f.length;p++)v.appendChild(f[p]);t.appendChild(d)}return[2,t]}}))}))}(t,r)}))]:[2,null]}))}))}var N=/url\((['"]?)([^'"]+?)\1\)/g,k=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,I=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function D(t,r,i,o,u){return e(this,void 0,void 0,(function(){var e,c,a,s;return n(this,(function(n){switch(n.label){case 0:return n.trys.push([0,5,,6]),e=i?function(t,e){if(t.match(/^[a-z]+:\/\//i))return t;if(t.match(/^\/\//))return window.location.protocol+t;if(t.match(/^[a-z]+:/i))return t;var n=document.implementation.createHTMLDocument(),r=n.createElement("base"),i=n.createElement("a");return n.head.appendChild(r),n.body.appendChild(i),e&&(r.href=e),i.href=t,i.href}(r,i):r,c=b(r),a=void 0,u?[4,u(e)]:[3,2];case 1:return s=n.sent(),a=x(s,c),[3,4];case 2:return[4,C(e,c,o)];case 3:a=n.sent(),n.label=4;case 4:return[2,t.replace((l=r,f=l.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1"),new RegExp("(url\\(['\"]?)(".concat(f,")(['\"]?\\))"),"g")),"$1".concat(a,"$3"))];case 5:return n.sent(),[3,6];case 6:return[2,t]}var l,f}))}))}function M(t){return-1!==t.search(N)}function H(t,r,i){return e(this,void 0,void 0,(function(){var e,o;return n(this,(function(n){return M(t)?(e=function(t,e){var n=e.preferredFontFormat;return n?t.replace(I,(function(t){for(;;){var e=k.exec(t)||[],r=e[0],i=e[2];if(!i)return"";if(i===n)return"src: ".concat(r,";")}})):t}(t,i),o=function(t){var e=[];return t.replace(N,(function(t,n,r){return e.push(r),t})),e.filter((function(t){return!y(t)}))}(e),[2,o.reduce((function(t,e){return t.then((function(t){return D(t,e,r,i)}))}),Promise.resolve(e))]):[2,t]}))}))}function V(t,r,i){var o;return e(this,void 0,void 0,(function(){var e,u;return n(this,(function(n){switch(n.label){case 0:return(e=null===(o=r.style)||void 0===o?void 0:o.getPropertyValue(t))?[4,H(e,null,i)]:[3,2];case 1:return u=n.sent(),r.style.setProperty(t,u,r.style.getPropertyPriority(t)),[2,!0];case 2:return[2,!1]}}))}))}function F(t,r){return e(this,void 0,void 0,(function(){return n(this,(function(e){switch(e.label){case 0:return[4,V("background",t,r)];case 1:return e.sent()?[3,3]:[4,V("background-image",t,r)];case 2:e.sent(),e.label=3;case 3:return[4,V("mask",t,r)];case 4:return e.sent()?[3,6]:[4,V("mask-image",t,r)];case 5:e.sent(),e.label=6;case 6:return[2]}}))}))}function j(t,r){return e(this,void 0,void 0,(function(){var e,i,o;return n(this,(function(n){switch(n.label){case 0:return(e=d(t,HTMLImageElement))&&!y(t.src)||d(t,SVGImageElement)&&!y(t.href.baseVal)?[4,C(i=e?t.src:t.href.baseVal,b(i),r)]:[2];case 1:return o=n.sent(),[4,new Promise((function(n,r){t.onload=n,t.onerror=r;var i=t;i.decode&&(i.decode=n),"lazy"===i.loading&&(i.loading="eager"),e?(t.srcset="",t.src=o):t.href.baseVal=o}))];case 2:return n.sent(),[2]}}))}))}function q(t,r){return e(this,void 0,void 0,(function(){var e,i;return n(this,(function(n){switch(n.label){case 0:return e=o(t.childNodes),i=e.map((function(t){return U(t,r)})),[4,Promise.all(i).then((function(){return t}))];case 1:return n.sent(),[2]}}))}))}function U(t,r){return e(this,void 0,void 0,(function(){return n(this,(function(e){switch(e.label){case 0:return d(t,Element)?[4,F(t,r)]:[3,4];case 1:return e.sent(),[4,j(t,r)];case 2:return e.sent(),[4,q(t,r)];case 3:e.sent(),e.label=4;case 4:return[2]}}))}))}var O={};function B(t){return e(this,void 0,void 0,(function(){var e,r;return n(this,(function(n){switch(n.label){case 0:return null!=(e=O[t])?[2,e]:[4,fetch(t)];case 1:return[4,n.sent().text()];case 2:return r=n.sent(),e={url:t,cssText:r},O[t]=e,[2,e]}}))}))}function z(t,r){return e(this,void 0,void 0,(function(){var i,o,u,c,a=this;return n(this,(function(s){return i=t.cssText,o=/url\(["']?([^"')]+)["']?\)/g,u=i.match(/url\([^)]+\)/g)||[],c=u.map((function(u){return e(a,void 0,void 0,(function(){var e;return n(this,(function(n){return(e=u.replace(o,"$1")).startsWith("https://")||(e=new URL(e,t.url).href),[2,S(e,r.fetchRequestInit,(function(t){var e=t.result;return i=i.replace(u,"url(".concat(e,")")),[u,e]}))]}))}))})),[2,Promise.all(c).then((function(){return i}))]}))}))}function W(t){if(null==t)return[];for(var e=[],n=t.replace(/(\/\*[\s\S]*?\*\/)/gi,""),r=new RegExp("((@.*?keyframes [\\s\\S]*?){([\\s\\S]*?}\\s*?)})","gi");;){if(null===(u=r.exec(n)))break;e.push(u[0])}n=n.replace(r,"");for(var i=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,o=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");;){var u;if(null===(u=i.exec(n))){if(null===(u=o.exec(n)))break;i.lastIndex=o.lastIndex}else o.lastIndex=i.lastIndex;e.push(u[0])}return e}function $(t,r){return e(this,void 0,void 0,(function(){var e,i;return n(this,(function(n){return e=[],i=[],t.forEach((function(e){if("cssRules"in e)try{o(e.cssRules||[]).forEach((function(t,n){if(t.type===CSSRule.IMPORT_RULE){var o=n+1,u=B(t.href).then((function(t){return z(t,r)})).then((function(t){return W(t).forEach((function(t){try{e.insertRule(t,t.startsWith("@import")?o+=1:e.cssRules.length)}catch(e){console.error("Error inserting rule from remote css",{rule:t,error:e})}}))})).catch((function(t){console.error("Error loading remote css",t.toString())}));i.push(u)}}))}catch(o){var n=t.find((function(t){return null==t.href}))||document.styleSheets[0];null!=e.href&&i.push(B(e.href).then((function(t){return z(t,r)})).then((function(t){return W(t).forEach((function(t){n.insertRule(t,e.cssRules.length)}))})).catch((function(t){console.error("Error loading remote stylesheet",t)}))),console.error("Error inlining remote css file",o)}})),[2,Promise.all(i).then((function(){return t.forEach((function(t){if("cssRules"in t)try{o(t.cssRules||[]).forEach((function(t){e.push(t)}))}catch(e){console.error("Error while reading CSS rules from ".concat(t.href),e)}})),e}))]}))}))}function _(t){return t.filter((function(t){return t.type===CSSRule.FONT_FACE_RULE})).filter((function(t){return M(t.style.getPropertyValue("src"))}))}function G(t,r){return e(this,void 0,void 0,(function(){return n(this,(function(e){switch(e.label){case 0:if(null==t.ownerDocument)throw new Error("Provided element is not within a Document");return[4,$(o(t.ownerDocument.styleSheets),r)];case 1:return[2,_(e.sent())]}}))}))}function J(t,r){return e(this,void 0,void 0,(function(){var e;return n(this,(function(n){switch(n.label){case 0:return[4,G(t,r)];case 1:return e=n.sent(),[4,Promise.all(e.map((function(t){var e=t.parentStyleSheet?t.parentStyleSheet.href:null;return H(t.cssText,e,r)})))];case 2:return[2,n.sent().join("\n")]}}))}))}function Q(t,r){return e(this,void 0,void 0,(function(){var e,i,o,u,c;return n(this,(function(n){switch(n.label){case 0:return null==r.fontEmbedCSS?[3,1]:(i=r.fontEmbedCSS,[3,5]);case 1:return r.skipFonts?(o=null,[3,4]):[3,2];case 2:return[4,J(t,r)];case 3:o=n.sent(),n.label=4;case 4:i=o,n.label=5;case 5:return(e=i)&&(u=document.createElement("style"),c=document.createTextNode(e),u.appendChild(c),t.firstChild?t.insertBefore(u,t.firstChild):t.appendChild(u)),[2]}}))}))}function X(t,r){return void 0===r&&(r={}),e(this,void 0,void 0,(function(){var e,i,o,u;return n(this,(function(n){switch(n.label){case 0:return e=c(t,r),i=e.width,o=e.height,[4,L(t,r,!0)];case 1:return[4,Q(u=n.sent(),r)];case 2:return n.sent(),[4,U(u,r)];case 3:return n.sent(),function(t,e){var n=t.style;e.backgroundColor&&(n.backgroundColor=e.backgroundColor),e.width&&(n.width="".concat(e.width,"px")),e.height&&(n.height="".concat(e.height,"px"));var r=e.style;null!=r&&Object.keys(r).forEach((function(t){n[t]=r[t]}))}(u,r),[4,h(u,i,o)];case 4:return[2,n.sent()]}}))}))}function K(t,r){return void 0===r&&(r={}),e(this,void 0,void 0,(function(){var e,i,o,u,s,f,h,d,v;return n(this,(function(n){switch(n.label){case 0:return e=c(t,r),i=e.width,o=e.height,[4,X(t,r)];case 1:return[4,l(n.sent())];case 2:return u=n.sent(),s=document.createElement("canvas"),f=s.getContext("2d"),h=r.pixelRatio||function(){var t,e;try{e=process}catch(t){}var n=e&&e.env?e.env.devicePixelRatio:null;return n&&(t=parseInt(n,10),Number.isNaN(t)&&(t=1)),t||window.devicePixelRatio||1}(),d=r.canvasWidth||i,v=r.canvasHeight||o,s.width=d*h,s.height=v*h,r.skipAutoScale||function(t){(t.width>a||t.height>a)&&(t.width>a&&t.height>a?t.width>t.height?(t.height*=a/t.width,t.width=a):(t.width*=a/t.height,t.height=a):t.width>a?(t.height*=a/t.width,t.width=a):(t.width*=a/t.height,t.height=a))}(s),s.style.width="".concat(d),s.style.height="".concat(v),r.backgroundColor&&(f.fillStyle=r.backgroundColor,f.fillRect(0,0,s.width,s.height)),f.drawImage(u,0,0,s.width,s.height),[2,s]}}))}))}t.getFontEmbedCSS=function(t,r){return void 0===r&&(r={}),e(this,void 0,void 0,(function(){return n(this,(function(e){return[2,J(t,r)]}))}))},t.toBlob=function(t,r){return void 0===r&&(r={}),e(this,void 0,void 0,(function(){return n(this,(function(e){switch(e.label){case 0:return[4,K(t,r)];case 1:return[4,s(e.sent())];case 2:return[2,e.sent()]}}))}))},t.toCanvas=K,t.toJpeg=function(t,r){return void 0===r&&(r={}),e(this,void 0,void 0,(function(){return n(this,(function(e){switch(e.label){case 0:return[4,K(t,r)];case 1:return[2,e.sent().toDataURL("image/jpeg",r.quality||1)]}}))}))},t.toPixelData=function(t,r){return void 0===r&&(r={}),e(this,void 0,void 0,(function(){var e,i,o,u;return n(this,(function(n){switch(n.label){case 0:return e=c(t,r),i=e.width,o=e.height,[4,K(t,r)];case 1:return u=n.sent(),[2,u.getContext("2d").getImageData(0,0,i,o).data]}}))}))},t.toPng=function(t,r){return void 0===r&&(r={}),e(this,void 0,void 0,(function(){return n(this,(function(e){switch(e.label){case 0:return[4,K(t,r)];case 1:return[2,e.sent().toDataURL()]}}))}))},t.toSvg=X}));
|
|
@@ -525,6 +525,7 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
|
|
|
525
525
|
display: flex; align-items: center; justify-content: center; font: 700 calc(34 * var(--u)) var(--hrl-card-display, var(--hrl-font)); color: #64748B;
|
|
526
526
|
}
|
|
527
527
|
.hrl-idcard__photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
528
|
+
.hrl-photo-zoom { width: 100%; min-height: var(--hrl-target); accent-color: var(--hrl-accent); }
|
|
528
529
|
.hrl-idcard__who { margin-top: calc(124 * var(--u)); text-align: center; padding: 0 calc(24 * var(--u)); }
|
|
529
530
|
.hrl-idcard__name { font: 800 calc(25 * var(--u))/1.1 var(--hrl-card-display, var(--hrl-font)); letter-spacing: -.02em; text-wrap: balance; }
|
|
530
531
|
.hrl-idcard__role { margin-top: calc(6 * var(--u)); font: 500 calc(13 * var(--u))/1.3 var(--hrl-card-body, var(--hrl-font)); color: #334155; }
|
|
@@ -111,6 +111,35 @@ module HrLite
|
|
|
111
111
|
end
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# HR sets a locked slot from today for the period.
|
|
115
|
+
def slot
|
|
116
|
+
@employee = find_manageable_employee
|
|
117
|
+
period = params[:period].to_s
|
|
118
|
+
slot = AttendanceSlot.new(user_id: @employee.id, set_by_id: hr_current_user.id, period: period,
|
|
119
|
+
start_minute: params[:start_minute].to_i, starts_on: Date.current,
|
|
120
|
+
ends_on: AttendanceSlot.ends_on_for(period, Date.current))
|
|
121
|
+
flash_for = slot.save ? { notice: "Slot set to #{AttendanceSlot.label(slot.start_minute)}." } : { alert: slot.errors.full_messages.to_sentence }
|
|
122
|
+
redirect_to admin_attendance_path(@employee.id), flash_for
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Lets the employee pick a new slot themselves.
|
|
126
|
+
def unlock_slot
|
|
127
|
+
@employee = find_manageable_employee
|
|
128
|
+
AttendanceSlot.where(user_id: @employee.id, locked: true).where(ends_on: Date.current..).find_each { |s| s.update!(locked: false) }
|
|
129
|
+
redirect_to admin_attendance_path(@employee.id), notice: "Slot unlocked. #{hr_display_name(@employee)} can pick a new one."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def remove_penalty
|
|
133
|
+
@employee = find_manageable_employee
|
|
134
|
+
penalty = AttendancePenalty.active.where(user_id: @employee.id).find(params[:penalty_id])
|
|
135
|
+
reason = params[:removal_reason].to_s.strip
|
|
136
|
+
month = penalty.date.strftime("%Y-%m")
|
|
137
|
+
return redirect_to admin_attendance_path(@employee.id, month: month), alert: "A reason is required." if reason.blank?
|
|
138
|
+
|
|
139
|
+
penalty.update!(removed_at: Time.current, removed_by_id: hr_current_user.id, removal_reason: reason)
|
|
140
|
+
redirect_to admin_attendance_path(@employee.id, month: month), notice: "Penalty removed."
|
|
141
|
+
end
|
|
142
|
+
|
|
114
143
|
private
|
|
115
144
|
|
|
116
145
|
# Viewing somebody's month and rewriting their punches are different
|
|
@@ -115,7 +115,8 @@ module HrLite
|
|
|
115
115
|
:user_id, :manager_id, :designation, :date_of_birth, :date_of_joining,
|
|
116
116
|
:date_of_exit, :probation_until, :department, :work_location, :pan_number, :pf_uan, :esi_number,
|
|
117
117
|
:bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions,
|
|
118
|
-
:fy_opening_gross, :fy_opening_tds, :blood_group, :emergency_contact
|
|
118
|
+
:fy_opening_gross, :fy_opening_tds, :blood_group, :emergency_contact,
|
|
119
|
+
:buffer_minutes, :buffer_reason, :buffer_note
|
|
119
120
|
)
|
|
120
121
|
end
|
|
121
122
|
|
|
@@ -25,6 +25,29 @@ module HrLite
|
|
|
25
25
|
punch(:check_out)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Saving locks the slot for the period; a locked one only HR changes.
|
|
29
|
+
def slot
|
|
30
|
+
raise ActiveRecord::RecordNotFound unless HrLite.config.attendance_slots
|
|
31
|
+
|
|
32
|
+
period = params[:period].to_s
|
|
33
|
+
to = AttendanceSlot.ends_on_for(period, Date.current)
|
|
34
|
+
if AttendanceSlot.where(user_id: hr_current_user.id, locked: true).overlapping(Date.current, to).exists?
|
|
35
|
+
return redirect_to attendance_path, alert: "Your slot is locked for that period. Ask HR to change it."
|
|
36
|
+
end
|
|
37
|
+
# Today's penalty was priced on the slot at check-in; picking one now would rewrite it.
|
|
38
|
+
if AttendanceRecord.where(user_id: hr_current_user.id, date: Date.current).where.not(check_in_at: nil).exists?
|
|
39
|
+
return redirect_to attendance_path, alert: "Pick your slot before checking in."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
slot = AttendanceSlot.new(user_id: hr_current_user.id, set_by_id: hr_current_user.id, period: period,
|
|
43
|
+
start_minute: params[:start_minute].to_i, starts_on: Date.current, ends_on: to)
|
|
44
|
+
if slot.save
|
|
45
|
+
redirect_to attendance_path, notice: "Slot #{AttendanceSlot.label(slot.start_minute)} saved and locked until #{to.strftime('%d %b')}."
|
|
46
|
+
else
|
|
47
|
+
redirect_to attendance_path, alert: slot.errors.full_messages.to_sentence
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
28
51
|
private
|
|
29
52
|
|
|
30
53
|
def punch(kind)
|
|
@@ -27,11 +27,23 @@ module HrLite
|
|
|
27
27
|
tell(user, date, "attendance.short_day",
|
|
28
28
|
"You worked #{(record.worked_duration / 3600.0).round(1)} of #{record.required_hours}h on #{day} — marked half day")
|
|
29
29
|
end
|
|
30
|
+
penalize_short_day(record) if record&.check_out_at
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
private
|
|
34
35
|
|
|
36
|
+
# Out before the slot's required checkout: +30 min on the next working day.
|
|
37
|
+
def penalize_short_day(record)
|
|
38
|
+
return unless HrLite.config.attendance_slots && record.check_out_at < record.required_check_out_at
|
|
39
|
+
|
|
40
|
+
next_day = AttendancePenalty.next_working_days(record.date, 1).first
|
|
41
|
+
AttendancePenalty.find_or_create_by!(attendance_record: record, reason: "short_day", date: next_day) do |p|
|
|
42
|
+
p.user_id = record.user_id
|
|
43
|
+
p.minutes = AttendanceSlot::STEP_MINUTES
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
35
47
|
def tell(user, date, event, title)
|
|
36
48
|
rule = HrLite.config.self_regularization
|
|
37
49
|
body = if rule
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Minutes added to one day's required checkout. Never deleted: HR removes
|
|
3
|
+
# one with a reason and the row stays as the record.
|
|
4
|
+
class AttendancePenalty < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
REASONS = {
|
|
8
|
+
"late" => "Late check-in",
|
|
9
|
+
"late_carry" => "Checked in after 11:00",
|
|
10
|
+
"short_day" => "Left before the required checkout"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
14
|
+
belongs_to :attendance_record
|
|
15
|
+
belongs_to :removed_by, class_name: HrLite.config.user_class, optional: true
|
|
16
|
+
|
|
17
|
+
validates :reason, inclusion: { in: REASONS.keys }
|
|
18
|
+
validates :minutes, numericality: { greater_than: 0 }
|
|
19
|
+
validates :removal_reason, presence: true, if: :removed_at
|
|
20
|
+
|
|
21
|
+
scope :active, -> { where(removed_at: nil) }
|
|
22
|
+
|
|
23
|
+
def self.next_working_days(date, count)
|
|
24
|
+
calendar = WorkingCalendar.new((date + 1)..(date + 31))
|
|
25
|
+
((date + 1)..(date + 31)).select { |d| calendar.working_day?(d) }.first(count)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def label = REASONS.fetch(reason)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -3,8 +3,9 @@ module HrLite
|
|
|
3
3
|
STATUSES = %w[present half_day].freeze
|
|
4
4
|
AUTO_CHECKOUT_NOTE = "Auto check-out: no check-out punched".freeze
|
|
5
5
|
SHORT_DAY_NOTE = "Short day: under the required hours".freeze
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
LATE_HALF_DAY_NOTE = "Half day: checked in after 11:30".freeze
|
|
7
|
+
# Half days the system set (not HR); an approved ticket lifts them.
|
|
8
|
+
AUTO_NOTES = [ AUTO_CHECKOUT_NOTE, SHORT_DAY_NOTE, LATE_HALF_DAY_NOTE ].freeze
|
|
8
9
|
# Check-in and check-out further apart than this are called out on the board.
|
|
9
10
|
PUNCH_GAP_ALERT_M = 500
|
|
10
11
|
|
|
@@ -45,6 +46,15 @@ module HrLite
|
|
|
45
46
|
!!(hours && worked_duration && worked_duration < hours.hours)
|
|
46
47
|
end
|
|
47
48
|
|
|
49
|
+
# config.attendance_slots: slot start + 8h30 + the day's active penalties.
|
|
50
|
+
def required_check_out_at
|
|
51
|
+
return unless HrLite.config.attendance_slots && check_in_at
|
|
52
|
+
|
|
53
|
+
extra = AttendancePenalty.active.where(user_id: user_id, date: date).sum(:minutes)
|
|
54
|
+
date.in_time_zone(HrLite.config.time_zone) +
|
|
55
|
+
(AttendanceSlot.start_minute_for(user_id, date) + AttendanceSlot::DAY_MINUTES + extra).minutes
|
|
56
|
+
end
|
|
57
|
+
|
|
48
58
|
# Metres between where the day was checked in and checked out.
|
|
49
59
|
def punch_gap_m
|
|
50
60
|
return unless check_in_lat && check_in_lng && check_out_lat && check_out_lng
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The check-in slot an employee commits to for a day, week or month.
|
|
3
|
+
# Saving locks it; only an attendance manager changes or unlocks it.
|
|
4
|
+
class AttendanceSlot < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
START_MINUTES = [ 540, 570, 600, 630 ].freeze # 09:00 09:30 10:00 10:30
|
|
8
|
+
DEFAULT_START_MINUTE = 600
|
|
9
|
+
PERIODS = { "day" => "Today", "week" => "This week", "month" => "This month" }.freeze
|
|
10
|
+
DAY_MINUTES = 510 # 8h30m from slot to checkout
|
|
11
|
+
GRACE_MINUTES = 5
|
|
12
|
+
STEP_MINUTES = 30
|
|
13
|
+
CARRY_AFTER_MINUTE = 660 # 11:00 — +1h on the next 2 working days
|
|
14
|
+
HALF_DAY_AFTER_MINUTE = 690 # 11:30 — the day is a half day
|
|
15
|
+
|
|
16
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
17
|
+
|
|
18
|
+
validates :start_minute, inclusion: { in: START_MINUTES }
|
|
19
|
+
validates :period, inclusion: { in: PERIODS.keys }
|
|
20
|
+
validates :starts_on, :ends_on, presence: true
|
|
21
|
+
|
|
22
|
+
scope :overlapping, ->(from, to) { where("starts_on <= ? AND ends_on >= ?", to, from) }
|
|
23
|
+
|
|
24
|
+
def self.current(user_id, date) = where(user_id: user_id).overlapping(date, date).order(:created_at).last
|
|
25
|
+
def self.start_minute_for(user_id, date) = current(user_id, date)&.start_minute || DEFAULT_START_MINUTE
|
|
26
|
+
|
|
27
|
+
def self.ends_on_for(period, from)
|
|
28
|
+
{ "week" => from.end_of_week(:monday), "month" => from.end_of_month }.fetch(period, from)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 0 within grace, then +30 for every started half hour: 6–35 late = 30, 36–65 = 60.
|
|
32
|
+
def self.penalty_minutes(late)
|
|
33
|
+
late <= GRACE_MINUTES ? 0 : STEP_MINUTES * ((late - GRACE_MINUTES) / STEP_MINUTES.to_f).ceil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.label(minute) = format("%02d:%02d", *minute.divmod(60))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -20,6 +20,17 @@ module HrLite
|
|
|
20
20
|
# read as zero income for the months it cannot see.
|
|
21
21
|
encrypted_money :declared_annual_deductions, :fy_opening_gross, :fy_opening_tds
|
|
22
22
|
|
|
23
|
+
# HR-granted late allowance; it delays the penalty clock. Colleagues see
|
|
24
|
+
# the category, only HR sees the note (encrypted: it can be medical).
|
|
25
|
+
BUFFER_MINUTES = [ 60, 120 ].freeze
|
|
26
|
+
BUFFER_REASONS = { "parental_care" => "Parental care", "medical" => "Medical",
|
|
27
|
+
"health" => "Health", "other" => "Other" }.freeze
|
|
28
|
+
encrypts :buffer_note
|
|
29
|
+
normalizes :buffer_minutes, with: ->(v) { v.presence }
|
|
30
|
+
validates :buffer_minutes, inclusion: { in: BUFFER_MINUTES }, allow_nil: true
|
|
31
|
+
validates :buffer_reason, inclusion: { in: BUFFER_REASONS.keys }, if: :buffer_minutes
|
|
32
|
+
validates :buffer_note, presence: true, if: -> { buffer_minutes && buffer_reason == "other" }
|
|
33
|
+
|
|
23
34
|
TAX_REGIMES = %w[new old].freeze
|
|
24
35
|
BLOOD_GROUPS = %w[A+ A- B+ B- AB+ AB- O+ O-].freeze
|
|
25
36
|
normalizes :blood_group, with: ->(v) { v.presence } # the "Not set" chip posts ""
|
|
@@ -53,6 +53,7 @@ module HrLite
|
|
|
53
53
|
|
|
54
54
|
apply_geo_flags(record)
|
|
55
55
|
record.save!
|
|
56
|
+
apply_slot_rules(record) if @kind == :check_in && HrLite.config.attendance_slots
|
|
56
57
|
end
|
|
57
58
|
notify_if_flagged(record)
|
|
58
59
|
Result.new(record: record)
|
|
@@ -86,6 +87,25 @@ module HrLite
|
|
|
86
87
|
"Already checked in at #{record.check_in_at.in_time_zone(HrLite.config.time_zone).strftime('%H:%M')}."
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
# Late ladder against slot + buffer; after 11:00 also +1h on the next two
|
|
91
|
+
# working days; after 11:30 the day is a half day. The buffer shifts all three.
|
|
92
|
+
def apply_slot_rules(record)
|
|
93
|
+
local = record.check_in_at.in_time_zone(HrLite.config.time_zone)
|
|
94
|
+
arrived = local.hour * 60 + local.min - EmployeeProfile.where(user_id: @user.id).pick(:buffer_minutes).to_i
|
|
95
|
+
late = AttendanceSlot.penalty_minutes(arrived - AttendanceSlot.start_minute_for(@user.id, record.date))
|
|
96
|
+
penalize(record, record.date, late, "late") if late.positive?
|
|
97
|
+
return if arrived <= AttendanceSlot::CARRY_AFTER_MINUTE
|
|
98
|
+
|
|
99
|
+
AttendancePenalty.next_working_days(record.date, 2).each { |day| penalize(record, day, 60, "late_carry") }
|
|
100
|
+
return if arrived <= AttendanceSlot::HALF_DAY_AFTER_MINUTE
|
|
101
|
+
|
|
102
|
+
record.update!(status: "half_day", regularization_note: AttendanceRecord::LATE_HALF_DAY_NOTE)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def penalize(record, date, minutes, reason)
|
|
106
|
+
AttendancePenalty.create!(user_id: @user.id, date: date, minutes: minutes, reason: reason, attendance_record: record)
|
|
107
|
+
end
|
|
108
|
+
|
|
89
109
|
def apply_geo_flags(record)
|
|
90
110
|
label = @kind == :check_in ? "Check-in" : "Check-out"
|
|
91
111
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<% slot = HrLite::AttendanceSlot.current(employee.id, Date.current) %>
|
|
2
|
+
<section class="hrl-card">
|
|
3
|
+
<h2 class="hrl-card__title">Check-in slot</h2>
|
|
4
|
+
<p class="hrl-small">
|
|
5
|
+
<span class="hrl-badge"><%= HrLite::AttendanceSlot.label(slot&.start_minute || HrLite::AttendanceSlot::DEFAULT_START_MINUTE) %><%= " (default)" unless slot %></span>
|
|
6
|
+
<% if slot %><%= slot.locked? ? "Locked" : "Unlocked" %> till <%= slot.ends_on.strftime("%-d %b") %><% end %>
|
|
7
|
+
</p>
|
|
8
|
+
<% if slot&.locked? %>
|
|
9
|
+
<%= form_with url: hr_lite.unlock_slot_admin_attendance_path(employee.id), method: :post, local: true do %>
|
|
10
|
+
<button type="submit" class="hrl-btn">Unlock so they can pick again</button>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<%= form_with url: hr_lite.slot_admin_attendance_path(employee.id), method: :post, local: true, class: "hrl-mt" do %>
|
|
14
|
+
<div class="hrl-field">
|
|
15
|
+
<label for="hrl-admin-slot-start">Set slot</label>
|
|
16
|
+
<%= select_tag :start_minute, options_for_select(HrLite::AttendanceSlot::START_MINUTES.map { |m| [ HrLite::AttendanceSlot.label(m), m ] }, slot&.start_minute || HrLite::AttendanceSlot::DEFAULT_START_MINUTE), id: "hrl-admin-slot-start" %>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="hrl-field">
|
|
19
|
+
<label for="hrl-admin-slot-period">For</label>
|
|
20
|
+
<%= select_tag :period, options_for_select(HrLite::AttendanceSlot::PERIODS.invert), id: "hrl-admin-slot-period" %>
|
|
21
|
+
</div>
|
|
22
|
+
<button type="submit" class="hrl-btn">Set and lock</button>
|
|
23
|
+
<% end %>
|
|
24
|
+
</section>
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
<p class="hrl-small hrl-muted hrl-mt">Tap a date to fix its punches.</p>
|
|
25
25
|
</section>
|
|
26
26
|
|
|
27
|
+
<% if HrLite.config.attendance_slots %>
|
|
28
|
+
<%= render "slot", employee: @employee %>
|
|
29
|
+
<%= render "hr_lite/attendance/penalties", user: @employee, month: @month, removable: @employee.id != hr_current_user.id %>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
27
32
|
<% if @punches.any? %>
|
|
28
33
|
<section class="hrl-card">
|
|
29
34
|
<h2 class="hrl-card__title">Where they punched</h2>
|
|
@@ -74,6 +74,17 @@
|
|
|
74
74
|
<%= f.label :probation_until, "On probation until (blank = not on probation)" %>
|
|
75
75
|
<%= f.date_field :probation_until %>
|
|
76
76
|
</div>
|
|
77
|
+
<% if HrLite.config.attendance_slots %>
|
|
78
|
+
<div class="hrl-field">
|
|
79
|
+
<%= f.label :buffer_minutes, "Late buffer (delays late penalties; colleagues see the reason category)" %>
|
|
80
|
+
<%= f.select :buffer_minutes, HrLite::EmployeeProfile::BUFFER_MINUTES.map { |m| [ "#{m / 60}h", m ] }, include_blank: "None" %>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="hrl-field">
|
|
83
|
+
<%= f.label :buffer_reason, "Buffer reason" %>
|
|
84
|
+
<%= f.select :buffer_reason, HrLite::EmployeeProfile::BUFFER_REASONS.invert, include_blank: "Choose one" %>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="hrl-field"><%= f.label :buffer_note, "Buffer note (HR only; required for Other)" %><%= f.text_area :buffer_note, rows: 2 %></div>
|
|
87
|
+
<% end %>
|
|
77
88
|
<div class="hrl-field">
|
|
78
89
|
<%= f.label :date_of_exit, "Date of exit (leave blank while employed)" %>
|
|
79
90
|
<%= f.date_field :date_of_exit %>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<% penalties = HrLite::AttendancePenalty.where(user_id: user.id, date: month.all_month).order(:date, :id) %>
|
|
2
|
+
<section class="hrl-card">
|
|
3
|
+
<h2 class="hrl-card__title">Penalties in <%= month.strftime("%B") %></h2>
|
|
4
|
+
<% if penalties.empty? %>
|
|
5
|
+
<p class="hrl-card--empty">No penalties this month.</p>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% penalties.each do |penalty| %>
|
|
8
|
+
<div class="hrl-feed__item hrl-small">
|
|
9
|
+
<div class="hrl-feed__body">
|
|
10
|
+
<strong><%= penalty.date.strftime("%a %-d %b") %></strong> ·
|
|
11
|
+
<span class="<%= class_names("hrl-badge", penalty.removed_at ? "hrl-badge--muted" : "hrl-badge--warn") %>">+<%= penalty.minutes %> min</span>
|
|
12
|
+
<%= penalty.label %>
|
|
13
|
+
<% if penalty.removed_at %>
|
|
14
|
+
<div class="hrl-muted">Removed by HR: <%= penalty.removal_reason %></div>
|
|
15
|
+
<% elsif local_assigns[:removable] %>
|
|
16
|
+
<%= form_with url: hr_lite.remove_penalty_admin_attendance_path(user.id, penalty_id: penalty.id), method: :post, local: true, class: "hrl-row" do %>
|
|
17
|
+
<label class="hrl-sr-only" for="hrl-remove-<%= penalty.id %>">Reason to remove</label>
|
|
18
|
+
<input type="text" class="hrl-input" name="removal_reason" id="hrl-remove-<%= penalty.id %>" required placeholder="Reason to remove">
|
|
19
|
+
<button type="submit" class="hrl-btn">Remove</button>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
</section>
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
<span class="hrl-muted">Not checked in yet.</span>
|
|
29
29
|
<% end %>
|
|
30
30
|
</div>
|
|
31
|
+
<% if HrLite.config.attendance_slots %>
|
|
32
|
+
<%= render "hr_lite/attendance/slot", record: record %>
|
|
33
|
+
<% end %>
|
|
31
34
|
<% if record&.flagged? %>
|
|
32
35
|
<p class="hrl-small">Flagged: <%= record.flag_note %></p>
|
|
33
36
|
<% end %>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<% slot = HrLite::AttendanceSlot.current(hr_current_user.id, Date.current)
|
|
2
|
+
start = slot&.start_minute || HrLite::AttendanceSlot::DEFAULT_START_MINUTE
|
|
3
|
+
due = record&.required_check_out_at %>
|
|
4
|
+
<div class="hrl-row hrl-row--mb hrl-small">
|
|
5
|
+
<span class="hrl-badge">Slot <%= HrLite::AttendanceSlot.label(start) %><%= " (default)" unless slot %></span>
|
|
6
|
+
<% if slot&.locked? %><span class="hrl-muted">Locked till <%= slot.ends_on.strftime("%-d %b") %></span><% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<% if due && record.check_out_at %>
|
|
9
|
+
<% short = due - record.check_out_at %>
|
|
10
|
+
<p class="hrl-small">Checkout was due <%= due.strftime("%H:%M") %>.
|
|
11
|
+
<% if short.positive? %><span class="hrl-badge hrl-badge--bad">Short by <%= hrl_duration(short) %></span> +30 min goes on your next working day.<% end %></p>
|
|
12
|
+
<% elsif due %>
|
|
13
|
+
<% left = due - Time.current %>
|
|
14
|
+
<p class="hrl-small">Checked in <%= record.check_in_at.strftime("%H:%M") %> · checkout at <strong><%= due.strftime("%H:%M") %></strong> ·
|
|
15
|
+
<%= left.positive? ? "#{hrl_duration(left)} left" : "you can check out" %></p>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% HrLite::AttendancePenalty.active.where(user_id: hr_current_user.id, date: Date.current).each do |penalty| %>
|
|
18
|
+
<p class="hrl-small"><span class="hrl-badge hrl-badge--warn">+<%= penalty.minutes %> min</span> <%= penalty.label %></p>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% unless slot&.locked? || record&.check_in_at %>
|
|
21
|
+
<%= form_with url: hr_lite.slot_attendance_path, method: :post, local: true, class: "hrl-mt" do %>
|
|
22
|
+
<div class="hrl-field">
|
|
23
|
+
<label for="hrl-slot-start">Check-in slot</label>
|
|
24
|
+
<%= select_tag :start_minute, options_for_select(HrLite::AttendanceSlot::START_MINUTES.map { |m| [ "#{HrLite::AttendanceSlot.label(m)} – out #{HrLite::AttendanceSlot.label(m + HrLite::AttendanceSlot::DAY_MINUTES)}", m ] }, start), id: "hrl-slot-start" %>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="hrl-field">
|
|
27
|
+
<label for="hrl-slot-period">For</label>
|
|
28
|
+
<%= select_tag :period, options_for_select(HrLite::AttendanceSlot::PERIODS.invert), id: "hrl-slot-period" %>
|
|
29
|
+
</div>
|
|
30
|
+
<button type="submit" class="hrl-btn hrl-btn--block">Save and lock slot</button>
|
|
31
|
+
<p class="hrl-small hrl-muted">Once saved, only HR can change it for that period.</p>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% end %>
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
· <a href="<%= hr_lite.regularization_requests_path %>">My tickets</a></p>
|
|
26
26
|
</section>
|
|
27
27
|
|
|
28
|
+
<% if HrLite.config.attendance_slots %>
|
|
29
|
+
<%= render "hr_lite/attendance/penalties", user: hr_current_user, month: @month %>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
28
32
|
<% if @day %>
|
|
29
33
|
<% rec, leave = @day_info.record, @day_info.leave
|
|
30
34
|
part = rec&.half_day_part.presence || leave&.half_day_part.presence
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
</div>
|
|
24
24
|
<div class="hrl-idcard__photo">
|
|
25
25
|
<% if @profile.photo.attached? %>
|
|
26
|
-
<img src="<%= Rails.application.routes.url_helpers.
|
|
26
|
+
<img src="<%= Rails.application.routes.url_helpers.rails_storage_proxy_path(@profile.photo, disposition: "inline", only_path: true) %>" alt="<%= hr_display_name(@user) %>">
|
|
27
27
|
<% else %>
|
|
28
28
|
<span><%= hr_display_name(@user).to_s.scan(/\b[[:alpha:]]/).first(2).join.upcase %></span>
|
|
29
29
|
<% end %>
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
|
|
63
63
|
<div class="hrl-idcard-actions">
|
|
64
64
|
<button type="button" class="hrl-btn hrl-btn--primary" data-hrl-print>Print</button>
|
|
65
|
+
<button type="button" class="hrl-btn" data-hrl-share="id-card-<%= @profile.employee_code %>.png">Share</button>
|
|
65
66
|
<% if own && @print_requested %>
|
|
66
67
|
<p class="hrl-small hrl-muted">Printed card requested on <%= @print_requested.to_date.strftime("%d %b %Y") %>.</p>
|
|
67
68
|
<% elsif own %>
|
|
@@ -72,7 +73,11 @@
|
|
|
72
73
|
<%= form_with url: hr_lite.id_card_photo_path, method: :patch, multipart: true, class: "hrl-row" do |f| %>
|
|
73
74
|
<div class="hrl-field">
|
|
74
75
|
<label for="photo">Change photo</label>
|
|
75
|
-
<%= f.file_field :photo, id: "photo", accept: HrLite::EmployeeProfile::PHOTO_TYPES.join(","), required: true %>
|
|
76
|
+
<%= f.file_field :photo, id: "photo", accept: HrLite::EmployeeProfile::PHOTO_TYPES.join(","), required: true, data: { hrl_photo_input: true } %>
|
|
77
|
+
<div data-hrl-photo-adjust hidden>
|
|
78
|
+
<label for="photo_zoom">Zoom — drag the photo on the card to move it</label>
|
|
79
|
+
<input type="range" id="photo_zoom" class="hrl-photo-zoom" min="1" max="3" step="0.01" value="1" data-hrl-photo-zoom>
|
|
80
|
+
</div>
|
|
76
81
|
<p class="hrl-hint">Face centred, plain background. JPG, PNG or WebP, under <%= HrLite::EmployeeProfile::PHOTO_MAX_BYTES / 1.megabyte %> MB.</p>
|
|
77
82
|
</div>
|
|
78
83
|
<%= f.submit "Upload photo", class: "hrl-btn" %>
|
|
@@ -83,4 +88,5 @@
|
|
|
83
88
|
|
|
84
89
|
<% content_for :scripts do %>
|
|
85
90
|
<%= javascript_include_tag "hr_lite/print", defer: true %>
|
|
91
|
+
<%= javascript_include_tag "hr_lite/vendor/html_to_image", "hr_lite/share", "hr_lite/photo_crop", defer: true %>
|
|
86
92
|
<% end %>
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
<tr>
|
|
31
31
|
<td data-label="Name">
|
|
32
32
|
<span><%= tag.strong hr_display_name(row.user), title: ("On leave: #{row.leave.date_range_label}" if row.leave) %><% if row.profile&.designation.present? %>
|
|
33
|
-
<span class="hrl-small hrl-muted"> · <%= row.profile.designation %></span><% end
|
|
33
|
+
<span class="hrl-small hrl-muted"> · <%= row.profile.designation %></span><% end %><% if HrLite.config.attendance_slots && row.profile&.buffer_minutes %>
|
|
34
|
+
<span class="hrl-badge hrl-badge--muted">+<%= row.profile.buffer_minutes / 60 %>h buffer · <%= HrLite::EmployeeProfile::BUFFER_REASONS[row.profile.buffer_reason] %></span><% end %></span>
|
|
34
35
|
</td>
|
|
35
36
|
<td data-label="Status"><%= hrl_team_status(row) %></td>
|
|
36
37
|
<td data-label="Hours <%= @date == Date.current ? "today" : "that day" %>" class="hrl-num"><%= hrl_duration(row.day_seconds) %></td>
|
data/config/routes.rb
CHANGED
|
@@ -7,6 +7,7 @@ HrLite::Engine.routes.draw do
|
|
|
7
7
|
resource :attendance, only: :show, controller: "attendance" do
|
|
8
8
|
post :check_in
|
|
9
9
|
post :check_out
|
|
10
|
+
post :slot
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
resources :leave_requests, only: %i[index show new create] do
|
|
@@ -59,6 +60,7 @@ HrLite::Engine.routes.draw do
|
|
|
59
60
|
get "overview", to: "overview#index"
|
|
60
61
|
resources :attendances, only: %i[index show update], param: :user_id do
|
|
61
62
|
collection { get :week }
|
|
63
|
+
member { post :slot; post :unlock_slot; post :remove_penalty }
|
|
62
64
|
end
|
|
63
65
|
resources :leave_requests, only: %i[index show new create edit update] do
|
|
64
66
|
member { post :approve; post :reject; post :cancel }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateHrLiteAttendanceSlots < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hr_lite_attendance_slots do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.date :starts_on, null: false
|
|
6
|
+
t.date :ends_on, null: false
|
|
7
|
+
t.string :period, null: false
|
|
8
|
+
t.integer :start_minute, null: false
|
|
9
|
+
t.boolean :locked, null: false, default: true
|
|
10
|
+
t.bigint :set_by_id
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
add_index :hr_lite_attendance_slots, [ :user_id, :starts_on, :ends_on ]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class CreateHrLiteAttendancePenalties < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hr_lite_attendance_penalties do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.date :date, null: false
|
|
6
|
+
t.integer :minutes, null: false
|
|
7
|
+
t.string :reason, null: false
|
|
8
|
+
t.bigint :attendance_record_id, null: false
|
|
9
|
+
t.datetime :removed_at
|
|
10
|
+
t.bigint :removed_by_id
|
|
11
|
+
t.text :removal_reason
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
add_index :hr_lite_attendance_penalties, [ :user_id, :date ]
|
|
16
|
+
add_index :hr_lite_attendance_penalties, [ :attendance_record_id, :reason, :date ], unique: true,
|
|
17
|
+
name: "index_hr_lite_attendance_penalties_once"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddBufferToHrLiteEmployeeProfiles < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :hr_lite_employee_profiles, :buffer_minutes, :integer
|
|
4
|
+
add_column :hr_lite_employee_profiles, :buffer_reason, :string
|
|
5
|
+
add_column :hr_lite_employee_profiles, :buffer_note, :text
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -16,7 +16,9 @@ module HrLite
|
|
|
16
16
|
# e.g. 6 — default probation for new hires (admin edits per employee)
|
|
17
17
|
:probation_months,
|
|
18
18
|
# true = a punch without GPS is refused (default: recorded and flagged)
|
|
19
|
-
:require_punch_location
|
|
19
|
+
:require_punch_location,
|
|
20
|
+
# true = check-in slots, late penalties and HR buffers (default off)
|
|
21
|
+
:attendance_slots
|
|
20
22
|
|
|
21
23
|
attr_reader :leave_year_start_month, :slip_release_day, :self_regularization, :work_hours
|
|
22
24
|
|
|
@@ -102,6 +104,7 @@ module HrLite
|
|
|
102
104
|
@slip_release_day = nil # e.g. 10 = slips open on the 10th of the next month
|
|
103
105
|
@self_regularization = nil # e.g. { within_days: 2, per_week: 2 } — employees fix their own missed punch
|
|
104
106
|
@require_punch_location = false
|
|
107
|
+
@attendance_slots = false
|
|
105
108
|
@work_hours = nil # e.g. { day: 8, probation_day: 9, week: 40 } — short days close as half days
|
|
106
109
|
|
|
107
110
|
# Leadership onboarding/offboarding. onboard_user must return a saved
|
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.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -68,9 +68,12 @@ files:
|
|
|
68
68
|
- app/assets/javascripts/hr_lite/geo_punch.js
|
|
69
69
|
- app/assets/javascripts/hr_lite/mention.js
|
|
70
70
|
- app/assets/javascripts/hr_lite/nav.js
|
|
71
|
+
- app/assets/javascripts/hr_lite/photo_crop.js
|
|
71
72
|
- app/assets/javascripts/hr_lite/print.js
|
|
72
73
|
- app/assets/javascripts/hr_lite/select_filter.js
|
|
74
|
+
- app/assets/javascripts/hr_lite/share.js
|
|
73
75
|
- app/assets/javascripts/hr_lite/tax_preview.js
|
|
76
|
+
- app/assets/javascripts/hr_lite/vendor/html_to_image.js
|
|
74
77
|
- app/assets/stylesheets/hr_lite/application.css
|
|
75
78
|
- app/assets/stylesheets/hr_lite/hr_lite.css
|
|
76
79
|
- app/controllers/hr_lite/admin/appraisals_controller.rb
|
|
@@ -165,7 +168,9 @@ files:
|
|
|
165
168
|
- app/models/hr_lite/approval_step.rb
|
|
166
169
|
- app/models/hr_lite/asset.rb
|
|
167
170
|
- app/models/hr_lite/asset_assignment.rb
|
|
171
|
+
- app/models/hr_lite/attendance_penalty.rb
|
|
168
172
|
- app/models/hr_lite/attendance_record.rb
|
|
173
|
+
- app/models/hr_lite/attendance_slot.rb
|
|
169
174
|
- app/models/hr_lite/audit_log.rb
|
|
170
175
|
- app/models/hr_lite/award.rb
|
|
171
176
|
- app/models/hr_lite/benefit.rb
|
|
@@ -231,6 +236,7 @@ files:
|
|
|
231
236
|
- app/views/hr_lite/admin/appraisals/new.html.erb
|
|
232
237
|
- app/views/hr_lite/admin/assets/index.html.erb
|
|
233
238
|
- app/views/hr_lite/admin/assets/new.html.erb
|
|
239
|
+
- app/views/hr_lite/admin/attendances/_slot.html.erb
|
|
234
240
|
- app/views/hr_lite/admin/attendances/index.html.erb
|
|
235
241
|
- app/views/hr_lite/admin/attendances/show.html.erb
|
|
236
242
|
- app/views/hr_lite/admin/attendances/week.html.erb
|
|
@@ -315,7 +321,9 @@ files:
|
|
|
315
321
|
- app/views/hr_lite/appraisals/show.html.erb
|
|
316
322
|
- app/views/hr_lite/approvals/index.html.erb
|
|
317
323
|
- app/views/hr_lite/attendance/_month_grid.html.erb
|
|
324
|
+
- app/views/hr_lite/attendance/_penalties.html.erb
|
|
318
325
|
- app/views/hr_lite/attendance/_punch_card.html.erb
|
|
326
|
+
- app/views/hr_lite/attendance/_slot.html.erb
|
|
319
327
|
- app/views/hr_lite/attendance/show.html.erb
|
|
320
328
|
- app/views/hr_lite/benefits/index.html.erb
|
|
321
329
|
- app/views/hr_lite/calendar/show.html.erb
|
|
@@ -420,6 +428,9 @@ files:
|
|
|
420
428
|
- db/migrate/20261010100500_grant_hr_lite_tax_to_hr.rb
|
|
421
429
|
- db/migrate/20261010110000_create_hr_lite_ca_reviews.rb
|
|
422
430
|
- db/migrate/20261010110100_seed_hr_lite_ca_role.rb
|
|
431
|
+
- db/migrate/20261013090000_create_hr_lite_attendance_slots.rb
|
|
432
|
+
- db/migrate/20261013090100_create_hr_lite_attendance_penalties.rb
|
|
433
|
+
- db/migrate/20261013090200_add_buffer_to_hr_lite_employee_profiles.rb
|
|
423
434
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
424
435
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
425
436
|
- lib/generators/hr_lite/install/templates/initializer.rb
|