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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/README.md +54 -2
  4. data/app/assets/javascripts/hr_lite/confirm.js +18 -3
  5. data/app/assets/javascripts/hr_lite/geo_punch.js +1 -1
  6. data/app/assets/javascripts/hr_lite/nav.js +17 -0
  7. data/app/assets/javascripts/hr_lite/select_filter.js +46 -0
  8. data/app/assets/stylesheets/hr_lite/hr_lite.css +223 -83
  9. data/app/controllers/hr_lite/admin/assets_controller.rb +1 -1
  10. data/app/controllers/hr_lite/admin/attendances_controller.rb +31 -0
  11. data/app/controllers/hr_lite/approvals_controller.rb +1 -12
  12. data/app/controllers/hr_lite/attendance_controller.rb +7 -0
  13. data/app/controllers/hr_lite/holidays_controller.rb +1 -0
  14. data/app/controllers/hr_lite/home_controller.rb +40 -3
  15. data/app/controllers/hr_lite/hr_requests_controller.rb +1 -1
  16. data/app/controllers/hr_lite/leave_requests_controller.rb +13 -2
  17. data/app/controllers/hr_lite/regularization_requests_controller.rb +10 -2
  18. data/app/controllers/hr_lite/tax_declarations_controller.rb +4 -0
  19. data/app/helpers/hr_lite/application_helper.rb +43 -30
  20. data/app/jobs/hr_lite/attendance_close_job.rb +6 -1
  21. data/app/models/hr_lite/approval.rb +5 -0
  22. data/app/models/hr_lite/attendance_record.rb +26 -0
  23. data/app/models/hr_lite/employee_profile.rb +1 -0
  24. data/app/models/hr_lite/hr_request.rb +2 -1
  25. data/app/models/hr_lite/office_location.rb +12 -0
  26. data/app/models/hr_lite/regularization_request.rb +10 -4
  27. data/app/services/hr_lite/attendance_puncher.rb +8 -3
  28. data/app/views/hr_lite/admin/assets/index.html.erb +3 -2
  29. data/app/views/hr_lite/admin/assets/new.html.erb +6 -2
  30. data/app/views/hr_lite/admin/attendances/index.html.erb +37 -6
  31. data/app/views/hr_lite/admin/attendances/show.html.erb +11 -7
  32. data/app/views/hr_lite/admin/attendances/week.html.erb +42 -0
  33. data/app/views/hr_lite/admin/checklists/index.html.erb +1 -1
  34. data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +2 -2
  35. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +2 -2
  36. data/app/views/hr_lite/admin/documents/index.html.erb +1 -1
  37. data/app/views/hr_lite/admin/employees/_form.html.erb +15 -7
  38. data/app/views/hr_lite/admin/employees/show.html.erb +5 -5
  39. data/app/views/hr_lite/admin/expenses/index.html.erb +2 -2
  40. data/app/views/hr_lite/admin/leave_balances/index.html.erb +3 -3
  41. data/app/views/hr_lite/admin/leave_requests/index.html.erb +2 -2
  42. data/app/views/hr_lite/admin/leave_requests/show.html.erb +4 -6
  43. data/app/views/hr_lite/admin/loans/new.html.erb +2 -1
  44. data/app/views/hr_lite/admin/overview/index.html.erb +3 -3
  45. data/app/views/hr_lite/admin/regularization_requests/index.html.erb +1 -1
  46. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +6 -5
  47. data/app/views/hr_lite/appraisals/show.html.erb +3 -3
  48. data/app/views/hr_lite/approvals/index.html.erb +1 -1
  49. data/app/views/hr_lite/attendance/_month_grid.html.erb +21 -26
  50. data/app/views/hr_lite/attendance/_punch_card.html.erb +29 -7
  51. data/app/views/hr_lite/attendance/show.html.erb +36 -7
  52. data/app/views/hr_lite/benefits/index.html.erb +1 -1
  53. data/app/views/hr_lite/calendar/show.html.erb +29 -22
  54. data/app/views/hr_lite/comp_off_requests/index.html.erb +3 -3
  55. data/app/views/hr_lite/comp_off_requests/new.html.erb +3 -2
  56. data/app/views/hr_lite/documents/index.html.erb +32 -30
  57. data/app/views/hr_lite/employee_profiles/show.html.erb +2 -2
  58. data/app/views/hr_lite/expenses/index.html.erb +17 -35
  59. data/app/views/hr_lite/expenses/new.html.erb +11 -6
  60. data/app/views/hr_lite/holidays/_next_holiday.html.erb +11 -8
  61. data/app/views/hr_lite/holidays/index.html.erb +6 -6
  62. data/app/views/hr_lite/home/index.html.erb +29 -19
  63. data/app/views/hr_lite/hr_requests/index.html.erb +7 -24
  64. data/app/views/hr_lite/hr_requests/new.html.erb +7 -5
  65. data/app/views/hr_lite/hr_requests/show.html.erb +4 -0
  66. data/app/views/hr_lite/kudos/index.html.erb +4 -3
  67. data/app/views/hr_lite/leave_balances/index.html.erb +14 -5
  68. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +5 -3
  69. data/app/views/hr_lite/leave_requests/_out_list.html.erb +16 -0
  70. data/app/views/hr_lite/leave_requests/index.html.erb +8 -21
  71. data/app/views/hr_lite/leave_requests/new.html.erb +22 -9
  72. data/app/views/hr_lite/leave_requests/show.html.erb +3 -5
  73. data/app/views/hr_lite/loans/index.html.erb +18 -20
  74. data/app/views/hr_lite/policies/index.html.erb +1 -1
  75. data/app/views/hr_lite/regularization_requests/index.html.erb +2 -2
  76. data/app/views/hr_lite/regularization_requests/new.html.erb +11 -8
  77. data/app/views/hr_lite/resignations/show.html.erb +1 -1
  78. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +9 -3
  79. data/app/views/hr_lite/salary_slips/index.html.erb +48 -44
  80. data/app/views/hr_lite/shared/_bell.html.erb +1 -1
  81. data/app/views/hr_lite/shared/_form_errors.html.erb +1 -1
  82. data/app/views/hr_lite/tax_declarations/show.html.erb +24 -19
  83. data/app/views/hr_lite/team/show.html.erb +2 -2
  84. data/app/views/layouts/hr_lite/application.html.erb +24 -8
  85. data/config/routes.rb +3 -1
  86. data/lib/hr_lite/configuration.rb +16 -2
  87. data/lib/hr_lite/notifications.rb +1 -0
  88. data/lib/hr_lite/version.rb +1 -1
  89. 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
