hr_lite 0.21.2 → 0.22.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 +75 -0
- data/README.md +54 -2
- data/app/assets/javascripts/hr_lite/confirm.js +18 -3
- data/app/assets/javascripts/hr_lite/geo_punch.js +1 -1
- data/app/assets/javascripts/hr_lite/nav.js +17 -0
- data/app/assets/javascripts/hr_lite/select_filter.js +46 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +223 -83
- data/app/controllers/hr_lite/admin/assets_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/attendances_controller.rb +31 -0
- data/app/controllers/hr_lite/approvals_controller.rb +1 -12
- data/app/controllers/hr_lite/attendance_controller.rb +7 -0
- data/app/controllers/hr_lite/holidays_controller.rb +1 -0
- data/app/controllers/hr_lite/home_controller.rb +40 -3
- data/app/controllers/hr_lite/hr_requests_controller.rb +1 -1
- data/app/controllers/hr_lite/leave_requests_controller.rb +13 -2
- data/app/controllers/hr_lite/regularization_requests_controller.rb +10 -2
- data/app/controllers/hr_lite/tax_declarations_controller.rb +4 -0
- data/app/helpers/hr_lite/application_helper.rb +43 -30
- data/app/jobs/hr_lite/attendance_close_job.rb +6 -1
- data/app/models/hr_lite/approval.rb +5 -0
- data/app/models/hr_lite/attendance_record.rb +26 -0
- data/app/models/hr_lite/employee_profile.rb +1 -0
- data/app/models/hr_lite/hr_request.rb +2 -1
- data/app/models/hr_lite/office_location.rb +12 -0
- data/app/models/hr_lite/regularization_request.rb +10 -4
- data/app/services/hr_lite/attendance_puncher.rb +8 -3
- data/app/views/hr_lite/admin/assets/index.html.erb +3 -2
- data/app/views/hr_lite/admin/assets/new.html.erb +6 -2
- data/app/views/hr_lite/admin/attendances/index.html.erb +37 -6
- data/app/views/hr_lite/admin/attendances/show.html.erb +11 -7
- data/app/views/hr_lite/admin/attendances/week.html.erb +42 -0
- data/app/views/hr_lite/admin/checklists/index.html.erb +1 -1
- data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +2 -2
- data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +2 -2
- data/app/views/hr_lite/admin/documents/index.html.erb +1 -1
- data/app/views/hr_lite/admin/employees/_form.html.erb +15 -7
- data/app/views/hr_lite/admin/employees/show.html.erb +5 -5
- data/app/views/hr_lite/admin/expenses/index.html.erb +2 -2
- data/app/views/hr_lite/admin/leave_balances/index.html.erb +3 -3
- data/app/views/hr_lite/admin/leave_requests/index.html.erb +2 -2
- data/app/views/hr_lite/admin/leave_requests/show.html.erb +4 -6
- data/app/views/hr_lite/admin/loans/new.html.erb +2 -1
- data/app/views/hr_lite/admin/overview/index.html.erb +3 -3
- data/app/views/hr_lite/admin/regularization_requests/index.html.erb +1 -1
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +6 -5
- data/app/views/hr_lite/appraisals/show.html.erb +3 -3
- data/app/views/hr_lite/approvals/index.html.erb +1 -1
- data/app/views/hr_lite/attendance/_month_grid.html.erb +21 -26
- data/app/views/hr_lite/attendance/_punch_card.html.erb +29 -7
- data/app/views/hr_lite/attendance/show.html.erb +36 -7
- data/app/views/hr_lite/benefits/index.html.erb +1 -1
- data/app/views/hr_lite/calendar/show.html.erb +29 -22
- data/app/views/hr_lite/comp_off_requests/index.html.erb +3 -3
- data/app/views/hr_lite/comp_off_requests/new.html.erb +3 -2
- data/app/views/hr_lite/documents/index.html.erb +32 -30
- data/app/views/hr_lite/employee_profiles/show.html.erb +2 -2
- data/app/views/hr_lite/expenses/index.html.erb +17 -35
- data/app/views/hr_lite/expenses/new.html.erb +11 -6
- data/app/views/hr_lite/holidays/_next_holiday.html.erb +11 -8
- data/app/views/hr_lite/holidays/index.html.erb +6 -6
- data/app/views/hr_lite/home/index.html.erb +29 -19
- data/app/views/hr_lite/hr_requests/index.html.erb +7 -24
- data/app/views/hr_lite/hr_requests/new.html.erb +7 -5
- data/app/views/hr_lite/hr_requests/show.html.erb +4 -0
- data/app/views/hr_lite/kudos/index.html.erb +4 -3
- data/app/views/hr_lite/leave_balances/index.html.erb +14 -5
- data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +5 -3
- data/app/views/hr_lite/leave_requests/_out_list.html.erb +16 -0
- data/app/views/hr_lite/leave_requests/index.html.erb +8 -21
- data/app/views/hr_lite/leave_requests/new.html.erb +22 -9
- data/app/views/hr_lite/leave_requests/show.html.erb +3 -5
- data/app/views/hr_lite/loans/index.html.erb +18 -20
- data/app/views/hr_lite/policies/index.html.erb +1 -1
- data/app/views/hr_lite/regularization_requests/index.html.erb +2 -2
- data/app/views/hr_lite/regularization_requests/new.html.erb +11 -8
- data/app/views/hr_lite/resignations/show.html.erb +1 -1
- data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +9 -3
- data/app/views/hr_lite/salary_slips/index.html.erb +48 -44
- data/app/views/hr_lite/shared/_bell.html.erb +1 -1
- data/app/views/hr_lite/shared/_form_errors.html.erb +1 -1
- data/app/views/hr_lite/tax_declarations/show.html.erb +24 -19
- data/app/views/hr_lite/team/show.html.erb +2 -2
- data/app/views/layouts/hr_lite/application.html.erb +24 -8
- data/config/routes.rb +3 -1
- data/lib/hr_lite/configuration.rb +16 -2
- data/lib/hr_lite/notifications.rb +1 -0
- data/lib/hr_lite/version.rb +1 -1
- metadata +4 -1
|
@@ -2,9 +2,46 @@ module HrLite
|
|
|
2
2
|
class HomeController < ApplicationController
|
|
3
3
|
def index
|
|
4
4
|
@latest_kudos = Kudo.recent.includes(:giver, kudo_mentions: :user).limit(3)
|
|
5
|
-
@
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
@out_week = LeaveRequest.approved.overlapping_range(Date.current.beginning_of_week, Date.current.end_of_week)
|
|
6
|
+
.includes(:user).where(user_id: HrLite.active_employees.map(&:id))
|
|
7
|
+
.sort_by { |leave| hr_display_name(leave.user).downcase }
|
|
8
|
+
@attention = attention_days
|
|
9
|
+
@waiting = Approval.pending_for(hr_current_user).count
|
|
10
|
+
@queue = queue_count
|
|
11
|
+
@mine_pending = [ LeaveRequest.includes(:leave_type), RegularizationRequest, CompOffRequest ]
|
|
12
|
+
.flat_map { |scope| scope.pending.where(user_id: hr_current_user.id).order(created_at: :desc).limit(5) }
|
|
13
|
+
.max_by(5, &:created_at)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Requests with no routed flow are decided straight off the HR queues, scoped as those queues decide them.
|
|
19
|
+
def queue_count
|
|
20
|
+
leave = ApprovalFlow.for(LeaveRequest.name) ? 0 : decidable(LeaveRequest, "leave.approve")
|
|
21
|
+
leave + decidable(CompOffRequest, "leave.approve") + decidable(RegularizationRequest, "attendance.manage")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def decidable(model, key) = hr_scope(model.pending, key).where.not(user_id: hr_current_user.id).count
|
|
25
|
+
|
|
26
|
+
# Last week's days that still need a fix (absent, no check-out, flagged, short), minus those with a pending ticket.
|
|
27
|
+
def attention_days
|
|
28
|
+
range = (Date.current - 7)..(Date.current - 1)
|
|
29
|
+
days = DayStatus.new(user: hr_current_user, range: range)
|
|
30
|
+
pending = RegularizationRequest.pending.where(user_id: hr_current_user.id, date: range).pluck(:date)
|
|
31
|
+
profile = EmployeeProfile.find_by(user_id: hr_current_user.id)
|
|
32
|
+
(range.to_a - pending).filter_map do |date|
|
|
33
|
+
day = days.for(date)
|
|
34
|
+
# The day card offers no Fix on these; days outside employment are not absences.
|
|
35
|
+
next if %i[holiday weekend leave].include?(day.kind) || (profile && !profile.active_on?(date))
|
|
36
|
+
|
|
37
|
+
# Yesterday's open shift may still be running; the punch card offers its check-out.
|
|
38
|
+
reason = if day.kind == :absent then "Absent"
|
|
39
|
+
elsif day.record&.check_in_at && !day.record.check_out_at && date < Date.current - 1 then "No check-out"
|
|
40
|
+
elsif day.record&.flagged? then "Flagged"
|
|
41
|
+
elsif day.record&.short? && !day.record.regularized? then "Short day"
|
|
42
|
+
end
|
|
43
|
+
[ date, reason ] if reason
|
|
44
|
+
end
|
|
8
45
|
end
|
|
9
46
|
end
|
|
10
47
|
end
|
|
@@ -12,18 +12,22 @@ module HrLite
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def new
|
|
15
|
-
@request = LeaveRequest.new(start_date:
|
|
15
|
+
@request = LeaveRequest.new(start_date: (parse_date_param(params[:date]) if params[:date].present?),
|
|
16
|
+
leave_type_id: params[:leave_type_id])
|
|
16
17
|
@balances = balance_cards
|
|
18
|
+
@out_soon = colleagues_out_soon
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
def create
|
|
20
22
|
@request = LeaveRequest.new(request_params.merge(user_id: hr_current_user.id))
|
|
23
|
+
@request.end_date ||= @request.start_date # a blank "To" means one day
|
|
21
24
|
if @request.save
|
|
22
25
|
# Say it now, not on the payslip: days past the balance are unpaid.
|
|
23
26
|
short = @request.days_beyond_balance
|
|
24
|
-
redirect_to leave_requests_path, notice: "Leave request submitted.#{" #{
|
|
27
|
+
redirect_to leave_requests_path, notice: "Leave request submitted.#{" #{helpers.hrl_days(short)} day(s) are beyond your balance and will be unpaid if approved." if short.positive?}"
|
|
25
28
|
else
|
|
26
29
|
@balances = balance_cards
|
|
30
|
+
@out_soon = colleagues_out_soon
|
|
27
31
|
render :new, status: :unprocessable_entity
|
|
28
32
|
end
|
|
29
33
|
end
|
|
@@ -51,6 +55,13 @@ module HrLite
|
|
|
51
55
|
end
|
|
52
56
|
end
|
|
53
57
|
|
|
58
|
+
# Approved leave only, name and dates; the type stays private (see _out_list).
|
|
59
|
+
def colleagues_out_soon
|
|
60
|
+
LeaveRequest.approved.overlapping_range(Date.current, Date.current + 14)
|
|
61
|
+
.where(user_id: HrLite.active_employees.map(&:id)).where.not(user_id: hr_current_user.id)
|
|
62
|
+
.includes(:user).order(:start_date, :id).to_a
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
def request_params
|
|
55
66
|
params.require(:leave_request).permit(:leave_type_id, :start_date, :end_date, :half_day, :half_day_part, :reason)
|
|
56
67
|
end
|
|
@@ -34,12 +34,20 @@ module HrLite
|
|
|
34
34
|
return "Ticket raised — an admin will review it." unless rule
|
|
35
35
|
return "Fixed. #{helpers.pluralize(@request.self_fixes_left, 'self-fix')} left this week." if @request.approved?
|
|
36
36
|
|
|
37
|
-
"Sent to HR as a ticket — you can fix only a missed punch on a working day in the last #{rule[:within_days]} days yourself, " \
|
|
37
|
+
"Sent to HR as a ticket — you can fix only a missed punch or short day on a working day in the last #{rule[:within_days]} days yourself, " \
|
|
38
38
|
"#{rule[:per_week]} times a week."
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def request_params
|
|
42
|
-
params.require(:regularization_request).permit(:date, :check_in_at, :check_out_at, :reason)
|
|
42
|
+
p = params.require(:regularization_request).permit(:date, :check_in_at, :check_out_at, :reason)
|
|
43
|
+
# The form sends times only: pin them to the day; a check-out before check-in is the next morning (night shift).
|
|
44
|
+
in_at, out_at = p.values_at(:check_in_at, :check_out_at).map do |t|
|
|
45
|
+
Time.zone.parse("#{p[:date]} #{t}") if t.present?
|
|
46
|
+
rescue ArgumentError # an impossible date or time ("25:99") is left blank for the model to refuse
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
out_at += 1.day if in_at && out_at && out_at < in_at
|
|
50
|
+
p.merge(check_in_at: in_at, check_out_at: out_at)
|
|
43
51
|
end
|
|
44
52
|
end
|
|
45
53
|
end
|
|
@@ -5,6 +5,8 @@ module HrLite
|
|
|
5
5
|
class TaxDeclarationsController < ApplicationController
|
|
6
6
|
def show
|
|
7
7
|
@declaration = current_declaration || build_declaration
|
|
8
|
+
# A saved draft holds only its claims; the rest still need a line to fill.
|
|
9
|
+
fill_missing_sections(@declaration)
|
|
8
10
|
@regime = @declaration.regime
|
|
9
11
|
end
|
|
10
12
|
|
|
@@ -23,6 +25,8 @@ module HrLite
|
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
if @declaration.update(declaration_params)
|
|
28
|
+
return submit if params[:submit_to_hr] # Submit to HR saves what is on screen first
|
|
29
|
+
|
|
26
30
|
redirect_to tax_declaration_path, notice: "Saved."
|
|
27
31
|
else
|
|
28
32
|
# Re-render needs the full set of lines back, or the form loses the
|
|
@@ -1,31 +1,30 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module ApplicationHelper
|
|
3
3
|
NAV_ITEMS = [
|
|
4
|
-
{ label: "Home", path: :root_path, match: [ "/" ] },
|
|
5
|
-
{ label: "Attendance", path: :attendance_path, match: [ "/attendance", "/regularization_requests" ] },
|
|
6
|
-
{ label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances", "/comp_off_requests" ] },
|
|
7
|
-
{ label: "Team", path: :team_path, match: [ "/team" ] },
|
|
8
|
-
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
{ label: "Home", path: :root_path, match: [ "/" ], group: "My work" },
|
|
5
|
+
{ label: "Attendance", path: :attendance_path, match: [ "/attendance", "/regularization_requests" ], group: "My work" },
|
|
6
|
+
{ label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances", "/comp_off_requests" ], group: "My work" },
|
|
7
|
+
{ label: "Team", path: :team_path, match: [ "/team" ], group: "My work" },
|
|
8
|
+
# The phone tab bar shows the first four; the rest fill the More sheet,
|
|
9
|
+
# one label per group, so a group's items must stay together.
|
|
10
|
+
{ label: "Calendar", path: :calendar_path, match: [ "/calendar", "/holidays" ], group: "Time" },
|
|
11
|
+
{ label: "Expenses", path: :expenses_path, match: [ "/expenses" ], group: "Money" },
|
|
12
|
+
{ label: "Benefits", path: :benefits_path, match: [ "/benefits" ], group: "Money" },
|
|
13
|
+
{ label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ], group: "Money" },
|
|
14
|
+
{ label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ], group: "Money" },
|
|
15
|
+
{ label: "Loans", path: :loans_path, match: [ "/loans" ], group: "Money" },
|
|
16
|
+
{ label: "Profile", path: :employee_profile_path, match: [ "/profile", "/resignation" ], group: "Me" },
|
|
17
|
+
{ label: "Documents", path: :documents_path, match: [ "/documents" ], group: "Me" },
|
|
18
|
+
{ label: "ID card", path: :id_card_path, match: [ "/id_card" ], group: "Me" },
|
|
19
|
+
{ label: "Career", path: :career_path, match: [ "/career", "/appraisals" ], group: "Me" },
|
|
20
|
+
{ label: "Policies", path: :policies_path, match: [ "/policies" ], group: "Company" },
|
|
21
|
+
{ label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ], group: "Company" },
|
|
22
|
+
{ label: "Org", path: :org_path, match: [ "/org" ], group: "Company" },
|
|
23
|
+
{ label: "Kudos", path: :kudos_path, match: [ "/kudos" ], group: "Company" },
|
|
12
24
|
# Employee tier all the same — holding an approval IS the authorisation,
|
|
13
25
|
# so a manager and a stand-in covering for one reach the same screen.
|
|
14
|
-
{ label: "Approvals", path: :approvals_path, match: [ "/approvals" ],
|
|
15
|
-
if: -> { HrLite::ApprovalFlow.where(active: true).exists? } }
|
|
16
|
-
{ label: "Expenses", path: :expenses_path, match: [ "/expenses" ] },
|
|
17
|
-
{ label: "Benefits", path: :benefits_path, match: [ "/benefits" ] },
|
|
18
|
-
{ label: "Policies", path: :policies_path, match: [ "/policies" ] },
|
|
19
|
-
{ label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ] },
|
|
20
|
-
{ label: "Org", path: :org_path, match: [ "/org" ] },
|
|
21
|
-
{ label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
|
|
22
|
-
{ label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
|
|
23
|
-
{ label: "Career", path: :career_path, match: [ "/career", "/appraisals" ] },
|
|
24
|
-
{ label: "Profile", path: :employee_profile_path, match: [ "/profile", "/resignation" ] },
|
|
25
|
-
{ label: "Documents", path: :documents_path, match: [ "/documents" ] },
|
|
26
|
-
{ label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
|
|
27
|
-
{ label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
|
|
28
|
-
{ label: "Loans", path: :loans_path, match: [ "/loans" ] }
|
|
26
|
+
{ label: "Approvals", path: :approvals_path, match: [ "/approvals" ], group: "Company",
|
|
27
|
+
if: -> { HrLite::ApprovalFlow.where(active: true).exists? } }
|
|
29
28
|
].freeze
|
|
30
29
|
|
|
31
30
|
ADMIN_NAV_ITEMS = [
|
|
@@ -60,6 +59,14 @@ module HrLite
|
|
|
60
59
|
{ label: "Loans", path: :admin_loans_path, match: [ "/admin/loans" ] }
|
|
61
60
|
].freeze
|
|
62
61
|
|
|
62
|
+
# DayStatus kind => [code, word, month-grid cell modifier]: the code keeps a state readable without its colour.
|
|
63
|
+
DAY_STATES = {
|
|
64
|
+
present: [ "P", "Present", "present" ], half_day: [ "½", "Half day", "half" ],
|
|
65
|
+
leave: [ "L", "Leave", "leave" ], half_day_leave: [ "½L", "Half-day leave", "half-leave" ],
|
|
66
|
+
holiday: [ "H", "Holiday", "holiday" ], weekend: [ "WO", "Weekly off", "weekend" ],
|
|
67
|
+
absent: [ "A", "Absent", "absent" ], upcoming: [ nil, "Upcoming", "muted" ]
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
63
70
|
# The ID card's QR: office address and phone as plain text, so any phone
|
|
64
71
|
# camera shows who to call about a found card. No URL, nothing to host.
|
|
65
72
|
def hrl_office_qr_svg(company, brand)
|
|
@@ -78,12 +85,16 @@ module HrLite
|
|
|
78
85
|
hr_lite.respond_to?(helper_name)
|
|
79
86
|
end
|
|
80
87
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
prefix == "/" ? request.path == href : request.path.start_with?("#{hr_lite.root_path.chomp('/')}#{prefix}")
|
|
88
|
+
def hrl_nav_active?(item)
|
|
89
|
+
item[:match].any? do |prefix|
|
|
90
|
+
prefix == "/" ? request.path == hr_lite.public_send(item[:path]) : request.path.start_with?("#{hr_lite.root_path.chomp('/')}#{prefix}")
|
|
85
91
|
end
|
|
86
|
-
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def hrl_nav_link(item)
|
|
95
|
+
active = hrl_nav_active?(item)
|
|
96
|
+
link_to item[:label], hr_lite.public_send(item[:path]),
|
|
97
|
+
class: "hrl-nav__link #{'hrl-nav__link--active' if active}", aria: { current: ("page" if active) }
|
|
87
98
|
end
|
|
88
99
|
|
|
89
100
|
# Mention-marker-aware, XSS-safe kudos message rendering. Every literal
|
|
@@ -140,6 +151,9 @@ module HrLite
|
|
|
140
151
|
HrLite::AmountInWords.words(amount)
|
|
141
152
|
end
|
|
142
153
|
|
|
154
|
+
# Day counts without a trailing ".0": 1.0 -> "1", 0.5 -> "0.5"; nil is unknown, not zero.
|
|
155
|
+
def hrl_days(days) = days.nil? ? "\u2014" : number_with_precision(days, precision: 1, strip_insignificant_zeros: true)
|
|
156
|
+
|
|
143
157
|
def hrl_duration(seconds)
|
|
144
158
|
return "\u2014" if seconds.nil?
|
|
145
159
|
|
|
@@ -156,8 +170,7 @@ module HrLite
|
|
|
156
170
|
when :half_day_leave then hrl_status_badge("Half-day leave (#{row.leave.leave_type.code})", "hrl-badge--warn", worked_hint(row))
|
|
157
171
|
when :upcoming then hrl_status_badge("\u2014", "hrl-badge--muted")
|
|
158
172
|
when :absent
|
|
159
|
-
|
|
160
|
-
hrl_status_badge(label, "hrl-badge--bad")
|
|
173
|
+
row.date_today? ? hrl_status_badge("Not in yet", "hrl-badge--muted") : hrl_status_badge("Absent", "hrl-badge--bad")
|
|
161
174
|
else # :present / :half_day punch
|
|
162
175
|
record = row.record
|
|
163
176
|
if record.check_out_at
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
# Closes the working day that ended last: a run at 23:55 closes today, and a
|
|
3
3
|
# retry or late run after midnight (or a morning schedule) closes yesterday.
|
|
4
|
-
# A punch left open is checked out at the day's end and paid as a half day
|
|
4
|
+
# A punch left open is checked out at the day's end and paid as a half day,
|
|
5
|
+
# and so is a closed punch short of config.work_hours.
|
|
5
6
|
# Nobody punched in already reads as absent (DayStatus), so they are only
|
|
6
7
|
# told. Leave days are left alone.
|
|
7
8
|
class AttendanceCloseJob < ApplicationJob
|
|
@@ -21,6 +22,10 @@ module HrLite
|
|
|
21
22
|
record.update!(check_out_at: date.end_of_day, status: "half_day",
|
|
22
23
|
regularization_note: AttendanceRecord::AUTO_CHECKOUT_NOTE)
|
|
23
24
|
tell(user, date, "attendance.missed_check_out", "You didn't check out on #{day} — marked half day")
|
|
25
|
+
elsif !record.regularized? && record.status == "present" && record.short?
|
|
26
|
+
record.update!(status: "half_day", regularization_note: AttendanceRecord::SHORT_DAY_NOTE)
|
|
27
|
+
tell(user, date, "attendance.short_day",
|
|
28
|
+
"You worked #{(record.worked_duration / 3600.0).round(1)} of #{record.required_hours}h on #{day} — marked half day")
|
|
24
29
|
end
|
|
25
30
|
end
|
|
26
31
|
end
|
|
@@ -14,6 +14,11 @@ module HrLite
|
|
|
14
14
|
scope :pending, -> { where(status: "pending") }
|
|
15
15
|
scope :at_position, ->(position) { where(position: position) }
|
|
16
16
|
scope :recent_first, -> { order(created_at: :desc) }
|
|
17
|
+
# Rows addressed to `user`, plus rows of anybody who has delegated to them while away.
|
|
18
|
+
scope :pending_for, lambda { |user|
|
|
19
|
+
standing_in_for = ApprovalDelegation.live_on(Date.current).where(to_user_id: user.id).pluck(:from_user_id)
|
|
20
|
+
pending.where(approver_id: [ user.id, *standing_in_for ])
|
|
21
|
+
}
|
|
17
22
|
|
|
18
23
|
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
19
24
|
|
|
@@ -2,6 +2,11 @@ module HrLite
|
|
|
2
2
|
class AttendanceRecord < ApplicationRecord
|
|
3
3
|
STATUSES = %w[present half_day].freeze
|
|
4
4
|
AUTO_CHECKOUT_NOTE = "Auto check-out: no check-out punched".freeze
|
|
5
|
+
SHORT_DAY_NOTE = "Short day: under the required hours".freeze
|
|
6
|
+
# Half days the close job set (not HR); an approved ticket lifts them.
|
|
7
|
+
AUTO_NOTES = [ AUTO_CHECKOUT_NOTE, SHORT_DAY_NOTE ].freeze
|
|
8
|
+
# Check-in and check-out further apart than this are called out on the board.
|
|
9
|
+
PUNCH_GAP_ALERT_M = 500
|
|
5
10
|
|
|
6
11
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
7
12
|
belongs_to :regularized_by, class_name: HrLite.config.user_class, optional: true
|
|
@@ -26,6 +31,27 @@ module HrLite
|
|
|
26
31
|
check_out_at - check_in_at
|
|
27
32
|
end
|
|
28
33
|
|
|
34
|
+
# config.work_hours: probation days need more hours. Nil when the rule is
|
|
35
|
+
# off, and on Saturdays (a working Saturday has no hours rule).
|
|
36
|
+
def required_hours
|
|
37
|
+
rule = HrLite.config.work_hours
|
|
38
|
+
return if rule.nil? || date.saturday?
|
|
39
|
+
|
|
40
|
+
EmployeeProfile.find_by(user_id: user_id)&.on_probation?(date) ? rule[:probation_day] : rule[:day]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def short?
|
|
44
|
+
hours = required_hours
|
|
45
|
+
!!(hours && worked_duration && worked_duration < hours.hours)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Metres between where the day was checked in and checked out.
|
|
49
|
+
def punch_gap_m
|
|
50
|
+
return unless check_in_lat && check_in_lng && check_out_lat && check_out_lng
|
|
51
|
+
|
|
52
|
+
Geo.distance_m(check_in_lat, check_in_lng, check_out_lat, check_out_lng)
|
|
53
|
+
end
|
|
54
|
+
|
|
29
55
|
def add_flag!(note)
|
|
30
56
|
self.flagged = true
|
|
31
57
|
self.flag_note = [ flag_note.presence, note ].compact.join("; ")
|
|
@@ -22,6 +22,7 @@ module HrLite
|
|
|
22
22
|
|
|
23
23
|
TAX_REGIMES = %w[new old].freeze
|
|
24
24
|
BLOOD_GROUPS = %w[A+ A- B+ B- AB+ AB- O+ O-].freeze
|
|
25
|
+
normalizes :blood_group, with: ->(v) { v.presence } # the "Not set" chip posts ""
|
|
25
26
|
# The ID-card photo. Images a browser can print; content-sniffed like
|
|
26
27
|
# Document, so the check is on the real bytes.
|
|
27
28
|
PHOTO_TYPES = %w[image/jpeg image/png image/webp].freeze
|
|
@@ -26,7 +26,8 @@ module HrLite
|
|
|
26
26
|
|
|
27
27
|
after_create_commit :notify_desk
|
|
28
28
|
|
|
29
|
-
def category_label = category.humanize.sub(/\AId\b/, "ID")
|
|
29
|
+
def self.category_label(category) = category.humanize.sub(/\AId\b/, "ID")
|
|
30
|
+
def category_label = self.class.category_label(category)
|
|
30
31
|
|
|
31
32
|
def assign!(actor:, assignee:)
|
|
32
33
|
update!(assigned_to_id: assignee.id, status: "in_progress")
|
|
@@ -13,6 +13,18 @@ module HrLite
|
|
|
13
13
|
active.any? { |office| Geo.distance_m(office.lat, office.lng, lat, lng) <= office.radius_m }
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Where a punch happened, for the admin board: the office whose radius
|
|
17
|
+
# covers it, else "Off-site · 3.2 km from Head Office". Nil without GPS.
|
|
18
|
+
def self.place(lat, lng, offices = active.to_a)
|
|
19
|
+
return if lat.nil? || lng.nil?
|
|
20
|
+
|
|
21
|
+
office = offices.min_by { |o| Geo.distance_m(o.lat, o.lng, lat, lng) }
|
|
22
|
+
return "Off-site" unless office
|
|
23
|
+
|
|
24
|
+
metres = Geo.distance_m(office.lat, office.lng, lat, lng)
|
|
25
|
+
metres <= office.radius_m ? office.name : "Off-site · #{(metres / 1000.0).round(1)} km from #{office.name}"
|
|
26
|
+
end
|
|
27
|
+
|
|
16
28
|
# For flag notes: "1.2 km from Head Office". Nil when no offices exist.
|
|
17
29
|
def self.nearest(lat, lng)
|
|
18
30
|
active.min_by { |office| Geo.distance_m(office.lat, office.lng, lat, lng) }
|
|
@@ -31,7 +31,7 @@ module HrLite
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def times_label
|
|
34
|
-
[ check_in_at&.strftime("%H:%M"), check_out_at&.strftime("%H:%M") ].compact.join(" – ").presence || "
|
|
34
|
+
[ check_in_at&.strftime("%H:%M"), check_out_at&.strftime("%H:%M") ].compact.join(" – ").presence || "Short day"
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Current punch state for the approver's context.
|
|
@@ -49,10 +49,15 @@ module HrLite
|
|
|
49
49
|
day = punch
|
|
50
50
|
fills_gap = (check_in_at.nil? || !day&.check_in_at) &&
|
|
51
51
|
(check_out_at.nil? || !day&.check_out_at || day.regularization_note == AttendanceRecord::AUTO_CHECKOUT_NOTE)
|
|
52
|
-
fills_gap && !day&.regularized? && !self.class.exists?(user_id: user_id, date: date, status: "rejected") &&
|
|
52
|
+
(fills_gap || short_day_ticket?) && !day&.regularized? && !self.class.exists?(user_id: user_id, date: date, status: "rejected") &&
|
|
53
53
|
self_fixes_left.positive?
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# No times, just a reason: "my hours were short that day, accept them".
|
|
57
|
+
def short_day_ticket?
|
|
58
|
+
check_in_at.nil? && check_out_at.nil? && !!punch&.short?
|
|
59
|
+
end
|
|
60
|
+
|
|
56
61
|
# Self-fixes the user still has in this ticket's Mon–Sun week.
|
|
57
62
|
def self_fixes_left
|
|
58
63
|
week = date.beginning_of_week(:monday)..date.end_of_week(:monday)
|
|
@@ -86,7 +91,7 @@ module HrLite
|
|
|
86
91
|
end
|
|
87
92
|
|
|
88
93
|
# Lifts only the close job's half day; one HR set on purpose stays.
|
|
89
|
-
record.status = "present" if record.status.blank? || record.regularization_note
|
|
94
|
+
record.status = "present" if record.status.blank? || AttendanceRecord::AUTO_NOTES.include?(record.regularization_note)
|
|
90
95
|
record.regularized_by_id = actor.id
|
|
91
96
|
record.regularized_at = Time.current
|
|
92
97
|
record.regularization_note = "Ticket ##{id}: #{reason}"
|
|
@@ -163,7 +168,7 @@ module HrLite
|
|
|
163
168
|
end
|
|
164
169
|
|
|
165
170
|
def at_least_one_time
|
|
166
|
-
return if check_in_at || check_out_at
|
|
171
|
+
return if check_in_at || check_out_at || short_day_ticket?
|
|
167
172
|
|
|
168
173
|
errors.add(:base, "Propose a check-in time, a check-out time, or both")
|
|
169
174
|
end
|
|
@@ -173,6 +178,7 @@ module HrLite
|
|
|
173
178
|
|
|
174
179
|
[ [ :check_in_at, check_in_at ], [ :check_out_at, check_out_at ] ].each do |attr, time|
|
|
175
180
|
next unless time
|
|
181
|
+
next if attr == :check_out_at && check_in_at && time.to_date == date + 1 && time < check_in_at + 1.day # night shift
|
|
176
182
|
|
|
177
183
|
errors.add(attr, "must be on #{date.strftime('%d %b')}") unless time.to_date == date
|
|
178
184
|
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
# The single write path for employee punches. Race-safe (DB unique index +
|
|
3
|
-
# row lock + in-lock re-check)
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# row lock + in-lock re-check). A punch outside every office radius is
|
|
4
|
+
# recorded AND flagged; one without GPS is too, unless
|
|
5
|
+
# config.require_punch_location refuses it.
|
|
6
6
|
class AttendancePuncher
|
|
7
7
|
# What the browser can legitimately report (geo_punch.js).
|
|
8
8
|
GEO_STATUSES = %w[ok denied unavailable timeout insecure].freeze
|
|
@@ -29,6 +29,11 @@ module HrLite
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def call
|
|
32
|
+
if HrLite.config.require_punch_location && (@lat.nil? || @lng.nil?)
|
|
33
|
+
return Result.new(error: "Location is required to punch. Turn on location and try again, " \
|
|
34
|
+
"or raise a regularization ticket.")
|
|
35
|
+
end
|
|
36
|
+
|
|
32
37
|
record = resolve_record
|
|
33
38
|
return Result.new(error: "No open check-in to close.") if record.nil?
|
|
34
39
|
|
|
@@ -39,13 +39,14 @@
|
|
|
39
39
|
<td data-label="">
|
|
40
40
|
<% if asset.assigned? %>
|
|
41
41
|
<%= form_with url: hr_lite.take_back_admin_asset_path(asset), method: :post do %>
|
|
42
|
-
<%= text_field_tag :condition_note, nil, placeholder: "Condition" %>
|
|
42
|
+
<%= text_field_tag :condition_note, nil, placeholder: "Condition", class: "hrl-input" %>
|
|
43
43
|
<%= submit_tag "Take back", class: "hrl-btn" %>
|
|
44
44
|
<% end %>
|
|
45
45
|
<% elsif asset.available? %>
|
|
46
46
|
<%= form_with url: hr_lite.assign_admin_asset_path(asset), method: :post do %>
|
|
47
47
|
<%= select_tag :user_id,
|
|
48
|
-
options_for_select(HrLite.employees.map { |u| [ hr_display_name(u), u.id ] })
|
|
48
|
+
options_for_select(HrLite.employees.map { |u| [ hr_display_name(u), u.id ] }), class: "hrl-input",
|
|
49
|
+
data: { hrl_filter: "Type a name to filter" } %>
|
|
49
50
|
<%= submit_tag "Hand over", class: "hrl-btn" %>
|
|
50
51
|
<% end %>
|
|
51
52
|
<% end %>
|
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
<%= render "hr_lite/shared/form_errors", record: @asset %>
|
|
7
7
|
<div class="hrl-field"><%= f.label :name %><%= f.text_field :name %></div>
|
|
8
8
|
<div class="hrl-field">
|
|
9
|
-
<%= f.label :category %>
|
|
10
|
-
|
|
9
|
+
<%= f.label :category, id: "hrl-asset-category", for: nil %>
|
|
10
|
+
<div class="hrl-chiprow" role="radiogroup" aria-labelledby="hrl-asset-category">
|
|
11
|
+
<% HrLite::Asset::CATEGORIES.each do |c| %>
|
|
12
|
+
<label><%= f.radio_button :category, c, required: true %><span><%= c.humanize %></span></label>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
11
15
|
</div>
|
|
12
16
|
<div class="hrl-field">
|
|
13
17
|
<%= f.label :serial_number, "Serial number (optional)" %>
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
<h1 class="hrl-page__title">Team attendance</h1>
|
|
4
4
|
</div>
|
|
5
5
|
|
|
6
|
-
<div class="hrl-row
|
|
7
|
-
<a class="hrl-btn" href="<%= hr_lite.admin_attendances_path(date: @date - 1) %>">←</a>
|
|
6
|
+
<div class="hrl-row hrl-row--mb">
|
|
7
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_attendances_path(date: @date - 1) %>" aria-label="Previous day, <%= (@date - 1).strftime("%A %-d %B") %>">←</a>
|
|
8
8
|
<strong><%= @date.strftime("%A, %d %B %Y") %></strong>
|
|
9
|
-
<a class="hrl-btn" href="<%= hr_lite.admin_attendances_path(date: @date + 1) %>">→</a>
|
|
9
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_attendances_path(date: @date + 1) %>" aria-label="Next day, <%= (@date + 1).strftime("%A %-d %B") %>">→</a>
|
|
10
10
|
<% unless @date == Date.current %>
|
|
11
11
|
<a class="hrl-btn" href="<%= hr_lite.admin_attendances_path %>">Today</a>
|
|
12
12
|
<% end %>
|
|
13
|
+
<a class="hrl-btn" href="<%= hr_lite.week_admin_attendances_path(date: @date) %>">Weekly hours</a>
|
|
13
14
|
</div>
|
|
14
15
|
|
|
15
16
|
<div class="hrl-kpis">
|
|
@@ -22,9 +23,20 @@
|
|
|
22
23
|
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @records.values.count { |r| r.check_in_at && r.check_out_at.nil? } %></div><div class="hrl-kpi__label">Still in</div></div>
|
|
23
24
|
</div>
|
|
24
25
|
|
|
26
|
+
<% if @places.values.any?(&:any?) %>
|
|
27
|
+
<section class="hrl-card">
|
|
28
|
+
<h2 class="hrl-card__title">Where people punched</h2>
|
|
29
|
+
<% { check_in: "Checked in at", check_out: "Checked out at" }.each do |side, label| %>
|
|
30
|
+
<% next if @places[side].empty? %>
|
|
31
|
+
<p class="hrl-small"><strong><%= label %>:</strong>
|
|
32
|
+
<%= @places[side].sort_by { |place, n| [ -n, place ] }.map { |place, n| "#{place} #{n}" }.join(" · ") %></p>
|
|
33
|
+
<% end %>
|
|
34
|
+
</section>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
25
37
|
<div class="hrl-table-wrap">
|
|
26
38
|
<table class="hrl-table hrl-table--stack">
|
|
27
|
-
<thead><tr><th>Employee</th><th>In</th><th>Out</th><th>Status</th><th></th></tr></thead>
|
|
39
|
+
<thead><tr><th>Employee</th><th>In</th><th>Out</th><th>Location</th><th>Status</th><th></th></tr></thead>
|
|
28
40
|
<tbody>
|
|
29
41
|
<% @employees.each do |employee| %>
|
|
30
42
|
<% record = @records[employee.id] %>
|
|
@@ -32,9 +44,28 @@
|
|
|
32
44
|
<td data-label="Employee"><%= hr_display_name(employee) %></td>
|
|
33
45
|
<td data-label="In"><%= record&.check_in_at&.strftime("%H:%M") || "—" %></td>
|
|
34
46
|
<td data-label="Out"><%= record&.check_out_at&.strftime("%H:%M") || "—" %></td>
|
|
47
|
+
<td data-label="Location" class="hrl-small">
|
|
48
|
+
<div>
|
|
49
|
+
<% if record&.check_in_at %>
|
|
50
|
+
<div>In: <%= HrLite::OfficeLocation.place(record.check_in_lat, record.check_in_lng, @offices) || "No GPS" %></div>
|
|
51
|
+
<% end %>
|
|
52
|
+
<% if record&.check_out_at %>
|
|
53
|
+
<div>Out: <%= HrLite::OfficeLocation.place(record.check_out_lat, record.check_out_lng, @offices) || "No GPS" %></div>
|
|
54
|
+
<% end %>
|
|
55
|
+
<% if (gap = record&.punch_gap_m) %>
|
|
56
|
+
<% apart = "#{(gap / 1000.0).round(1)} km apart" %>
|
|
57
|
+
<% if gap > HrLite::AttendanceRecord::PUNCH_GAP_ALERT_M %>
|
|
58
|
+
<div><span class="hrl-badge hrl-badge--warn"><%= apart %></span></div>
|
|
59
|
+
<% else %>
|
|
60
|
+
<div class="hrl-muted"><%= apart %></div>
|
|
61
|
+
<% end %>
|
|
62
|
+
<% end %>
|
|
63
|
+
<% unless record&.check_in_at %>—<% end %>
|
|
64
|
+
</div>
|
|
65
|
+
</td>
|
|
35
66
|
<td data-label="Status">
|
|
36
67
|
<% if record&.flagged? %>
|
|
37
|
-
<span class="hrl-badge hrl-badge--bad"
|
|
68
|
+
<span class="hrl-badge hrl-badge--bad">Flagged</span> <span class="hrl-small"><%= record.flag_note %></span>
|
|
38
69
|
<% elsif record&.check_in_at %>
|
|
39
70
|
<span class="hrl-badge hrl-badge--ok"><%= record.status.humanize %><%= " (#{record.half_day_part} half)" if record.half_day_part %></span>
|
|
40
71
|
<% else %>
|
|
@@ -43,7 +74,7 @@
|
|
|
43
74
|
<% if record&.regularized? %><span class="hrl-badge">Fixed</span><% end %>
|
|
44
75
|
</td>
|
|
45
76
|
<td data-label="">
|
|
46
|
-
<a class="hrl-
|
|
77
|
+
<a class="hrl-card__link" href="<%= hr_lite.admin_attendance_path(employee.id, date: @date, month: @date.strftime("%Y-%m"), anchor: "regularize") %>">Edit / half day</a>
|
|
47
78
|
</td>
|
|
48
79
|
</tr>
|
|
49
80
|
<% end %>
|
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
</div>
|
|
8
8
|
|
|
9
9
|
<section class="hrl-card">
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
<% prev_m, next_m = @month - 1.month, @month + 1.month %>
|
|
11
|
+
<div class="hrl-row hrl-row--mb">
|
|
12
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@employee.id, month: prev_m.strftime("%Y-%m")) %>" aria-label="Previous month, <%= prev_m.strftime("%B %Y") %>"><span aria-hidden="true">‹</span> <%= prev_m.strftime("%b") %></a>
|
|
12
13
|
<strong><%= @month.strftime("%B %Y") %></strong>
|
|
13
|
-
<a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@employee.id, month: (
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@employee.id, month: next_m.strftime("%Y-%m")) %>" aria-label="Next month, <%= next_m.strftime("%B %Y") %>"><%= next_m.strftime("%b") %> <span aria-hidden="true">›</span></a>
|
|
15
|
+
<div class="hrl-row hrl-right">
|
|
16
|
+
<% { present: "ok", half_day: "ok", leave: nil, absent: "bad" }.each do |kind, tone| %>
|
|
17
|
+
<% code, word = HrLite::ApplicationHelper::DAY_STATES[kind] %>
|
|
18
|
+
<span class="<%= class_names("hrl-badge", "hrl-badge--#{tone}": tone) %>" role="img" aria-label="<%= word %> <%= pluralize(@counts[kind], "day") %>"><%= code %> <%= @counts[kind] %></span>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
17
21
|
</div>
|
|
18
22
|
<%= render "hr_lite/attendance/month_grid", month: @month, day_status: @day_status,
|
|
19
|
-
link_builder: ->(date) { hr_lite.admin_attendance_path(@employee.id, date: date, month: @month.strftime("%Y-%m")) } %>
|
|
23
|
+
link_builder: ->(date) { hr_lite.admin_attendance_path(@employee.id, date: date, month: @month.strftime("%Y-%m"), anchor: "regularize") if date <= Date.current } %>
|
|
20
24
|
<p class="hrl-small hrl-muted hrl-mt">Tap a date to fix its punches.</p>
|
|
21
25
|
</section>
|
|
22
26
|
|