hr_lite 0.37.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/app/assets/javascripts/hr_lite/nav.js +5 -0
  4. data/app/assets/stylesheets/hr_lite/hr_lite.css +20 -13
  5. data/app/controllers/hr_lite/admin/base_controller.rb +1 -5
  6. data/app/controllers/hr_lite/application_controller.rb +7 -1
  7. data/app/helpers/hr_lite/application_helper.rb +152 -72
  8. data/app/models/hr_lite/wfh_request.rb +1 -0
  9. data/app/services/hr_lite/attendance_puncher.rb +3 -1
  10. data/app/views/hr_lite/admin/attendances/show.html.erb +1 -0
  11. data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +0 -2
  12. data/app/views/hr_lite/admin/documents/show.html.erb +1 -0
  13. data/app/views/hr_lite/admin/employees/show.html.erb +16 -9
  14. data/app/views/hr_lite/admin/leave_balances/index.html.erb +0 -2
  15. data/app/views/hr_lite/admin/leave_requests/index.html.erb +0 -2
  16. data/app/views/hr_lite/admin/regularization_requests/index.html.erb +0 -2
  17. data/app/views/hr_lite/admin/tax_declarations/person.html.erb +1 -0
  18. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +0 -2
  19. data/app/views/hr_lite/admin/work_locations/index.html.erb +0 -2
  20. data/app/views/hr_lite/admin/work_time/index.html.erb +1 -0
  21. data/app/views/hr_lite/approvals/index.html.erb +11 -1
  22. data/app/views/hr_lite/comp_off_requests/index.html.erb +1 -1
  23. data/app/views/hr_lite/comp_off_requests/new.html.erb +1 -1
  24. data/app/views/hr_lite/id_cards/show.html.erb +1 -0
  25. data/app/views/hr_lite/leave_balances/index.html.erb +1 -1
  26. data/app/views/hr_lite/leave_requests/new.html.erb +1 -1
  27. data/app/views/hr_lite/leave_requests/show.html.erb +1 -1
  28. data/app/views/hr_lite/shared/_person_tabs.html.erb +21 -0
  29. data/app/views/hr_lite/timelines/show.html.erb +1 -0
  30. data/app/views/hr_lite/wfh_requests/index.html.erb +1 -1
  31. data/app/views/hr_lite/work_time/_timeline.html.erb +1 -1
  32. data/app/views/layouts/hr_lite/application.html.erb +21 -59
  33. data/lib/hr_lite/version.rb +1 -1
  34. metadata +2 -2
  35. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +0 -20
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab092169ccdae2360753c2535d7e3dba33f4fb661f05f8dabf96b7d4ea4bf91a
4
- data.tar.gz: '039d406ffda5c820c670b1b9d30171e06bd70d1f29f5b4dc77f41dde2ed86fd7'
3
+ metadata.gz: 21440aa07238881b5cd3634fe5ab6d0bc1f4a101ab64dfeab82fd61f2bdb3b40
4
+ data.tar.gz: 17c0c09696da50c92f0512ec885981f8965ba493519a1d4194f692c1a28e020b
5
5
  SHA512:
6
- metadata.gz: 30340a7e5e365e3a2a84d6e00701688c68a76550bfde6a778a19fc285282531a458a9f700c7639b1cc30d16d060ec604c4134e25cfb5b65bd7cb6a9701aaa701
7
- data.tar.gz: 97f9e43b39ffa62c388077bb00f89c54baa1ab1a286b0db8cf6b6c15313e5a1e2d5880449d82b82e7dc7babf5fd512721d34b80a2e40351b1648453b8e5bda06
6
+ metadata.gz: 4e04c65673ad94c2a6adbe6663c54f546066fb4d5b8dc4229c101eb4b2b2b171eab98e931d7668a69930379969d82dcbf657b6f22a647363bc9d8d16ecf8fae7
7
+ data.tar.gz: dc39966df09fbf335a48bf658f773a4e38a827002011b28f5c87ddf82dceada302f43593be3294b726d13aa9199d7df6ff9f4ff91097d73b122205d74ab2edb7
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.40.1] - 2026-10-15
11
+
12
+ - Managers see their team's tabs. A Manager (leave and attendance at team scope) now gets Home › Company overview, Attendance › Team and Work modes, Leave › Team balances, Inbox (All, Leave requests, Comp-off, Attendance fixes, WFH, Home locations) and Admin › Reports. Those pages already let a manager in and show only their team's rows. The menu hid them unless you had the permission across the whole company or were leadership.
13
+ - These tabs now use the same check as their page (`hr_operations?`, which `Admin::BaseController` also uses), so they can't drift apart. Attendance › Team work time drops the expense-approver rule, because its page never admitted expense approvers. Admin › Reports shows for anyone who can open at least one report, which includes Finance.
14
+ - A manager's Inbox count and All list hold only their team's requests: each queue is scoped by the viewer's reach (0.40.0).
15
+ - The person tab row (0.39.0) reads `hr_operations?` too, instead of repeating its three permission checks.
16
+
17
+ ## [0.40.0] - 2026-10-15
18
+
19
+ - Inbox › All lists everything waiting on you in one place: approval-flow steps, plus every leave request, comp-off, attendance fix, WFH request, home location, claim and document waiting in the queues you can decide. Rows run oldest first. Each row has an Open button that goes to that queue's own page, where you decide as before. Inline Approve/Reject is not built yet.
20
+ - Inbox shows how many items are waiting, on the menu and on each queue tab. On the phone the count sits on the icon's corner, so the bar keeps its height. Before, the queue tabs counted every pending request in the company. Now they count only what you can decide (your reach, never your own requests), the same scope each queue page uses to decide.
21
+ - All opens Inbox whenever you can see any queue, not only when an approval flow is active. Someone with nothing to decide still has no Inbox.
22
+ - Each Inbox tab's `badge:` is replaced by `queue:`, a scoped relation that drives both the counts and the All list.
23
+
24
+ ## [0.39.0] - 2026-10-15
25
+
26
+ - One person's records are tabs: Overview, Attendance, Work time, Documents, Tax, Timeline, ID card. The row replaces the page's usual tab row on the seven per-person pages (the employee page, their attendance month, work time, documents, tax, timeline and ID card), so HR steps through someone's records without picking them again.
27
+ - Each tab shows only when its page would open for the viewer, using the same check that page makes. A manager sees their own report's Attendance, Timeline and ID card. A colleague sees no record tabs, and the timeline keeps the People tabs. A row with a single tab is not drawn.
28
+ - Employee page: the Edit button stays. New appraisal, Role change, Start PIP and New structure fold into one "Manage" button. The ID card, Timeline, Work time, Attendance and Documents buttons are now tabs.
29
+ - Layout: a page can fill the tab row slot itself with `content_for(:tabrow)`.
30
+
31
+ ## [0.38.1] - 2026-10-15
32
+
33
+ - Each of the nine places has a stroke icon in the rail, the phone bar and the More sheet. Icons are `aria-hidden`, and the label always shows beside the icon (in the phone bar, under it). Tabs carry no icons.
34
+ - The More sheet is one column. Under each place it lists that person's tabs in it ("Profile, Documents, ID card, Career, Timeline"), on one line that ends in "…" when it runs long.
35
+ - The rail spaces its links 8 px apart (was 2 px), now that it holds nine items rather than about forty.
36
+
37
+ ## [0.38.0] - 2026-10-15
38
+
39
+ - The menu has nine places instead of about 40 links in eight groups: Home, Attendance, Leave, Inbox, Pay, People, Me, Help, Admin. Each place has a row of tabs at the top of its pages. Your own tabs and your team's sit side by side, so "My attendance", "My work time", "Team" and "Team work time" are neighbours. Each place opens on its first tab, and a place shows only when at least one of its tabs does.
40
+ - No URL changes: every page keeps its address, so bookmarks and host links still work.
41
+ - One name, one meaning. The two "Approvals", "Documents", "Policies", "Benefits", "Tax" and "Loans" items are gone. HR's approval queues are tabs in Inbox (Leave requests, Comp-off, Attendance fixes, WFH, Home locations, Claims, Documents to verify, CA review), with their pending counts. Payroll and Payroll approval are one Pay › Payroll tab. "My work time", which had no menu link, is an Attendance tab.
42
+ - Phone: the bar holds the first four places; More lists the rest, 44 px tall with 8 px between rows (it was 2 px). The tab row scrolls sideways inside itself, never the page, and centres the current tab.
43
+ - Every tab is gated by the check its own page makes, so no menu link ends on "access denied". This fixes links the old menu showed to people who could not open them: Expenses, Benefits, Policies and Ask HR for someone without that permission (for example a CA), Organisation › Policies for leadership without `policy.manage`, and Roles for a superadmin without `role.manage`.
44
+ - `ApplicationHelper::NAV_ITEMS`, `ADMIN_NAV_ITEMS`, `LEADERSHIP_NAV_ITEMS` and `SUPERADMIN_NAV_ITEMS` are replaced by `SECTIONS`. The `admin/shared/_approvals_tabs` partial is removed; the layout's tab row replaces it.
45
+
46
+ ## [0.37.3] - 2026-10-14
47
+
48
+ - Admin → Employees → a person now links to their Work time, Attendance and Documents next to Timeline and ID card. Each button shows only when the viewer can open that screen for that person.
49
+
50
+ ## [0.37.2] - 2026-10-14
51
+
52
+ - Employee timeline no longer overlaps itself: the work-time day strip reused the `.hrl-timeline` class and squashed the timeline list to 2rem, so each month's events ran into the next month's heading. The day strip is now `.hrl-daystrip`.
53
+
54
+ ## [0.37.1] - 2026-10-14
55
+
56
+ - A WFH request still waiting for HR now lets the person check in away from the office on that day (HR has 24 hours to decide). The day is flagged "WFH request pending HR approval: <reason>", and HR gets the flagged-punch alert with that reason. HR was already told when the request was raised. Rejected requests and requests for other dates are still refused.
57
+
10
58
  ## [0.37.0] - 2026-10-14