- @out_today = LeaveRequest.active_on(Date.current).includes(:user)
6
- .where(user_id: HrLite.active_employees.map(&:id))
7
- .sort_by { |leave| hr_display_name(leave.user).downcase }
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,7 +12,7 @@ module HrLite
12
12
  end
13
13
 
14
14
  def new
15
- @request = HrRequest.new
15
+ @request = HrRequest.new(category: params[:category].presence_in(HrRequest::CATEGORIES))
16
16
  end
17
17
 
18
18
  def create
@@ -12,18 +12,22 @@ module HrLite
12
12
  end
13
13
 
14
14
  def new
15
- @request = LeaveRequest.new(start_date: Date.current, end_date: Date.current)
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.#{" #{short.to_s('F')} day(s) are beyond your balance and will be unpaid if approved." if short.positive?}"
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
- { label: "Calendar", path: :calendar_path, match: [ "/calendar", "/holidays" ] },
9
- # After Calendar deliberately: the phone tab bar shows the first four
10
- # and this screen is empty for everybody who is not an approver, so it
11
- # belongs in "More" rather than displacing something everybody uses.
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 hrl_nav_link(item)
82
- href = hr_lite.public_send(item[:path])
83
- active = item[:match].any? do |prefix|
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
- link_to item[:label], href, class: "hrl-nav__link #{'hrl-nav__link--active' if active}"
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
- label = row.date_today? ? "Not in yet" : "Absent"
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 == AttendanceRecord::AUTO_CHECKOUT_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), never blocked by geolocation: a punch
4
- # without GPS or outside every office radius is recorded AND flagged —
5
- # denying GPS must not stop anyone from working.
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
- <%= f.select :category, HrLite::Asset::CATEGORIES.map { |c| [ c.humanize, c ] } %>
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" style="margin-bottom:.8rem;">
7
- <a class="hrl-btn" href="<%= hr_lite.admin_attendances_path(date: @date - 1) %>">&larr;</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") %>">&larr;</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) %>">&rarr;</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") %>">&rarr;</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" title="<%= record.flag_note %>">Flagged</span>
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-small" href="<%= hr_lite.admin_attendance_path(employee.id, date: @date, month: @date.strftime("%Y-%m")) %>">Edit / half day</a>
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
- <div class="hrl-row" style="margin-bottom:.6rem;">
11
- <a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@employee.id, month: (@month - 1.month).strftime("%Y-%m")) %>">&larr;</a>
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">&lsaquo;</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: (@month + 1.month).strftime("%Y-%m")) %>">&rarr;</a>
14
- <span class="hrl-right hrl-small hrl-muted">
15
- Present <%= @counts[:present] %> · Half <%= @counts[:half_day] %> · Absent <%= @counts[:absent] %>
16
- </span>
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">&rsaquo;</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