11
59
 
12
60
  - Home detection: a WFH check-in matches any approved home within 500 m, and the nearest one becomes active. From anywhere else the punch card asks "Is this your home location?". Yes saves the place as an approved, active home straight away (a GPS fix good to 500 m, fewer than three live homes, audit row `work_location.self_saved`). No checks in as Travelling. The question comes back at every check-in from a place that is not a home.
@@ -12,6 +12,11 @@
12
12
 
13
13
  centre(document.querySelector(".hrl-side"));
14
14
 
15
+ // The tab row scrolls sideways on a phone; bring the current tab into it.
16
+ var row = document.querySelector(".hrl-tabrow");
17
+ var tab = row && row.querySelector(".hrl-nav__link--active");
18
+ if (tab) row.scrollLeft += tab.getBoundingClientRect().left - row.getBoundingClientRect().left - (row.clientWidth - tab.offsetWidth) / 2;
19
+
15
20
  // Chrome scrolls a focused field's caret into view, not the whole field, so a
16
21
  // textarea near the bottom can sit half under the fixed tab bar. Nudge it out.
17
22
  var tabbar = document.querySelector(".hrl-tabbar");
@@ -114,9 +114,18 @@ html:has(.hrl-body) { scroll-padding-top: 4rem; scroll-padding-bottom: calc(4.4r
114
114
  .hrl-nav__link {
115
115
  color: var(--hrl-muted); font-size: var(--hrl-t-small); padding: .5rem .6rem;
116
116
  border-radius: var(--hrl-radius-sm); min-height: var(--hrl-target);
117
- display: inline-flex; align-items: center;
117
+ display: inline-flex; align-items: center; gap: .6rem;
118
118
  }
119
119
  .hrl-nav__link--active { color: var(--hrl-accent); background: var(--hrl-info-bg); font-weight: 600; }
120
+ /* Place icons: stroke-only, decoration beside a label that always shows. */
121
+ .hrl-icon { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
122
+ .hrl-nav__text { display: flex; flex-direction: column; min-width: 0; }
123
+ .hrl-nav__hint { font-size: var(--hrl-t-small); font-weight: 400; color: var(--hrl-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
124
+ .hrl-nav__link--active .hrl-nav__hint { color: inherit; }
125
+ /* Tab row: the current place's tabs on one line; it scrolls sideways, never the page. */
126
+ .hrl-tabrow { display: flex; gap: var(--hrl-gap-target); overflow-x: auto; margin: 0 0 var(--hrl-s-4); padding-bottom: var(--hrl-s-2); border-bottom: 1px solid var(--hrl-line); }
127
+ .hrl-tabrow .hrl-nav__link { flex: 0 0 auto; white-space: nowrap; }
128
+ .hrl-side__divider { height: 1px; background: var(--hrl-line); margin: var(--hrl-s-2) var(--hrl-s-1); }
120
129
 
121
130
  /* Mobile app bar */
122
131
  .hrl-appbar--mobile {
@@ -138,6 +147,12 @@ html:has(.hrl-body) { scroll-padding-top: 4rem; scroll-padding-bottom: calc(4.4r
138
147
  padding: .25rem .25rem calc(.25rem + env(safe-area-inset-bottom));
139
148
  }
140
149
  .hrl-tabbar .hrl-nav__link { flex: 1 1 auto; min-width: var(--hrl-target); padding-inline: var(--hrl-s-1); justify-content: center; font-size: var(--hrl-t-micro); font-weight: 600; }
150
+ /* Bar items stack the icon over the label; the sheet's links stay in a row. */
151
+ .hrl-tabbar > .hrl-nav__link, .hrl-tabbar__more > summary { flex-direction: column; gap: 2px; padding-block: 2px; line-height: var(--hrl-lh-tight); }
152
+ .hrl-tabbar > .hrl-nav__link .hrl-icon, .hrl-tabbar__more > summary .hrl-icon { width: 22px; height: 22px; }
153
+ /* The Inbox count sits on the icon's corner so the bar keeps its height. */
154
+ .hrl-tabbar > .hrl-nav__link { position: relative; }
155
+ .hrl-tabbar > .hrl-nav__link .hrl-badge { position: absolute; top: 0; left: calc(50% + 2px); }
141
156
  /* "More" sheet — everything five tabs cannot hold. Pure <details>: the
142
157
  engine ships no Turbo or Stimulus and this must not need any. */
143
158
  .hrl-tabbar__more { flex: 1 1 auto; display: flex; }
@@ -151,7 +166,7 @@ html:has(.hrl-body) { scroll-padding-top: 4rem; scroll-padding-bottom: calc(4.4r
151
166
  position: fixed; left: 0; right: 0;
152
167
  bottom: calc(3.3rem + env(safe-area-inset-bottom));
153
168
  max-height: calc(100vh - 8rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)); overflow-y: auto;
154
- display: grid; grid-template-columns: 1fr 1fr; gap: 2px var(--hrl-gap-target);
169
+ display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--hrl-gap-target);
155
170
  background: var(--hrl-card); border-top: 1px solid var(--hrl-line);
156
171
  border-radius: var(--hrl-radius-lg) var(--hrl-radius-lg) 0 0;
157
172
  padding: .6rem .85rem;
@@ -161,15 +176,8 @@ html:has(.hrl-body) { scroll-padding-top: 4rem; scroll-padding-bottom: calc(4.4r
161
176
  links and would otherwise apply here too. */
162
177
  .hrl-sheet .hrl-nav__link {
163
178
  display: flex; justify-content: flex-start;
164
- min-height: var(--hrl-target); font-size: var(--hrl-t-body); padding: .5rem .6rem;
179
+ min-height: var(--hrl-target); min-width: 0; font-size: var(--hrl-t-body); padding: .5rem .6rem;
165
180
  }
166
- .hrl-sheet .hrl-side__group {
167
- color: var(--hrl-muted); font-size: var(--hrl-t-micro); font-weight: 700;
168
- text-transform: uppercase; letter-spacing: .06em;
169
- margin: .7rem 0 .15rem; padding: 0 .6rem;
170
- }
171
- .hrl-sheet .hrl-side__group, .hrl-sheet .hrl-side__back { grid-column: 1 / -1; }
172
- @media (min-width: 481px) { .hrl-sheet { grid-template-columns: 1fr; } }
173
181
  /* Scrim over the page while the sheet is open; tapping it closes the sheet (nav.js). */
174
182
  .hrl-tabbar__more[open]::before {
175
183
  content: ""; position: fixed; inset: 0 0 calc(3.3rem + env(safe-area-inset-bottom));
@@ -191,7 +199,7 @@ html:has(.hrl-body) { scroll-padding-top: 4rem; scroll-padding-bottom: calc(4.4r
191
199
  .hrl-side__brand { display: flex; align-items: center; min-height: var(--hrl-target); padding: .25rem .5rem 1rem; }
192
200
  .hrl-side__logo { max-height: 40px; max-width: 100%; display: block; }
193
201
  .hrl-side__brandname { font-family: var(--hrl-font-head); font-weight: 700; font-size: var(--hrl-t-lead); color: var(--hrl-ink); }
194
- .hrl-side__nav { display: flex; flex-direction: column; gap: 2px; }
202
+ .hrl-side__nav { display: flex; flex-direction: column; gap: var(--hrl-gap-target); }
195
203
  .hrl-side__nav .hrl-nav__link { display: flex; padding: .45rem .6rem; }
196
204
  .hrl-side__group {
197
205
  margin: .9rem .5rem .25rem; font-size: var(--hrl-t-micro); font-weight: 700;
@@ -504,7 +512,6 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
504
512
  .hrl-right { margin-left: auto; }
505
513
  .hrl-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
506
514
  .hrl-row--mb { margin-bottom: var(--hrl-s-3); }
507
- .hrl-row--tabs > .hrl-btn { padding-inline: var(--hrl-s-2); } /* four approvals tabs fit one row beside the rail at 768px */
508
515
 
509
516
  /* Long-form policy text. Constrained measure — a policy read across the
510
517
  full width of a laptop is a policy nobody finishes. */
@@ -616,7 +623,7 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
616
623
  .hrl-chart-scroll { overflow-x: auto; }
617
624
  .hrl-bars { width: 100%; min-width: 16rem; height: 10rem; }
618
625
  .hrl-bars__label { font-size: 5px; fill: var(--hrl-muted); }
619
- .hrl-timeline { width: 100%; height: 2rem; background: var(--hrl-bg); border-radius: var(--hrl-radius-sm); }
626
+ .hrl-daystrip { width: 100%; height: 2rem; background: var(--hrl-bg); border-radius: var(--hrl-radius-sm); }
620
627
  .hrl-timeline__auto { stroke: var(--hrl-card); stroke-width: .3; stroke-dasharray: .6 .4; }
621
628
  .hrl-timeline__axis { display: flex; justify-content: space-between; }
622
629
  .hrl-timeline__list { margin: .5rem 0 0; padding-left: 1.2rem; display: grid; gap: .25rem; }
@@ -15,11 +15,7 @@ module HrLite
15
15
  private
16
16
 
17
17
  def require_operations_access!
18
- return if hr_can?("leave.approve", scope: :team) ||
19
- hr_can?("attendance.manage", scope: :team) ||
20
- hr_can?("leave.view", scope: :team)
21
-
22
- hr_access_denied
18
+ hr_access_denied unless hr_operations?
23
19
  end
24
20
  end
25
21
  end
@@ -10,7 +10,7 @@ module HrLite
10
10
  before_action :hr_set_current_actor
11
11
  around_action :hr_use_time_zone
12
12
 
13
- helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name,
13
+ helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_operations?, :hr_display_name,
14
14
  :hr_can?, :hr_reaches?, :hr_access
15
15
 
16
16
  private
@@ -72,6 +72,12 @@ module HrLite
72
72
  HrLite.leadership?(hr_current_user)
73
73
  end
74
74
 
75
+ # Admin::BaseController's gate, and the menu's for its tabs: team scope is
76
+ # enough to reach the screen; each row is still checked by hr_require_reach!.
77
+ def hr_operations?
78
+ hr_can?("leave.approve", scope: :team) || hr_can?("attendance.manage", scope: :team) || hr_can?("leave.view", scope: :team)
79
+ end
80
+
75
81
  def hr_display_name(user)
76
82
  HrLite.display_name(user)
77
83
  end
@@ -1,76 +1,103 @@
1
1
  module HrLite
2
2
  module ApplicationHelper
3
- NAV_ITEMS = [
4
- { label: "Home", path: :root_path, match: [ "/" ], group: "My work" },
5
- { label: "Attendance", path: :attendance_path, match: [ "/attendance", "/regularization_requests", "/wfh_requests", "/work_locations" ], 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: "Timeline", path: :timeline_path, match: [ "/timeline" ], group: "Me" },
21
- { label: "CA review", path: :admin_ca_reviews_path, match: [ "/admin/ca_reviews" ], group: "Company",
22
- if: -> { hr_can?("payroll.verify", scope: :all) || hr_can?("payroll.approve", scope: :all) } },
23
- { label: "Policies", path: :policies_path, match: [ "/policies" ], group: "Company" },
24
- { label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ], group: "Company" },
25
- { label: "Org", path: :org_path, match: [ "/org" ], group: "Company" },
26
- { label: "Kudos", path: :kudos_path, match: [ "/kudos" ], group: "Company" },
3
+ # Who may see a tab. OPS is the operations pages' own check; the rest are the
4
+ # rules the old menu items had. Either way a tab never leads to a refusal.
5
+ MANAGE = -> { hr_admin? || hr_leadership? }
6
+ OPS = -> { hr_operations? }
7
+ MANAGE_OR_CLAIMS = -> { hr_admin? || hr_leadership? || hr_can?("expense.approve", scope: :team) }
8
+ LEAD = -> { hr_leadership? }
9
+ SUPER = -> { hr_superadmin? }
10
+ GEO = -> { HrLite.config.office_geofence }
11
+
12
+ # The menu: nine places, each with tabs. The rail and the phone bar list
13
+ # the places; the tab row lists the current place's tabs. A place shows
14
+ # when one of its tabs does, and opens on the first. One URL, one home.
15
+ SECTIONS = [
16
+ { label: "Home", tabs: [
17
+ { label: "Today", path: :root_path, match: [ "/" ] },
18
+ { label: "Company overview", path: :admin_overview_path, match: [ "/admin/overview" ], if: OPS }
19
+ ] },
20
+ { label: "Attendance", tabs: [
21
+ { label: "My attendance", path: :attendance_path, match: [ "/attendance", "/regularization_requests", "/wfh_requests", "/work_locations" ] },
22
+ { label: "My work time", path: :work_time_path, match: [ "/work_time" ], if: GEO },
23
+ { label: "Team", path: :admin_attendances_path, match: [ "/admin/attendances" ], if: OPS },
24
+ { label: "Work modes", path: :admin_work_modes_path, match: [ "/admin/work_modes" ], if: -> { instance_exec(&GEO) && hr_operations? } },
25
+ { label: "Team work time", path: :admin_work_time_path, match: [ "/admin/work_time" ],
26
+ if: -> { instance_exec(&GEO) && hr_can?("work_analytics.view", scope: :team) && hr_operations? } }
27
+ ] },
28
+ { label: "Leave", tabs: [
29
+ { label: "My leave", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances", "/comp_off_requests" ] },
30
+ { label: "Calendar", path: :calendar_path, match: [ "/calendar", "/holidays" ] },
31
+ { label: "Team balances", path: :admin_leave_balances_path, match: [ "/admin/leave_balances" ], if: OPS }
32
+ ] },
27
33
  # Employee tier all the same — holding an approval IS the authorisation,
28
34
  # so a manager and a stand-in covering for one reach the same screen.
29
- { label: "Approvals", path: :approvals_path, match: [ "/approvals" ], group: "Company",
30
- if: -> { HrLite::ApprovalFlow.where(active: true).exists? } }
31
- ].freeze
32
-
33
- ADMIN_NAV_ITEMS = [
34
- { label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
35
- { label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
36
- { label: "Work modes", path: :admin_work_modes_path, match: [ "/admin/work_modes" ],
37
- if: -> { HrLite.config.office_geofence } },
38
- { label: "Work time", path: :admin_work_time_path, match: [ "/admin/work_time" ],
39
- if: -> { HrLite.config.office_geofence && hr_can?("work_analytics.view", scope: :team) } },
40
- { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests", "/admin/wfh_requests", "/admin/work_locations" ] },
41
- { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] },
42
- { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ],
43
- if: -> { hr_can?("expense.approve", scope: :team) } },
44
- { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
45
- { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
46
- { label: "Benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ] },
47
- # HR approves payroll without running it; Super Admin reaches it under Organisation.
48
- { label: "Payroll approval", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs" ],
49
- if: -> { hr_can?("payroll.approve", scope: :all) && !hr_superadmin? } },
50
- { label: "Tax checks", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ],
51
- if: -> { hr_can?("tax.view", scope: :all) && !hr_superadmin? } },
52
- { label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
53
- ].freeze
54
-
55
- LEADERSHIP_NAV_ITEMS = [
56
- { label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
57
- { label: "Awards", path: :admin_awards_path, match: [ "/admin/awards" ] },
58
- { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting", "/admin/expense_categories" ] },
59
- { label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
60
- { label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
61
- { label: "Documents", path: :admin_documents_path, match: [ "/admin/documents" ] }
62
- ].freeze
63
-
64
- # Money screens, plus role management: granting somebody payroll is the
65
- # same act as holding it, so it sits with the pay rather than with the
66
- # other governing screens.
67
- SUPERADMIN_NAV_ITEMS = [
68
- { label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
69
- { label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ] },
70
- { label: "Rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ] },
71
- { label: "Tax", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ] },
72
- { label: "Loans", path: :admin_loans_path, match: [ "/admin/loans" ] },
73
- { label: "PIPs", path: :admin_performance_plans_path, match: [ "/admin/performance_plans" ] }
35
+ # `queue:` is what the tab's page lets this viewer decide, scoped as that
36
+ # page scopes it; the counts and the All list read it. All leads the place
37
+ # whenever any queue tab shows.
38
+ { label: "Inbox", tabs: [
39
+ { label: "All", lead: true, path: :approvals_path, match: [ "/approvals" ], if: -> { HrLite::ApprovalFlow.where(active: true).exists? } },
40
+ { label: "Leave requests", path: :admin_leave_requests_path, match: [ "/admin/leave_requests" ], if: OPS,
41
+ queue: -> { hrl_queue(HrLite::LeaveRequest.where(status: "pending"), "leave.approve") } },
42
+ { label: "Comp-off", path: :admin_comp_off_requests_path, match: [ "/admin/comp_off_requests" ], if: OPS,
43
+ queue: -> { hrl_queue(HrLite::CompOffRequest.pending, "leave.approve") } },
44
+ { label: "Attendance fixes", path: :admin_regularization_requests_path, match: [ "/admin/regularization_requests" ], if: OPS,
45
+ queue: -> { hrl_queue(HrLite::RegularizationRequest.pending, "attendance.manage") } },
46
+ { label: "WFH", path: :admin_wfh_requests_path, match: [ "/admin/wfh_requests" ], if: OPS,
47
+ queue: -> { hrl_queue(HrLite::WfhRequest.pending, "attendance.manage") } },
48
+ { label: "Home locations", path: :admin_work_locations_path, match: [ "/admin/work_locations" ], if: OPS,
49
+ queue: -> { hrl_queue(HrLite::WorkLocation.pending, "attendance.manage") } },
50
+ { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ], if: -> { hr_can?("expense.approve", scope: :team) },
51
+ queue: -> { hrl_queue(HrLite::Expense.where(status: "submitted"), "expense.approve") } },
52
+ { label: "Documents to verify", path: :admin_documents_path, match: [ "/admin/documents" ], if: LEAD,
53
+ queue: -> { hrl_queue(HrLite::Document.where(verification: "pending"), "document.view") } },
54
+ { label: "CA review", path: :admin_ca_reviews_path, match: [ "/admin/ca_reviews" ],
55
+ if: -> { hr_can?("payroll.verify", scope: :all) || hr_can?("payroll.approve", scope: :all) } }
56
+ ] },
57
+ { label: "Pay", tabs: [
58
+ { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
59
+ { label: "Expenses", path: :expenses_path, match: [ "/expenses" ], if: -> { hr_can?("expense.claim") } },
60
+ { label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
61
+ { label: "Loans", path: :loans_path, match: [ "/loans" ] },
62
+ { label: "Benefits", path: :benefits_path, match: [ "/benefits" ], if: -> { hr_can?("benefit.view") } },
63
+ # HR approves payroll without running it; Super Admin runs it. One page.
64
+ { label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips", "/admin/payroll_line_items" ],
65
+ if: -> { hr_superadmin? || (hr_can?("payroll.approve", scope: :all) && instance_exec(&MANAGE_OR_CLAIMS)) } },
66
+ { label: "Tax checks", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ],
67
+ if: -> { hr_superadmin? || (hr_can?("tax.view", scope: :all) && instance_exec(&MANAGE_OR_CLAIMS)) } },
68
+ { label: "Team loans", path: :admin_loans_path, match: [ "/admin/loans" ], if: SUPER },
69
+ { label: "Team benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ], if: MANAGE }
70
+ ] },
71
+ { label: "People", match: [ "/people" ], tabs: [
72
+ { label: "My team", path: :team_path, match: [ "/team" ] },
73
+ { label: "Org chart", path: :org_path, match: [ "/org" ] },
74
+ { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
75
+ { label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ], if: LEAD },
76
+ { label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ], if: MANAGE },
77
+ { label: "Awards", path: :admin_awards_path, match: [ "/admin/awards" ], if: LEAD },
78
+ { label: "PIPs", path: :admin_performance_plans_path, match: [ "/admin/performance_plans" ], if: SUPER }
79
+ ] },
80
+ { label: "Me", tabs: [
81
+ { label: "Profile", path: :employee_profile_path, match: [ "/profile", "/resignation" ] },
82
+ { label: "Documents", path: :documents_path, match: [ "/documents" ] },
83
+ { label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
84
+ { label: "Career", path: :career_path, match: [ "/career", "/appraisals" ] },
85
+ { label: "Timeline", path: :timeline_path, match: [ "/timeline" ] }
86
+ ] },
87
+ { label: "Help", tabs: [
88
+ { label: "Policies", path: :policies_path, match: [ "/policies" ], if: -> { hr_can?("policy.view", scope: :all) } },
89
+ { label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ], if: -> { hr_can?("hr_request.raise") } },
90
+ { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ], if: MANAGE },
91
+ { label: "Manage policies", path: :admin_policies_path, match: [ "/admin/policies" ], if: -> { hr_can?("policy.manage", scope: :all) } }
92
+ ] },
93
+ { label: "Admin", divider: true, tabs: [
94
+ { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting", "/admin/expense_categories" ], if: LEAD },
95
+ { label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ], if: -> { hr_can?("role.manage", scope: :all) } },
96
+ { label: "Statutory rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ], if: SUPER },
97
+ { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ], if: -> { hr_can?("expense.approve", scope: :all) || %w[leave.view attendance.view profile.view].any? { |key| hr_can?(key, scope: :team) } } },
98
+ { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ], if: MANAGE },
99
+ { label: "Audit trail", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ], if: LEAD }
100
+ ] }
74
101
  ].freeze
75
102
 
76
103
  # DayStatus kind => [code, word, month-grid cell modifier]: the code keeps a state readable without its colour.
@@ -105,12 +132,65 @@ module HrLite
105
132
  end
106
133
  end
107
134
 
108
- def hrl_nav_link(item)
135
+ # A place carries its icon (tabs carry none); `hint` adds a second line under the label.
136
+ def hrl_nav_link(item, hint: nil)
109
137
  active = hrl_nav_active?(item)
110
- link_to item[:label], hr_lite.public_send(item[:path]),
111
- class: "hrl-nav__link #{'hrl-nav__link--active' if active}", aria: { current: ("page" if active) }
138
+ count = hrl_waiting(item)
139
+ text = hint ? tag.span(safe_join([ item[:label], tag.span(hint, class: "hrl-nav__hint") ]), class: "hrl-nav__text") : item[:label]
140
+ link_to hr_lite.public_send(item[:path]), class: "hrl-nav__link #{'hrl-nav__link--active' if active}", aria: { current: (item.fetch(:current, "page") if active) } do
141
+ safe_join([ (hrl_icon(item[:label]) if item[:tabs]), text, (tag.span(count, class: "hrl-badge hrl-badge--warn") if count&.positive?) ].compact, " ")
142
+ end
112
143
  end
113
144
 
145
+ # How many wait in a tab's queue, or across a place's queue tabs; counted once per page.
146
+ def hrl_waiting(item)
147
+ queues = (item[:tabs] || [ item ]).select { |t| t[:queue] }
148
+ queues.sum { |t| (@hrl_waiting ||= {})[t[:label]] ||= instance_exec(&t[:queue]).count } if queues.any?
149
+ end
150
+
151
+ # What a queue page lets this viewer decide: their reach, never their own requests.
152
+ def hrl_queue(relation, key) = hr_access.scope_relation(relation, key).where.not(user_id: hr_current_user.id)
153
+
154
+ # Everything waiting in this viewer's queues, oldest first, as [tab, record].
155
+ def hrl_inbox_rows(limit = 50)
156
+ inbox = hrl_sections.find { |s| s[:label] == "Inbox" } or return []
157
+ inbox[:tabs].select { |t| t[:queue] }
158
+ .flat_map { |t| instance_exec(&t[:queue]).includes(:user).order(:created_at).limit(limit).map { |r| [ t, r ] } }
159
+ .sort_by { |_, r| r.created_at }.first(limit)
160
+ end
161
+
162
+ # One stroke icon per place. Decoration only: the label always shows beside it.
163
+ ICONS = {
164
+ "Home" => '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>',
165
+ "Attendance" => '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
166
+ "Leave" => '<rect x="3" y="4" width="18" height="17" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
167
+ "Inbox" => '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5.1 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.5-6.9A2 2 0 0 0 16.8 4H7.2a2 2 0 0 0-1.7 1.1z"/>',
168
+ "Pay" => '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M6 15h4"/>',
169
+ "People" => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/>',
170
+ "Me" => '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
171
+ "Help" => '<circle cx="12" cy="12" r="9"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/>',
172
+ "Admin" => '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
173
+ "More" => '<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>'
174
+ }.freeze
175
+
176
+ def hrl_icon(name)
177
+ paths = ICONS[name] or return
178
+ tag.svg(paths.html_safe, class: "hrl-icon", viewBox: "0 0 24 24", aria: { hidden: true }, focusable: "false") # rubocop:disable Rails/OutputSafety -- constant markup above
179
+ end
180
+
181
+ # Visible places, each with its visible tabs; a place links to its first
182
+ # tab and lights for any tab's paths (plus its own, e.g. a colleague's timeline).
183
+ def hrl_sections
184
+ @hrl_sections ||= SECTIONS.filter_map do |section|
185
+ rest = hrl_nav_items(section[:tabs].reject { |t| t[:lead] })
186
+ # A lead tab also shows whenever any other tab in its place does.
187
+ tabs = section[:tabs].select { |t| t[:lead] && hr_lite_route?(t[:path]) && (rest.any? || instance_exec(&t[:if])) } + rest
188
+ section.merge(tabs: tabs, path: tabs.first[:path], current: "true", match: tabs.flat_map { |t| t[:match] } + section.fetch(:match, [])) if tabs.any?
189
+ end
190
+ end
191
+
192
+ def hrl_current_section = hrl_sections.find { |section| hrl_nav_active?(section) }
193
+
114
194
  # Mention-marker-aware, XSS-safe kudos message rendering. Every literal
115
195
  # chunk is escaped; only markers whose id has a mention row become
116
196
  # highlighted chips (with the CURRENT display name — stale-name safe).
@@ -23,6 +23,7 @@ module HrLite
23
23
  STATUSES.each { |s| define_method("#{s}?") { status == s } }
24
24
 
25
25
  def self.approved_for(user_id, date) = approved.where(user_id: user_id).overlapping(date, date).first
26
+ def self.pending_for(user_id, date) = pending.where(user_id: user_id).overlapping(date, date).first
26
27
 
27
28
  # HR grants WFH outright: approved on creation, up to HR_MAX_DAYS long.
28
29
  def self.grant!(user_id:, by:, start_date:, end_date:, reason:)
@@ -67,6 +67,7 @@ module HrLite
67
67
  record.work_mode = @mode
68
68
  record.work_location_id = @location&.id
69
69
  record.wfh_request_id = @wfh&.id if @mode == "wfh"
70
+ record.add_flag!("WFH request pending HR approval: #{@wfh.reason}") if @wfh&.pending?
70
71
  when :check_out
71
72
  return Result.new(record: record, error: "Check in first.") if record.check_in_at.blank?
72
73
 
@@ -124,7 +125,8 @@ module HrLite
124
125
  return
125
126
  end
126
127
 
127
- @wfh = WfhRequest.approved_for(@user.id, Date.current)
128
+ # A request HR has not decided yet still lets the person work; the day is flagged for HR.
129
+ @wfh = WfhRequest.approved_for(@user.id, Date.current) || WfhRequest.pending_for(@user.id, Date.current)
128
130
  return OFF_SITE_MESSAGE unless @wfh || profile&.permanent_wfh
129
131
 
130
132
  @mode, @place = "wfh", "home"
@@ -1,4 +1,5 @@
1
1
  <% content_for(:page_title) { "Attendance — #{hr_display_name(@employee)}" } %>
2
+ <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @employee } %>
2
3
  <div class="hrl-page__head">
3
4
  <h1 class="hrl-page__title"><%= hr_display_name(@employee) %></h1>
4
5
  <div class="hrl-page__actions">
@@ -3,8 +3,6 @@
3
3
  <h1 class="hrl-page__title">Comp-off approvals</h1>
4
4
  </div>
5
5
 
6
- <%= render "hr_lite/admin/shared/approvals_tabs", active: :comp_off %>
7
-
8
6
  <div class="hrl-row hrl-row--mb">
9
7
  <span class="hrl-small hrl-muted">Status</span>
10
8
  <% HrLite::CompOffRequest::STATUSES.each do |status| %>
@@ -1,4 +1,5 @@
1
1
  <% content_for(:page_title) { hr_display_name(@employee) } %>
2
+ <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @employee } %>
2
3
  <div class="hrl-page__head">
3
4
  <h1 class="hrl-page__title"><%= hr_display_name(@employee) %> — documents</h1>
4
5
  </div>
@@ -1,17 +1,24 @@
1
1
  <% content_for(:page_title) { @profile.employee_code } %>
2
+ <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @profile.user } %>
2
3
  <div class="hrl-page__head">
3
4
  <h1 class="hrl-page__title"><%= hr_display_name(@profile.user) %> <span class="hrl-muted">(<%= @profile.employee_code %>)</span></h1>
4
5
  <div class="hrl-page__actions">
5
6
  <a class="hrl-btn" href="<%= hr_lite.edit_admin_employee_path(@profile) %>">Edit</a>
6
- <a class="hrl-btn" href="<%= hr_lite.id_card_path(user_id: @profile.user_id) %>">ID card</a>
7
- <a class="hrl-btn" href="<%= hr_lite.person_timeline_path(@profile.user_id) %>">Timeline</a>
8
- <% if hr_superadmin? %>
9
- <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_appraisal_path(@profile) %>">New appraisal</a>
10
- <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_designation_change_path(@profile) %>">Role change</a>
11
- <a class="hrl-btn" href="<%= hr_lite.new_admin_performance_plan_path(user_id: @profile.user_id) %>">Start PIP</a>
12
- <% end %>
13
- <% if hr_can?("salary.manage", scope: :all) %>
14
- <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_employee_salary_structure_path(@profile) %>">New structure</a>
7
+ <%# Their records are the tabs above; the HR actions fold into one Manage button. %>
8
+ <% if hr_superadmin? || hr_can?("salary.manage", scope: :all) %>
9
+ <details>
10
+ <summary class="hrl-btn">Manage</summary>
11
+ <div class="hrl-row">
12
+ <% if hr_superadmin? %>
13
+ <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_appraisal_path(@profile) %>">New appraisal</a>
14
+ <a class="hrl-btn" href="<%= hr_lite.new_admin_employee_designation_change_path(@profile) %>">Role change</a>
15
+ <a class="hrl-btn" href="<%= hr_lite.new_admin_performance_plan_path(user_id: @profile.user_id) %>">Start PIP</a>
16
+ <% end %>
17
+ <% if hr_can?("salary.manage", scope: :all) %>
18
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_employee_salary_structure_path(@profile) %>">New structure</a>
19
+ <% end %>
20
+ </div>
21
+ </details>
15
22
  <% end %>
16
23
  </div>
17
24
  </div>
@@ -3,8 +3,6 @@
3
3
  <h1 class="hrl-page__title">Team leave balances <%= HrLite::LeaveYear.label(@year) %></h1>
4
4
  </div>
5
5
 
6
- <%= render "hr_lite/admin/shared/approvals_tabs", active: :balances %>
7
-
8
6
  <section class="hrl-card">
9
7
  <div class="hrl-table-wrap">
10
8
  <table class="hrl-table hrl-table--stack">
@@ -6,8 +6,6 @@
6
6
  </div>
7
7
  </div>
8
8
 
9
- <%= render "hr_lite/admin/shared/approvals_tabs", active: :leaves %>
10
-
11
9
  <div class="hrl-row hrl-row--mb">
12
10
  <% HrLite::LeaveRequest::STATUSES.each do |status| %>
13
11
  <a class="hrl-btn <%= 'hrl-btn--primary' if @status == status %>"
@@ -3,8 +3,6 @@
3
3
  <h1 class="hrl-page__title">Regularization tickets</h1>
4
4
  </div>
5
5
 
6
- <%= render "hr_lite/admin/shared/approvals_tabs", active: :regularizations %>
7
-
8
6
  <div class="hrl-row hrl-row--mb">
9
7
  <span class="hrl-small hrl-muted">Status</span>
10
8
  <% HrLite::RegularizationRequest::STATUSES.each do |status| %>
@@ -1,4 +1,5 @@
1
1
  <% content_for(:page_title) { "Tax — #{hr_display_name(@person)}" } %>
2
+ <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @person } %>
2
3
  <div class="hrl-page__head">
3
4
  <h1 class="hrl-page__title">Tax — <%= hr_display_name(@person) %></h1>
4
5
  <div class="hrl-page__actions">
@@ -3,8 +3,6 @@
3
3
  <h1 class="hrl-page__title">Work from home</h1>
4
4
  </div>
5
5
 
6
- <%= render "hr_lite/admin/shared/approvals_tabs", active: :wfh %>
7
-
8
6
  <section class="hrl-card">
9
7
  <%= form_with url: hr_lite.admin_wfh_requests_path, method: :get, local: true do %>
10
8
  <div class="hrl-field"><label for="wfh-from">From</label><%= date_field_tag :from, @from, id: "wfh-from" %></div>
@@ -3,8 +3,6 @@
3
3
  <h1 class="hrl-page__title">WFH locations</h1>
4
4
  </div>
5
5
 
6
- <%= render "hr_lite/admin/shared/approvals_tabs", active: :locations %>
7
-
8
6
  <div class="hrl-row hrl-row--mb">
9
7
  <span class="hrl-small hrl-muted">Status</span>
10
8
  <% HrLite::WorkLocation::STATUSES.each do |status| %>
@@ -1,4 +1,5 @@
1
1
  <% content_for(:page_title) { "Work time" } %>
2
+ <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @person } if @person %>
2
3
  <div class="hrl-page__head">
3
4
  <h1 class="hrl-page__title">Work time<%= " — #{@names[@person.id]}" if @person %></h1>
4
5
  <% if @person %>
@@ -13,8 +13,9 @@
13
13
  </section>
14
14
  <% end %>
15
15
 
16
+ <% rows = hrl_inbox_rows %>
16
17
  <section class="hrl-card">
17
- <% if @approvals.empty? %>
18
+ <% if @approvals.empty? && rows.empty? %>
18
19
  <p class="hrl-muted">Nothing is waiting on you.</p>
19
20
  <% else %>
20
21
  <div class="hrl-table-wrap">
@@ -44,6 +45,15 @@
44
45
  </td>
45
46
  </tr>
46
47
  <% end %>
48
+ <%# The HR queues this viewer decides; Open lands on that queue's own page. %>
49
+ <% rows.each do |tab, record| %>
50
+ <tr>
51
+ <td data-label="Request"><strong><%= tab[:label] %></strong></td>
52
+ <td data-label="From"><%= hr_display_name(record.user) %></td>
53
+ <td data-label="Waiting"><%= time_ago_in_words(record.created_at) %></td>
54
+ <td data-label=""><a class="hrl-btn" href="<%= hr_lite.public_send(tab[:path]) %>">Open</a></td>
55
+ </tr>
56
+ <% end %>
47
57
  </tbody>
48
58
  </table>
49
59
  </div>
@@ -1,5 +1,5 @@
1
1
  <% content_for(:page_title) { "Comp-off" } %>
2
- <% content_for(:back, "Leaves") %>
2
+ <% content_for(:back, "Leave") %>
3
3
  <div class="hrl-page__head">
4
4
  <h1 class="hrl-page__title">My comp-off</h1>
5
5
  <div class="hrl-page__actions">
@@ -1,5 +1,5 @@
1
1
  <% content_for(:page_title) { "Request comp-off" } %>
2
- <% content_for(:back, "Leaves") %>
2
+ <% content_for(:back, "Leave") %>
3
3
  <div class="hrl-page__head">
4
4
  <h1 class="hrl-page__title">Request comp-off</h1>
5
5
  </div>
@@ -1,4 +1,5 @@
1
1
  <% content_for(:page_title) { "ID card" } %>
2
+ <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @user } if params[:user_id] %>
2
3
  <% company = HrLite.config.company.call %>
3
4
  <% brand = company[:brand].presence || company[:name].presence || "HR" %>
4
5
  <% own = @user == hr_current_user %>
@@ -1,5 +1,5 @@
1
1
  <% content_for(:page_title) { "Leave balances" } %>
2
- <% content_for(:back, "Leaves") %>
2
+ <% content_for(:back, "Leave") %>
3
3
  <div class="hrl-page__head">
4
4
  <h1 class="hrl-page__title">Leave balances <%= HrLite::LeaveYear.label(@year) %></h1>
5
5
  <div class="hrl-page__actions">
@@ -1,5 +1,5 @@
1
1
  <% content_for(:page_title) { "Apply for leave" } %>
2
- <% content_for(:back, "Leaves") %>
2
+ <% content_for(:back, "Leave") %>
3
3
  <div class="hrl-page__head">
4
4
  <h1 class="hrl-page__title">Apply for leave</h1>
5
5
  </div>
@@ -1,5 +1,5 @@
1
1
  <% content_for(:page_title) { "Leave request" } %>
2
- <% content_for(:back, "Leaves") %>
2
+ <% content_for(:back, "Leave") %>
3
3
  <div class="hrl-page__head">
4
4
  <h1 class="hrl-page__title">Leave request</h1>
5
5
  </div>
@@ -0,0 +1,21 @@
1
+ <%# locals: user — one person's records as tabs. Each shows only when its page would open for this viewer. %>
2
+ <% ops = hr_operations?
3
+ profile = HrLite::EmployeeProfile.find_by(user_id: user.id)
4
+ tabs = [
5
+ [ "Overview", (hr_lite.admin_employee_path(profile) if profile && hr_leadership?) ],
6
+ [ "Attendance", (hr_lite.admin_attendance_path(user.id) if ops && hr_reaches?("attendance.view", user)) ],
7
+ [ "Work time", (hr_lite.admin_work_time_path(user_id: user.id) if HrLite.config.office_geofence && ops && hr_can?("work_analytics.view", scope: :team) && hr_reaches?("work_analytics.view", user)) ],
8
+ [ "Documents", (hr_lite.admin_employee_documents_path(user.id) if hr_can?("document.view", scope: :team) && hr_reaches?("document.view", user)) ],
9
+ [ "Tax", (hr_lite.person_admin_tax_declarations_path(user.id) if hr_can?("tax.view", scope: :all) && hr_reaches?("tax.view", user)) ],
10
+ [ "Timeline", hr_lite.person_timeline_path(user.id) ],
11
+ [ "ID card", (hr_lite.id_card_path(user_id: user.id) if user == hr_current_user || hr_reaches?("profile.view", user)) ]
12
+ ].select(&:last) %>
13
+ <%# One tab is no choice: render nothing and the layout keeps the place's own tab row. %>
14
+ <% if tabs.many? %>
15
+ <nav class="hrl-tabrow" aria-label="<%= hr_display_name(user) %>'s records">
16
+ <% tabs.each do |label, url| %>
17
+ <% on = request.path == url.split("?").first %>
18
+ <%= link_to label, url, class: [ "hrl-nav__link", ("hrl-nav__link--active" if on) ], aria: { current: ("page" if on) } %>
19
+ <% end %>
20
+ </nav>
21
+ <% end %>
@@ -1,4 +1,5 @@
1
1
  <% content_for(:page_title) { "Timeline — #{hr_display_name(@person)}" } %>
2
+ <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @person } if params[:user_id] %>
2
3
  <% year_path = ->(y) { @person.id == hr_current_user.id ? hr_lite.timeline_path(year: y) : hr_lite.person_timeline_path(@person.id, year: y) } %>
3
4
  <% name = hr_display_name(@person) %>
4
5
  <div class="hrl-page__head">
@@ -6,7 +6,7 @@
6
6
 
7
7
  <section class="hrl-card">
8
8
  <h2 class="hrl-card__title">Request WFH</h2>
9
- <p class="hrl-small hrl-muted">Pick dates from today up to <%= HrLite::WfhRequest::MAX_DAYS_AHEAD %> days ahead. HR approves or rejects; only approved dates let you check in away from the office.</p>
9
+ <p class="hrl-small hrl-muted">Pick dates from today up to <%= HrLite::WfhRequest::MAX_DAYS_AHEAD %> days ahead. HR approves or rejects. While a request is waiting you can still check in away from the office; HR sees the reason. A rejected date cannot.</p>
10
10
  <%= form_with model: @request, url: hr_lite.wfh_requests_path, scope: :wfh_request, local: true do |f| %>
11
11
  <%= render "hr_lite/shared/form_errors", record: @request %>
12
12
  <% min, max = @window.first.iso8601, @window.last.iso8601 %>
@@ -6,7 +6,7 @@
6
6
  <h2 class="hrl-card__title">The day, hour by hour</h2>
7
7
  <% rows.each do |user_id, segs| %>
8
8
  <% if names %><p class="hrl-small"><strong><%= names[user_id] %></strong></p><% end %>
9
- <svg class="hrl-timeline" viewBox="0 0 100 10" preserveAspectRatio="none" role="img" aria-label="Work modes through the day; details in the list below">
9
+ <svg class="hrl-daystrip" viewBox="0 0 100 10" preserveAspectRatio="none" role="img" aria-label="Work modes through the day; details in the list below">
10
10
  <% segs.each do |seg| %>
11
11
  <rect x="<%= pct.(seg.from).round(3) %>" y="1" width="<%= [ pct.(seg.to) - pct.(seg.from), 0.15 ].max.round(3) %>" height="8"
12
12
  class="<%= class_names("hrl-fill--#{seg.mode}", "hrl-timeline__auto": seg.source == "system") %>" data-hrl-tip tabindex="0">
@@ -26,34 +26,9 @@
26
26
  </a>
27
27
 
28
28
  <nav class="hrl-side__nav">
29
- <% hrl_nav_items(HrLite::ApplicationHelper::NAV_ITEMS).chunk { |item| item[:group] }.each do |group, items| %>
30
- <span class="hrl-side__group"><%= group %></span>
31
- <% items.each do |item| %>
32
- <%= hrl_nav_link(item) %>
33
- <% end %>
34
- <% end %>
35
-
36
- <% if hr_admin? || hr_leadership? || hr_can?("expense.approve", scope: :team) %>
37
- <span class="hrl-side__group">Manage</span>
38
- <% hrl_nav_items(HrLite::ApplicationHelper::ADMIN_NAV_ITEMS).each do |item| %>
39
- <%= hrl_nav_link(item) if item[:if] || hr_admin? || hr_leadership? %>
40
- <% end %>
41
- <% end %>
42
-
43
- <% if hr_leadership? || hr_superadmin? %>
44
- <span class="hrl-side__group">Organisation</span>
45
- <% if hr_leadership? %>
46
- <% hrl_nav_items(HrLite::ApplicationHelper::LEADERSHIP_NAV_ITEMS).each do |item| %>
47
- <%= hrl_nav_link(item) %>
48
- <% end %>
49
- <% end %>
50
- <%# Payroll hangs off the money tier alone — a superadmin who is not
51
- in leadership_emails still needs to reach it. %>
52
- <% if hr_superadmin? %>
53
- <% hrl_nav_items(HrLite::ApplicationHelper::SUPERADMIN_NAV_ITEMS).each do |item| %>
54
- <%= hrl_nav_link(item) %>
55
- <% end %>
56
- <% end %>
29
+ <% hrl_sections.each do |section| %>
30
+ <% if section[:divider] %><span class="hrl-side__divider" aria-hidden="true"></span><% end %>
31
+ <%= hrl_nav_link(section) %>
57
32
  <% end %>
58
33
  </nav>
59
34
 
@@ -81,8 +56,16 @@
81
56
  </header>
82
57
 
83
58
  <main class="hrl-main" id="hrl-main" tabindex="-1">
84
- <%# A sub-screen names its parent tab: content_for(:back, "Leaves"). %>
85
- <% if (tab = HrLite::ApplicationHelper::NAV_ITEMS.find { |i| i[:label] == content_for(:back) }) %>
59
+ <%# A page about one person puts that person's tabs here instead (shared/_person_tabs). %>
60
+ <% if content_for?(:tabrow) %>
61
+ <%= yield :tabrow %>
62
+ <% elsif (section = hrl_current_section) && section[:tabs].many? %>
63
+ <nav class="hrl-tabrow" aria-label="<%= section[:label] %> sections">
64
+ <% section[:tabs].each do |tab| %><%= hrl_nav_link(tab) %><% end %>
65
+ </nav>
66
+ <% end %>
67
+ <%# A sub-screen names its parent place or tab: content_for(:back, "Leave"). %>
68
+ <% if (tab = (hrl_sections + hrl_sections.flat_map { |s| s[:tabs] }).find { |i| i[:label] == content_for(:back) }) %>
86
69
  <a class="hrl-backlink" href="<%= hr_lite.public_send(tab[:path]) %>"><span aria-hidden="true">‹</span> <%= tab[:label] %></a>
87
70
  <% end %>
88
71
  <% if flash[:notice].present? %><div class="hrl-flash hrl-flash--ok" role="status"><%= flash[:notice] %></div><% end %>
@@ -100,41 +83,20 @@
100
83
  leadership and payroll screen were unreachable. <details> keeps it
101
84
  dependency-free; the engine ships no Turbo or Stimulus. %>
102
85
  <nav class="hrl-tabbar" aria-label="HR sections">
103
- <% primary, rest = hrl_nav_items(HrLite::ApplicationHelper::NAV_ITEMS).partition.with_index { |_, i| i < 4 } %>
104
- <% primary.each do |item| %>
105
- <%= hrl_nav_link(item) %>
86
+ <% primary, rest = hrl_sections.partition.with_index { |_, i| i < 4 } %>
87
+ <% primary.each do |section| %>
88
+ <%= hrl_nav_link(section) %>
106
89
  <% end %>
107
90
  <details class="hrl-tabbar__more">
108
- <% lit = rest.any? { |i| hrl_nav_active?(i) } || request.path.include?("/admin/") %>
109
- <%= tag.summary "More", class: [ "hrl-nav__link", ("hrl-nav__link--active" if lit) ], aria: { current: ("true" if lit) } %>
91
+ <% lit = rest.any? { |s| hrl_nav_active?(s) } %>
92
+ <%= tag.summary safe_join([ hrl_icon("More"), "More" ]), class: [ "hrl-nav__link", ("hrl-nav__link--active" if lit) ], aria: { current: ("true" if lit) } %>
110
93
  <div class="hrl-sheet">
111
94
  <% if HrLite.config.back_link %>
112
95
  <a class="hrl-nav__link hrl-side__back" href="<%= HrLite.config.back_link[:url] %>"><%= HrLite.config.back_link[:label] %></a>
113
96
  <% end %>
114
- <% rest.chunk { |item| item[:group] }.each do |group, items| %>
115
- <span class="hrl-side__group"><%= group %></span>
116
- <% items.each do |item| %>
117
- <%= hrl_nav_link(item) %>
118
- <% end %>
119
- <% end %>
120
- <% if hr_admin? || hr_leadership? || hr_can?("expense.approve", scope: :team) %>
121
- <span class="hrl-side__group">Manage</span>
122
- <% hrl_nav_items(HrLite::ApplicationHelper::ADMIN_NAV_ITEMS).each do |item| %>
123
- <%= hrl_nav_link(item) if item[:if] || hr_admin? || hr_leadership? %>
124
- <% end %>
125
- <% end %>
126
- <% if hr_leadership? || hr_superadmin? %>
127
- <span class="hrl-side__group">Organisation</span>
128
- <% if hr_leadership? %>
129
- <% hrl_nav_items(HrLite::ApplicationHelper::LEADERSHIP_NAV_ITEMS).each do |item| %>
130
- <%= hrl_nav_link(item) %>
131
- <% end %>
132
- <% end %>
133
- <% if hr_superadmin? %>
134
- <% hrl_nav_items(HrLite::ApplicationHelper::SUPERADMIN_NAV_ITEMS).each do |item| %>
135
- <%= hrl_nav_link(item) %>
136
- <% end %>
137
- <% end %>
97
+ <%# The second line names the place's tabs, so a phone user knows what is inside before opening it. %>
98
+ <% rest.each do |section| %>
99
+ <%= hrl_nav_link(section, hint: section[:tabs].map { |t| t[:label] }.join(", ")) %>
138
100
  <% end %>
139
101
  </div>
140
102
  </details>
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.37.0"
2
+ VERSION = "0.40.1"
3
3
  end
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.37.0
4
+ version: 0.40.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -330,7 +330,6 @@ files:
330
330
  - app/views/hr_lite/admin/salary_structures/edit.html.erb
331
331
  - app/views/hr_lite/admin/salary_structures/new.html.erb
332
332
  - app/views/hr_lite/admin/settings/edit.html.erb
333
- - app/views/hr_lite/admin/shared/_approvals_tabs.html.erb
334
333
  - app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb
335
334
  - app/views/hr_lite/admin/statutory_rate_cards/edit.html.erb
336
335
  - app/views/hr_lite/admin/statutory_rate_cards/index.html.erb
@@ -402,6 +401,7 @@ files:
402
401
  - app/views/hr_lite/shared/_bell.html.erb
403
402
  - app/views/hr_lite/shared/_form_errors.html.erb
404
403
  - app/views/hr_lite/shared/_pagination.html.erb
404
+ - app/views/hr_lite/shared/_person_tabs.html.erb
405
405
  - app/views/hr_lite/tax/_computation.html.erb
406
406
  - app/views/hr_lite/tax_declarations/show.html.erb
407
407
  - app/views/hr_lite/team/show.html.erb
@@ -1,20 +0,0 @@
1
- <%# locals: active (:leaves | :comp_off | :regularizations | :wfh | :locations | :balances) %>
2
- <% tab = ->(key) { tag.attributes(class: [ "hrl-btn", ("hrl-btn--primary" if active == key) ], aria: { current: ("page" if active == key) }) } %>
3
- <div class="hrl-row hrl-row--mb hrl-row--tabs">
4
- <a <%= tab.(:leaves) %>
5
- href="<%= hr_lite.admin_leave_requests_path %>">Leaves</a>
6
- <a <%= tab.(:comp_off) %>
7
- href="<%= hr_lite.admin_comp_off_requests_path %>">Comp-off
8
- <% if (n = HrLite::CompOffRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
9
- <a <%= tab.(:regularizations) %>
10
- href="<%= hr_lite.admin_regularization_requests_path %>">Regularization
11
- <% if (n = HrLite::RegularizationRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
12
- <a <%= tab.(:wfh) %>
13
- href="<%= hr_lite.admin_wfh_requests_path %>">WFH
14
- <% if (n = HrLite::WfhRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
15
- <a <%= tab.(:locations) %>
16
- href="<%= hr_lite.admin_work_locations_path %>">Locations
17
- <% if (n = HrLite::WorkLocation.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
18
- <a <%= tab.(:balances) %>
19
- href="<%= hr_lite.admin_leave_balances_path %>">Balances</a>
20
- </div>