hr_lite 0.22.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +10 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +18 -0
- data/app/controllers/hr_lite/admin/awards_controller.rb +43 -0
- data/app/controllers/hr_lite/admin/performance_plans_controller.rb +46 -0
- data/app/controllers/hr_lite/home_controller.rb +1 -0
- data/app/controllers/hr_lite/timelines_controller.rb +19 -0
- data/app/helpers/hr_lite/application_helper.rb +4 -1
- data/app/models/hr_lite/audit_log.rb +1 -0
- data/app/models/hr_lite/award.rb +50 -0
- data/app/models/hr_lite/performance_plan.rb +43 -0
- data/app/services/hr_lite/timeline.rb +183 -0
- data/app/views/hr_lite/admin/awards/_form.html.erb +24 -0
- data/app/views/hr_lite/admin/awards/edit.html.erb +3 -0
- data/app/views/hr_lite/admin/awards/index.html.erb +30 -0
- data/app/views/hr_lite/admin/awards/new.html.erb +3 -0
- data/app/views/hr_lite/admin/employees/show.html.erb +2 -0
- data/app/views/hr_lite/admin/performance_plans/_form.html.erb +29 -0
- data/app/views/hr_lite/admin/performance_plans/edit.html.erb +3 -0
- data/app/views/hr_lite/admin/performance_plans/index.html.erb +30 -0
- data/app/views/hr_lite/admin/performance_plans/new.html.erb +3 -0
- data/app/views/hr_lite/home/index.html.erb +19 -0
- data/app/views/hr_lite/org/_node.html.erb +1 -1
- data/app/views/hr_lite/timelines/_events.html.erb +20 -0
- data/app/views/hr_lite/timelines/show.html.erb +36 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20261010090000_create_hr_lite_performance_plans.rb +22 -0
- data/db/migrate/20261010090100_create_hr_lite_awards.rb +17 -0
- data/lib/hr_lite/notifications.rb +3 -0
- data/lib/hr_lite/version.rb +1 -1
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e83919f345e7b70bbe33b17937f9b44c70b04d1d58919d3d24607ac3931ba86
|
|
4
|
+
data.tar.gz: '0973e68fde9009f3b6e0e47de86a06149a97ed0158bc5c289740b5469192c82e'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 62e9b4d98c7f8f3743129b731d960c0879f9c71aa808536e5fde70c16f6f20eb6c0c26960cfa5c2cfd29396dfdfcbd22dab3a6329f482e39e35795df5cea9727
|
|
7
|
+
data.tar.gz: 07dcf6f864bc9675f5710b887ac7879f2996f22c926d64e3672d8b7d97f191d50bdedc4c7a190784c81525b87baf5acb21e9aff04fd8b208637d15a497d58231
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.23.0] - 2026-10-10
|
|
11
|
+
|
|
12
|
+
Two migrations: `hr_lite_performance_plans` and `hr_lite_awards`. No new permission keys.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Employee timeline: `/timeline` (your own) and `/people/:user_id/timeline` (anyone's), with a year picker; every name on the org chart links to it, and the admin employee page has a Timeline button. A header card gives role, department, joining date and tenure. Below it, two cards on a dot-and-rail list grouped by month, each event labelled in words (Leave, Promotion, Kudos, Award, Pay, Performance…). "Public timeline", which everyone sees: joining and exit, role changes in effect, kudos received and given, awards, and approved leave (colleagues see "On leave" without the type). "Private records", on a shaded card with a dashed rail and hollow dots, shown only to the employee and to whoever the permission reaches, and absent altogether for anyone else: bonus, incentive and arrears items and loans (`payroll.view`), approved and reimbursed expenses (`expense.approve`), shared appraisals and PIPs (`appraisal.view`), approved attendance fixes (`attendance.view`), comp-off earned (`leave.view`), assets given and returned (`asset.view`), verified documents (each document's own `readable_by?`), resignation filed and accepted (`resignation.view`), probation end (`profile.view`) and resolved help-desk requests (`hr_request.manage`). New CSS: `.hrl-timeline`, `.hrl-timeline__month`, `.hrl-timeline__item`, `.hrl-timeline__title`, `.hrl-card--private`. `HrLite::Timeline` reads existing records only.
|
|
16
|
+
- Performance improvement plans (`HrLite::PerformancePlan`): Super Admin starts one at Organisation → PIPs (`/admin/performance_plans`, or "Start PIP" on the employee page) with goals and a review date, then marks it extended, passed or failed; passed or failed stamps `closed_at` and freezes the plan. The employee hears `pip.started` and `pip.closed` (bell and email, never leadership). PIPs are money tier in the audit trail, like appraisals.
|
|
17
|
+
- Employee of the Month, Quarter and Year (`HrLite::Award`): Leadership picks the winner and writes a citation at Organisation → Awards (`/admin/awards`); one winner per period, saved as the period's first day. The winner hears `award.won`, and a Home card shows the current winner of each to everyone.
|
|
18
|
+
- Deviation from the plan: PIPs sit with appraisals under the Super Admin screens (`SuperadminController`) and Awards under Leadership (`LeadershipController`), rather than new permission keys, so existing installs need no role changes.
|
|
19
|
+
|
|
10
20
|
## [0.22.1] - 2026-10-09
|
|
11
21
|
|
|
12
22
|
### Added
|
data/README.md
CHANGED
|
@@ -363,6 +363,16 @@ visible to everyone and shows the reporting tree — names, designations and
|
|
|
363
363
|
departments only, never salary or private data — with each viewer's own
|
|
364
364
|
reporting line labelled L1/L2/…
|
|
365
365
|
|
|
366
|
+
Every employee has a timeline (`/timeline`, a colleague's at
|
|
367
|
+
`/people/:user_id/timeline`, linked from the org chart): joining and exit,
|
|
368
|
+
role changes, kudos received, awards and leave dates are public; leave type,
|
|
369
|
+
bonus and other pay items, loans, expenses, appraisals and performance
|
|
370
|
+
improvement plans show only to the employee and to whoever the matching
|
|
371
|
+
permission reaches (`leave.view`, `payroll.view`, `expense.approve`,
|
|
372
|
+
`appraisal.view`). Super Admin writes PIPs at `/admin/performance_plans`;
|
|
373
|
+
Leadership picks Employee of the Month, Quarter and Year at `/admin/awards`,
|
|
374
|
+
and Home shows the current winners to everyone.
|
|
375
|
+
|
|
366
376
|
## Recurring jobs
|
|
367
377
|
|
|
368
378
|
Schedule these on the host's job scheduler (cron, GoodJob, Sidekiq-cron,
|
|
@@ -373,6 +373,23 @@ summary.hrl-btn::-webkit-details-marker { display: none; } /* Safari before 18.4
|
|
|
373
373
|
.hrl-feed__meta { color: var(--hrl-muted); font-size: var(--hrl-t-small); }
|
|
374
374
|
.hrl-feed__body { min-width: 0; flex: 1; overflow-wrap: anywhere; }
|
|
375
375
|
|
|
376
|
+
/* Employee timeline: a rail with a dot per event, grouped by month. Private
|
|
377
|
+
records sit on a shaded card with a dashed rail and hollow dots, and say
|
|
378
|
+
"Private" in words, so the difference never rests on colour alone. */
|
|
379
|
+
.hrl-timeline__month { margin: var(--hrl-s-4) 0 var(--hrl-s-2); font-size: var(--hrl-t-small); color: var(--hrl-muted); }
|
|
380
|
+
.hrl-timeline { list-style: none; margin: 0; padding: 0 0 0 var(--hrl-s-5); border-left: 2px solid var(--hrl-line); }
|
|
381
|
+
.hrl-timeline__item { position: relative; padding: 0 0 var(--hrl-s-4); min-width: 0; overflow-wrap: anywhere; }
|
|
382
|
+
.hrl-timeline__item::before {
|
|
383
|
+
content: ""; position: absolute; left: calc(-1 * var(--hrl-s-5) - 7px); top: 6px;
|
|
384
|
+
width: 12px; height: 12px; border-radius: var(--hrl-radius-pill); background: var(--hrl-accent);
|
|
385
|
+
}
|
|
386
|
+
.hrl-timeline__title { margin: var(--hrl-s-1) 0 0; font-weight: 600; }
|
|
387
|
+
.hrl-timeline__item p.hrl-feed__meta { margin: 2px 0 0; }
|
|
388
|
+
.hrl-card--private { background: var(--hrl-bg); border-style: dashed; }
|
|
389
|
+
.hrl-card--private .hrl-timeline { border-left-style: dashed; border-left-color: var(--hrl-field); }
|
|
390
|
+
.hrl-card--private .hrl-timeline__item::before { background: var(--hrl-bg); border: 2px solid var(--hrl-field); }
|
|
391
|
+
.hrl-card--private .hrl-badge--muted { background: var(--hrl-card); }
|
|
392
|
+
|
|
376
393
|
/* Mention autocomplete menu */
|
|
377
394
|
.hrl-mention-wrap { position: relative; }
|
|
378
395
|
.hrl-mention-menu {
|
|
@@ -403,6 +420,7 @@ summary.hrl-btn::-webkit-details-marker { display: none; } /* Safari before 18.4
|
|
|
403
420
|
.hrl-tree__card--me { border-color: var(--hrl-accent); background: var(--hrl-info-bg); }
|
|
404
421
|
.hrl-tree__name, .hrl-tree__role { min-width: 0; overflow-wrap: anywhere; }
|
|
405
422
|
.hrl-tree__name { font-weight: 700; font-size: var(--hrl-t-small); }
|
|
423
|
+
a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--hrl-target); }
|
|
406
424
|
.hrl-tree__role { color: var(--hrl-muted); font-size: var(--hrl-t-small); }
|
|
407
425
|
.hrl-chainrow { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
|
408
426
|
.hrl-chainrow__step { display: inline-flex; align-items: center; gap: .4rem; min-width: 0; }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Employee of the month, quarter and year. Leadership picks the winner.
|
|
4
|
+
class AwardsController < LeadershipController
|
|
5
|
+
before_action :set_award, only: %i[edit update]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@awards = paginate(Award.includes(:user).recent_first)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def new
|
|
12
|
+
@award = Award.new(kind: "month", period_start: Date.current)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
@award = Award.new(award_params.merge(created_by_id: hr_current_user.id))
|
|
17
|
+
if @award.save
|
|
18
|
+
redirect_to admin_awards_path, notice: "#{@award.title} saved."
|
|
19
|
+
else
|
|
20
|
+
render :new, status: :unprocessable_entity
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def edit; end
|
|
25
|
+
|
|
26
|
+
def update
|
|
27
|
+
if @award.update(award_params)
|
|
28
|
+
redirect_to admin_awards_path, notice: "Award updated."
|
|
29
|
+
else
|
|
30
|
+
render :edit, status: :unprocessable_entity
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def set_award = @award = Award.find(params[:id])
|
|
37
|
+
|
|
38
|
+
def award_params
|
|
39
|
+
params.require(:award).permit(:user_id, :kind, :period_start, :citation)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Performance improvement plans: the appraisal tier, so Super Admin only.
|
|
4
|
+
class PerformancePlansController < SuperadminController
|
|
5
|
+
before_action :set_plan, only: %i[edit update]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@plans = paginate(PerformancePlan.includes(:user).open_first)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def new
|
|
12
|
+
@plan = PerformancePlan.new(user_id: params[:user_id], start_date: Date.current, end_date: Date.current + 30)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
@plan = PerformancePlan.new(plan_params.merge(created_by_id: hr_current_user.id))
|
|
17
|
+
if @plan.save
|
|
18
|
+
redirect_to admin_performance_plans_path, notice: "PIP started."
|
|
19
|
+
else
|
|
20
|
+
render :new, status: :unprocessable_entity
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def edit; end
|
|
25
|
+
|
|
26
|
+
def update
|
|
27
|
+
if @plan.update(plan_params.except(:user_id))
|
|
28
|
+
redirect_to admin_performance_plans_path, notice: "PIP updated."
|
|
29
|
+
else
|
|
30
|
+
render :edit, status: :unprocessable_entity
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def set_plan
|
|
37
|
+
@plan = PerformancePlan.find(params[:id])
|
|
38
|
+
redirect_to admin_performance_plans_path, alert: "A closed PIP cannot be changed." if @plan.closed?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def plan_params
|
|
42
|
+
params.require(:performance_plan).permit(:user_id, :start_date, :end_date, :goals, :status, :outcome_note)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -2,6 +2,7 @@ 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
|
+
@awards = Award.current
|
|
5
6
|
@out_week = LeaveRequest.approved.overlapping_range(Date.current.beginning_of_week, Date.current.end_of_week)
|
|
6
7
|
.includes(:user).where(user_id: HrLite.active_employees.map(&:id))
|
|
7
8
|
.sort_by { |leave| hr_display_name(leave.user).downcase }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Anyone's year on one page. Every employee may open a colleague's timeline;
|
|
3
|
+
# HrLite::Timeline decides which events the viewer is allowed to see.
|
|
4
|
+
class TimelinesController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
@person = params[:user_id] ? HrLite.employees.find { |u| u.id == params[:user_id].to_i } : hr_current_user
|
|
7
|
+
raise ActiveRecord::RecordNotFound unless @person
|
|
8
|
+
|
|
9
|
+
year = params[:year].to_i
|
|
10
|
+
@year = year.between?(2000, 2100) ? year : Date.current.year
|
|
11
|
+
@profile = EmployeeProfile.find_by(user_id: @person.id)
|
|
12
|
+
timeline = Timeline.new(user: @person, viewer: hr_current_user, year: @year)
|
|
13
|
+
@public_events = timeline.public_events
|
|
14
|
+
@private_events = timeline.private_events
|
|
15
|
+
# Shown only to someone who may see private rows at all: the person, or a reach-holder.
|
|
16
|
+
@private_card = @person.id == hr_current_user.id || @private_events.any?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -17,6 +17,7 @@ module HrLite
|
|
|
17
17
|
{ label: "Documents", path: :documents_path, match: [ "/documents" ], group: "Me" },
|
|
18
18
|
{ label: "ID card", path: :id_card_path, match: [ "/id_card" ], group: "Me" },
|
|
19
19
|
{ label: "Career", path: :career_path, match: [ "/career", "/appraisals" ], group: "Me" },
|
|
20
|
+
{ label: "Timeline", path: :timeline_path, match: [ "/timeline" ], group: "Me" },
|
|
20
21
|
{ label: "Policies", path: :policies_path, match: [ "/policies" ], group: "Company" },
|
|
21
22
|
{ label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ], group: "Company" },
|
|
22
23
|
{ label: "Org", path: :org_path, match: [ "/org" ], group: "Company" },
|
|
@@ -42,6 +43,7 @@ module HrLite
|
|
|
42
43
|
|
|
43
44
|
LEADERSHIP_NAV_ITEMS = [
|
|
44
45
|
{ label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
|
|
46
|
+
{ label: "Awards", path: :admin_awards_path, match: [ "/admin/awards" ] },
|
|
45
47
|
{ label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting", "/admin/expense_categories" ] },
|
|
46
48
|
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
|
|
47
49
|
{ label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
|
|
@@ -56,7 +58,8 @@ module HrLite
|
|
|
56
58
|
{ label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ] },
|
|
57
59
|
{ label: "Rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ] },
|
|
58
60
|
{ label: "Tax", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ] },
|
|
59
|
-
{ label: "Loans", path: :admin_loans_path, match: [ "/admin/loans" ] }
|
|
61
|
+
{ label: "Loans", path: :admin_loans_path, match: [ "/admin/loans" ] },
|
|
62
|
+
{ label: "PIPs", path: :admin_performance_plans_path, match: [ "/admin/performance_plans" ] }
|
|
60
63
|
].freeze
|
|
61
64
|
|
|
62
65
|
# DayStatus kind => [code, word, month-grid cell modifier]: the code keeps a state readable without its colour.
|
|
@@ -15,6 +15,7 @@ module HrLite
|
|
|
15
15
|
MONEY_TIER_TYPES = %w[
|
|
16
16
|
HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
|
|
17
17
|
HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem HrLite::Loan
|
|
18
|
+
HrLite::PerformancePlan
|
|
18
19
|
].freeze
|
|
19
20
|
|
|
20
21
|
scope :recent, -> { order(created_at: :desc) }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Employee of the month, quarter or year. Leadership picks the winner; one
|
|
3
|
+
# per period, editable (to correct the winner) but never deleted.
|
|
4
|
+
class Award < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
KINDS = %w[month quarter year].freeze
|
|
8
|
+
|
|
9
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
10
|
+
belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
|
|
11
|
+
|
|
12
|
+
validates :kind, inclusion: { in: KINDS }
|
|
13
|
+
validates :period_start, :citation, presence: true
|
|
14
|
+
validates :period_start, uniqueness: { scope: :kind, message: "already has a winner" }
|
|
15
|
+
|
|
16
|
+
before_validation :normalise_period
|
|
17
|
+
after_save_commit :notify, if: -> { saved_change_to_user_id? }
|
|
18
|
+
|
|
19
|
+
scope :recent_first, -> { order(period_start: :desc, kind: :asc) }
|
|
20
|
+
|
|
21
|
+
# The latest started award of each kind, month first.
|
|
22
|
+
def self.current
|
|
23
|
+
KINDS.filter_map { |kind| where(kind: kind, period_start: ..Date.current).order(period_start: :desc).first }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def title = "Employee of the #{kind}"
|
|
27
|
+
|
|
28
|
+
def period_label
|
|
29
|
+
case kind
|
|
30
|
+
when "month" then period_start.strftime("%B %Y")
|
|
31
|
+
when "quarter" then "Q#{(period_start.month - 1) / 3 + 1} #{period_start.year}"
|
|
32
|
+
else period_start.year.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def normalise_period
|
|
39
|
+
return unless period_start
|
|
40
|
+
|
|
41
|
+
self.period_start = { "month" => period_start.beginning_of_month, "quarter" => period_start.beginning_of_quarter,
|
|
42
|
+
"year" => period_start.beginning_of_year }.fetch(kind, period_start)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def notify
|
|
46
|
+
Notifications.publish("award.won", title: "You are #{title} — #{period_label}", body: citation,
|
|
47
|
+
path: "/timeline", bell_to: [ user ], email_to: [ user ])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A performance improvement plan. Super Admin writes it (the appraisal
|
|
3
|
+
# tier); the employee is told when it starts and when it closes. Closing as
|
|
4
|
+
# passed or failed stamps closed_at and freezes the row.
|
|
5
|
+
class PerformancePlan < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
STATUSES = %w[active extended passed failed].freeze
|
|
9
|
+
CLOSED = %w[passed failed].freeze
|
|
10
|
+
|
|
11
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
12
|
+
belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
|
|
13
|
+
|
|
14
|
+
validates :start_date, :end_date, :goals, presence: true
|
|
15
|
+
validates :status, inclusion: { in: STATUSES }
|
|
16
|
+
validate :ends_after_start
|
|
17
|
+
|
|
18
|
+
scope :open_first, -> { order(Arel.sql("CASE WHEN status IN ('active', 'extended') THEN 0 ELSE 1 END"), :end_date) }
|
|
19
|
+
|
|
20
|
+
before_save { self.closed_at ||= Time.current if closed? }
|
|
21
|
+
after_create_commit { notify("pip.started", "Performance improvement plan started", "Runs until #{end_date.strftime('%d %b %Y')}.") }
|
|
22
|
+
after_update_commit do
|
|
23
|
+
notify("pip.closed", "Performance improvement plan #{status}", outcome_note) if saved_change_to_status? && closed?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def closed? = CLOSED.include?(status)
|
|
27
|
+
|
|
28
|
+
# A closed plan is the record of its outcome.
|
|
29
|
+
def readonly?
|
|
30
|
+
persisted? && CLOSED.include?(status_in_database) && !destroyed?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def ends_after_start
|
|
36
|
+
errors.add(:end_date, "must be after the start date") if start_date && end_date && end_date <= start_date
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def notify(event, title, body)
|
|
40
|
+
Notifications.publish(event, title: title, body: body.presence, path: "/timeline", bell_to: [ user ], email_to: [ user ])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One employee's year as `viewer` may see it, split into what everyone may
|
|
3
|
+
# read (#public_events) and what only the employee and whoever the matching
|
|
4
|
+
# permission reaches may read (#private_events). Leave type follows
|
|
5
|
+
# leave.view, like the "Out in the next 2 weeks" card.
|
|
6
|
+
class Timeline
|
|
7
|
+
Event = Struct.new(:date, :category, :title, :detail, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
PAY_CODES = %w[bonus incentive arrears].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(user:, viewer:, year:)
|
|
12
|
+
@user = user
|
|
13
|
+
@viewer = viewer
|
|
14
|
+
@range = Date.new(year)..Date.new(year).end_of_year
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def public_events = newest_first(lifecycle + promotions + kudos_received + kudos_given + awards + leaves)
|
|
18
|
+
|
|
19
|
+
def private_events
|
|
20
|
+
newest_first(pay_items + loans + expenses + appraisals + plans + attendance_fixes + comp_offs +
|
|
21
|
+
assets + documents + resignations + probation + hr_requests)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def newest_first(events) = events.select { |e| @range.cover?(e.date) }.sort_by { |e| -e.date.jd }
|
|
27
|
+
|
|
28
|
+
def visible?(key)
|
|
29
|
+
@viewer.id == @user.id || HrLite.reaches?(@viewer, key, @user)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def mine = { user_id: @user.id }
|
|
33
|
+
def days = @range.begin.beginning_of_day..@range.end.end_of_day
|
|
34
|
+
def event(date, category, title, detail = nil) = Event.new(date: date, category: category, title: title, detail: detail.presence)
|
|
35
|
+
def profile = @profile ||= EmployeeProfile.find_by(user_id: @user.id)
|
|
36
|
+
|
|
37
|
+
# --- public ---------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def lifecycle
|
|
40
|
+
return [] unless profile
|
|
41
|
+
|
|
42
|
+
[ [ profile.date_of_joining, "Joined the company" ], [ profile.date_of_exit, "Left the company" ] ]
|
|
43
|
+
.select { |date, _| date && date <= Date.current }
|
|
44
|
+
.map { |date, title| event(date, "Milestone", title) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def promotions
|
|
48
|
+
DesignationChange.where(**mine, effective_date: @range.begin..[ @range.end, Date.current ].min).map do |change|
|
|
49
|
+
event(change.effective_date, "Promotion", "New role: #{change.to_designation}",
|
|
50
|
+
change.from_designation.presence && "from #{change.from_designation}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def kudos_received
|
|
55
|
+
Kudo.joins(:kudo_mentions).where(hr_lite_kudo_mentions: mine, created_at: days).includes(:giver).map do |kudo|
|
|
56
|
+
event(kudo.created_at.to_date, "Kudos", "Kudos from #{HrLite.display_name(kudo.giver)}", kudo_text(kudo))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def kudos_given
|
|
61
|
+
Kudo.where(giver_id: @user.id, created_at: days).includes(:mentioned_users).map do |kudo|
|
|
62
|
+
names = kudo.mentioned_users.map { |u| HrLite.display_name(u) }.to_sentence
|
|
63
|
+
event(kudo.created_at.to_date, "Kudos", "Gave kudos#{" to #{names}" if names.present?}", kudo_text(kudo))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def kudo_text(kudo) = [ kudo.badge_label, MentionParser.strip_markers(kudo.message) ].compact.join(" · ")
|
|
68
|
+
|
|
69
|
+
def awards
|
|
70
|
+
Award.where(**mine, period_start: @range).map do |award|
|
|
71
|
+
event(award.period_start, "Award", "#{award.title} — #{award.period_label}", award.citation)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def leaves
|
|
76
|
+
typed = visible?("leave.view")
|
|
77
|
+
LeaveRequest.approved.where(mine).overlapping_range(@range.begin, @range.end).includes(:leave_type).map do |leave|
|
|
78
|
+
event(leave.start_date, "Leave", typed ? leave.leave_type.name : "On leave", leave.date_range_label)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# --- private --------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def pay_items
|
|
85
|
+
return [] unless visible?("payroll.view")
|
|
86
|
+
|
|
87
|
+
PayrollLineItem.joins(:component).includes(:component).where(**mine, period_month: @range)
|
|
88
|
+
.where(hr_lite_salary_components: { code: PAY_CODES }).map do |item|
|
|
89
|
+
event(item.period_month, "Pay", item.component.label, [ Money.format(item.amount), item.note.presence ].compact.join(" · "))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def loans
|
|
94
|
+
return [] unless visible?("payroll.view")
|
|
95
|
+
|
|
96
|
+
Loan.where(**mine, starts_on: @range).map { |loan| event(loan.starts_on, "Pay", "Loan of #{Money.format(loan.principal)}", loan.reason) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def expenses
|
|
100
|
+
return [] unless visible?("expense.approve")
|
|
101
|
+
|
|
102
|
+
Expense.where(**mine, status: %w[approved reimbursed], spent_on: @range).includes(:category).map do |expense|
|
|
103
|
+
event(expense.spent_on, "Expense", "Expense #{expense.status}: #{expense.category.name}",
|
|
104
|
+
"#{Money.format(expense.amount)} · #{expense.description}")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def appraisals
|
|
109
|
+
return [] unless visible?("appraisal.view")
|
|
110
|
+
|
|
111
|
+
Appraisal.shared.where(**mine, shared_at: days).map do |appraisal|
|
|
112
|
+
outcome = appraisal.outcome == "none" ? nil : appraisal.outcome.humanize
|
|
113
|
+
event(appraisal.shared_at.to_date, "Performance", "Appraisal #{appraisal.period_label}",
|
|
114
|
+
[ appraisal.rating && "Rating #{appraisal.rating}/5", outcome ].compact.join(" · "))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def plans
|
|
119
|
+
return [] unless visible?("appraisal.view")
|
|
120
|
+
|
|
121
|
+
PerformancePlan.where(mine).where(start_date: ..@range.end).flat_map do |plan|
|
|
122
|
+
[ event(plan.start_date, "Performance", "Performance improvement plan started", "Until #{plan.end_date.strftime('%d %b %Y')}"),
|
|
123
|
+
plan.closed_at && event(plan.closed_at.to_date, "Performance", "Performance improvement plan #{plan.status}", plan.outcome_note) ].compact
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def attendance_fixes
|
|
128
|
+
return [] unless visible?("attendance.view")
|
|
129
|
+
|
|
130
|
+
RegularizationRequest.where(**mine, status: "approved", date: @range).map do |fix|
|
|
131
|
+
event(fix.date, "Attendance", "Attendance fixed (#{fix.times_label})", fix.reason)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def comp_offs
|
|
136
|
+
return [] unless visible?("leave.view")
|
|
137
|
+
|
|
138
|
+
CompOffRequest.where(**mine, status: "approved", date_worked: @range).map do |comp_off|
|
|
139
|
+
event(comp_off.date_worked, "Leave", "Comp-off earned (#{comp_off.credit_days} day)", comp_off.reason)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def assets
|
|
144
|
+
return [] unless visible?("asset.view")
|
|
145
|
+
|
|
146
|
+
AssetAssignment.where(mine).includes(:asset).flat_map do |held|
|
|
147
|
+
[ event(held.assigned_on, "Asset", "Given #{held.asset.name}", held.asset.serial_number),
|
|
148
|
+
held.returned_on && event(held.returned_on, "Asset", "Returned #{held.asset.name}", held.condition_note) ].compact
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def documents
|
|
153
|
+
Document.where(**mine, verification: "verified", verified_at: days).select { |doc| doc.readable_by?(@viewer) }.map do |doc|
|
|
154
|
+
event(doc.verified_at.to_date, "Document", "Document verified: #{doc.title}")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def resignations
|
|
159
|
+
return [] unless visible?("resignation.view")
|
|
160
|
+
|
|
161
|
+
Resignation.where(mine).flat_map do |resignation|
|
|
162
|
+
accepted = resignation.status == "accepted" && resignation.decided_at
|
|
163
|
+
[ event(resignation.created_at.to_date, "Exit", "Resigned", "Proposed last day #{resignation.proposed_last_day.strftime('%d %b %Y')}"),
|
|
164
|
+
accepted && event(resignation.decided_at.to_date, "Exit", "Resignation accepted",
|
|
165
|
+
"Last day #{resignation.proposed_last_day.strftime('%d %b %Y')}") ].select(&:itself)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def probation
|
|
170
|
+
return [] unless profile&.probation_until && visible?("profile.view")
|
|
171
|
+
|
|
172
|
+
[ event(profile.probation_until, "Milestone", profile.probation_until < Date.current ? "Probation ended" : "Probation ends") ]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def hr_requests
|
|
176
|
+
return [] unless visible?("hr_request.manage")
|
|
177
|
+
|
|
178
|
+
HrRequest.where(**mine, status: %w[resolved closed], resolved_at: days).map do |request|
|
|
179
|
+
event(request.resolved_at.to_date, "Help desk", "Request resolved: #{request.subject}", request.resolution)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<section class="hrl-card">
|
|
2
|
+
<%= form_with model: award, url: url, method: method, scope: :award, local: true do |f| %>
|
|
3
|
+
<%= render "hr_lite/shared/form_errors", record: award %>
|
|
4
|
+
<div class="hrl-field">
|
|
5
|
+
<%= f.label :kind, "Award" %>
|
|
6
|
+
<%= f.select :kind, HrLite::Award::KINDS.map { |k| [ "Employee of the #{k}", k ] } %>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="hrl-field">
|
|
9
|
+
<%= f.label :period_start, "Any day in the period" %>
|
|
10
|
+
<%= f.date_field :period_start %>
|
|
11
|
+
<p class="hrl-hint">Saved as the start of that month, quarter or year.</p>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="hrl-field">
|
|
14
|
+
<%= f.label :user_id, "Winner" %>
|
|
15
|
+
<%= f.select :user_id, options_for_select(HrLite.active_employees.map { |u| [ hr_display_name(u), u.id ] }, award.user_id),
|
|
16
|
+
{ include_blank: "Choose…" }, data: { hrl_filter: "Type a name to filter" } %>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="hrl-field"><%= f.label :citation, "Why they won" %><%= f.text_area :citation, rows: 3 %></div>
|
|
19
|
+
<div class="hrl-form-actions">
|
|
20
|
+
<%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
|
|
21
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_awards_path %>">Cancel</a>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
24
|
+
</section>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Awards" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Employee of the month, quarter and year</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_award_path %>">Pick a winner</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<section class="hrl-card">
|
|
10
|
+
<% if @awards.any? %>
|
|
11
|
+
<div class="hrl-table-wrap">
|
|
12
|
+
<table class="hrl-table hrl-table--stack">
|
|
13
|
+
<thead><tr><th>Award</th><th>Period</th><th>Winner</th><th></th></tr></thead>
|
|
14
|
+
<tbody>
|
|
15
|
+
<% @awards.each do |award| %>
|
|
16
|
+
<tr>
|
|
17
|
+
<td data-label="Award"><%= award.title %></td>
|
|
18
|
+
<td data-label="Period"><%= award.period_label %></td>
|
|
19
|
+
<td data-label="Winner"><%= hr_display_name(award.user) %></td>
|
|
20
|
+
<td data-label=""><a class="hrl-card__link" href="<%= hr_lite.edit_admin_award_path(award) %>">Edit</a></td>
|
|
21
|
+
</tr>
|
|
22
|
+
<% end %>
|
|
23
|
+
</tbody>
|
|
24
|
+
</table>
|
|
25
|
+
</div>
|
|
26
|
+
<%= hrl_pagination %>
|
|
27
|
+
<% else %>
|
|
28
|
+
<p class="hrl-card--empty">No winners picked yet.</p>
|
|
29
|
+
<% end %>
|
|
30
|
+
</section>
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
<div class="hrl-page__actions">
|
|
5
5
|
<a class="hrl-btn" href="<%= hr_lite.edit_admin_employee_path(@profile) %>">Edit</a>
|
|
6
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>
|
|
7
8
|
<% if hr_superadmin? %>
|
|
8
9
|
<a class="hrl-btn" href="<%= hr_lite.new_admin_employee_appraisal_path(@profile) %>">New appraisal</a>
|
|
9
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>
|
|
10
12
|
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_employee_salary_structure_path(@profile) %>">New structure</a>
|
|
11
13
|
<% end %>
|
|
12
14
|
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<section class="hrl-card">
|
|
2
|
+
<%= form_with model: plan, url: url, method: method, scope: :performance_plan, local: true do |f| %>
|
|
3
|
+
<%= render "hr_lite/shared/form_errors", record: plan %>
|
|
4
|
+
<div class="hrl-field">
|
|
5
|
+
<%= f.label :user_id, "Employee" %>
|
|
6
|
+
<% if plan.persisted? %>
|
|
7
|
+
<p><strong><%= hr_display_name(plan.user) %></strong></p>
|
|
8
|
+
<% else %>
|
|
9
|
+
<%= f.select :user_id, options_for_select(HrLite.active_employees.map { |u| [ hr_display_name(u), u.id ] }, plan.user_id),
|
|
10
|
+
{ include_blank: "Choose…" }, data: { hrl_filter: "Type a name to filter" } %>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="hrl-field"><%= f.label :start_date %><%= f.date_field :start_date %></div>
|
|
14
|
+
<div class="hrl-field"><%= f.label :end_date, "Review by" %><%= f.date_field :end_date %></div>
|
|
15
|
+
<div class="hrl-field"><%= f.label :goals, "Goals the employee must meet" %><%= f.text_area :goals, rows: 5 %></div>
|
|
16
|
+
<% if plan.persisted? %>
|
|
17
|
+
<div class="hrl-field">
|
|
18
|
+
<%= f.label :status %>
|
|
19
|
+
<%= f.select :status, HrLite::PerformancePlan::STATUSES.map { |s| [ s.humanize, s ] } %>
|
|
20
|
+
<p class="hrl-hint">Passed or failed closes the plan for good and tells the employee.</p>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="hrl-field"><%= f.label :outcome_note, "Outcome note" %><%= f.text_area :outcome_note, rows: 3 %></div>
|
|
23
|
+
<% end %>
|
|
24
|
+
<div class="hrl-form-actions">
|
|
25
|
+
<%= f.submit plan.persisted? ? "Save" : "Start PIP", class: "hrl-btn hrl-btn--primary" %>
|
|
26
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_performance_plans_path %>">Cancel</a>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
29
|
+
</section>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<% content_for(:page_title) { "PIP — #{hr_display_name(@plan.user)}" } %>
|
|
2
|
+
<div class="hrl-page__head"><h1 class="hrl-page__title">PIP — <%= hr_display_name(@plan.user) %></h1></div>
|
|
3
|
+
<%= render "form", plan: @plan, url: hr_lite.admin_performance_plan_path(@plan), method: :patch %>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<% content_for(:page_title) { "PIPs" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Performance improvement plans</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_performance_plan_path %>">Start a PIP</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<section class="hrl-card">
|
|
10
|
+
<% if @plans.any? %>
|
|
11
|
+
<div class="hrl-table-wrap">
|
|
12
|
+
<table class="hrl-table hrl-table--stack">
|
|
13
|
+
<thead><tr><th>Employee</th><th>Period</th><th>Status</th><th></th></tr></thead>
|
|
14
|
+
<tbody>
|
|
15
|
+
<% @plans.each do |plan| %>
|
|
16
|
+
<tr>
|
|
17
|
+
<td data-label="Employee"><a class="hrl-card__link" href="<%= hr_lite.person_timeline_path(plan.user_id) %>"><%= hr_display_name(plan.user) %></a></td>
|
|
18
|
+
<td data-label="Period"><%= plan.start_date.strftime("%-d %b %Y") %> – <%= plan.end_date.strftime("%-d %b %Y") %></td>
|
|
19
|
+
<td data-label="Status"><span class="hrl-badge <%= { "passed" => "hrl-badge--ok", "failed" => "hrl-badge--bad" }.fetch(plan.status, "hrl-badge--warn") %>"><%= plan.status.humanize %></span></td>
|
|
20
|
+
<td data-label=""><% unless plan.closed? %><a class="hrl-card__link" href="<%= hr_lite.edit_admin_performance_plan_path(plan) %>">Update</a><% end %></td>
|
|
21
|
+
</tr>
|
|
22
|
+
<% end %>
|
|
23
|
+
</tbody>
|
|
24
|
+
</table>
|
|
25
|
+
</div>
|
|
26
|
+
<%= hrl_pagination %>
|
|
27
|
+
<% else %>
|
|
28
|
+
<p class="hrl-card--empty">No PIPs yet.</p>
|
|
29
|
+
<% end %>
|
|
30
|
+
</section>
|
|
@@ -38,6 +38,25 @@
|
|
|
38
38
|
<a class="hrl-card__link" href="<%= hr_lite.team_path %>">Team board</a>
|
|
39
39
|
</section>
|
|
40
40
|
|
|
41
|
+
<% if @awards.any? %>
|
|
42
|
+
<section class="hrl-card">
|
|
43
|
+
<h2 class="hrl-card__title">Employee of the month, quarter and year</h2>
|
|
44
|
+
<ul class="hrl-feed">
|
|
45
|
+
<% @awards.each do |award| %>
|
|
46
|
+
<li class="hrl-feed__item">
|
|
47
|
+
<div class="hrl-feed__body">
|
|
48
|
+
<div class="hrl-row">
|
|
49
|
+
<a class="hrl-card__link" href="<%= hr_lite.person_timeline_path(award.user_id) %>"><%= hr_display_name(award.user) %></a>
|
|
50
|
+
<span class="hrl-badge"><%= award.title %> · <%= award.period_label %></span>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="hrl-feed__meta"><%= award.citation %></div>
|
|
53
|
+
</div>
|
|
54
|
+
</li>
|
|
55
|
+
<% end %>
|
|
56
|
+
</ul>
|
|
57
|
+
</section>
|
|
58
|
+
<% end %>
|
|
59
|
+
|
|
41
60
|
<section class="hrl-card">
|
|
42
61
|
<h2 class="hrl-card__title">Latest kudos</h2>
|
|
43
62
|
<% if @latest_kudos.any? %>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%# locals: profile — recursive org-chart node; @children / hr_current_user from OrgController %>
|
|
2
2
|
<li class="hrl-tree__node">
|
|
3
3
|
<div class="hrl-tree__card <%= 'hrl-tree__card--me' if profile.user_id == hr_current_user.id %>">
|
|
4
|
-
<
|
|
4
|
+
<a class="hrl-tree__name" href="<%= hr_lite.person_timeline_path(profile.user_id) %>"><%= hr_display_name(profile.user) %></a>
|
|
5
5
|
<% if profile.designation.present? %><span class="hrl-tree__role"><%= profile.designation %></span><% end %>
|
|
6
6
|
<% if profile.department.present? %><span class="hrl-small hrl-muted"><%= profile.department %></span><% end %>
|
|
7
7
|
</div>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%# locals: events, empty — one rail, grouped by month, newest first %>
|
|
2
|
+
<% if events.any? %>
|
|
3
|
+
<% events.group_by { |e| e.date.beginning_of_month }.each do |month, rows| %>
|
|
4
|
+
<h3 class="hrl-timeline__month"><%= month.strftime("%B %Y") %></h3>
|
|
5
|
+
<ol class="hrl-timeline">
|
|
6
|
+
<% rows.each do |event| %>
|
|
7
|
+
<li class="hrl-timeline__item">
|
|
8
|
+
<div class="hrl-row">
|
|
9
|
+
<span class="hrl-badge hrl-badge--muted"><%= event.category %></span>
|
|
10
|
+
<time class="hrl-feed__meta hrl-right" datetime="<%= event.date.iso8601 %>"><%= event.date.strftime("%a %-d %b") %></time>
|
|
11
|
+
</div>
|
|
12
|
+
<p class="hrl-timeline__title"><%= event.title %></p>
|
|
13
|
+
<% if event.detail %><p class="hrl-feed__meta"><%= event.detail %></p><% end %>
|
|
14
|
+
</li>
|
|
15
|
+
<% end %>
|
|
16
|
+
</ol>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% else %>
|
|
19
|
+
<p class="hrl-muted hrl-small"><%= empty %></p>
|
|
20
|
+
<% end %>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Timeline — #{hr_display_name(@person)}" } %>
|
|
2
|
+
<% 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
|
+
<% name = hr_display_name(@person) %>
|
|
4
|
+
<div class="hrl-page__head">
|
|
5
|
+
<h1 class="hrl-page__title"><%= name %></h1>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<section class="hrl-card">
|
|
9
|
+
<dl class="hrl-deflist">
|
|
10
|
+
<% if @profile&.designation.present? %><dt>Role</dt><dd><%= @profile.designation %></dd><% end %>
|
|
11
|
+
<% if @profile&.department.present? %><dt>Department</dt><dd><%= @profile.department %></dd><% end %>
|
|
12
|
+
<% if @profile %>
|
|
13
|
+
<dt>Joined</dt><dd><%= @profile.date_of_joining.strftime("%-d %B %Y") %>
|
|
14
|
+
<% unless @profile.date_of_exit && @profile.date_of_exit <= Date.current %><span class="hrl-muted">· <%= distance_of_time_in_words(@profile.date_of_joining, Date.current) %></span><% end %></dd>
|
|
15
|
+
<% end %>
|
|
16
|
+
</dl>
|
|
17
|
+
<div class="hrl-row hrl-mt">
|
|
18
|
+
<a class="hrl-btn" href="<%= year_path.(@year - 1) %>">← <%= @year - 1 %></a>
|
|
19
|
+
<strong><%= @year %></strong>
|
|
20
|
+
<a class="hrl-btn" href="<%= year_path.(@year + 1) %>"><%= @year + 1 %> →</a>
|
|
21
|
+
</div>
|
|
22
|
+
</section>
|
|
23
|
+
|
|
24
|
+
<section class="hrl-card">
|
|
25
|
+
<h2 class="hrl-card__title">Public timeline</h2>
|
|
26
|
+
<p class="hrl-small hrl-muted">Everyone at the company can see this.</p>
|
|
27
|
+
<%= render "events", events: @public_events, empty: "Nothing public in #{@year}." %>
|
|
28
|
+
</section>
|
|
29
|
+
|
|
30
|
+
<% if @private_card %>
|
|
31
|
+
<section class="hrl-card hrl-card--private" aria-labelledby="private-records">
|
|
32
|
+
<h2 class="hrl-card__title" id="private-records">Private records</h2>
|
|
33
|
+
<p class="hrl-small"><strong>Private</strong> — only <%= @person.id == hr_current_user.id ? "you" : name %> and people with access can see this.</p>
|
|
34
|
+
<%= render "events", events: @private_events, empty: "Nothing private in #{@year}." %>
|
|
35
|
+
</section>
|
|
36
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -39,6 +39,8 @@ HrLite::Engine.routes.draw do
|
|
|
39
39
|
resources :loans, only: :index
|
|
40
40
|
get "team", to: "team#show"
|
|
41
41
|
get "org", to: "org#show"
|
|
42
|
+
get "timeline", to: "timelines#show"
|
|
43
|
+
get "people/:user_id/timeline", to: "timelines#show", as: :person_timeline
|
|
42
44
|
resources :holidays, only: :index
|
|
43
45
|
get "calendar", to: "calendar#show"
|
|
44
46
|
resources :salary_slips, only: %i[index show]
|
|
@@ -116,6 +118,8 @@ HrLite::Engine.routes.draw do
|
|
|
116
118
|
end
|
|
117
119
|
resources :designation_changes, only: %i[new create]
|
|
118
120
|
end
|
|
121
|
+
resources :performance_plans, only: %i[index new create edit update]
|
|
122
|
+
resources :awards, only: %i[index new create edit update]
|
|
119
123
|
resources :payroll_runs, only: %i[index show new create destroy] do
|
|
120
124
|
member do
|
|
121
125
|
post :compute
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CreateHrLitePerformancePlans < ActiveRecord::Migration[8.1]
|
|
2
|
+
# A performance improvement plan: goals with a deadline, closed as passed or
|
|
3
|
+
# failed. Money tier, like appraisals — never deleted.
|
|
4
|
+
def change
|
|
5
|
+
create_table :hr_lite_performance_plans do |t|
|
|
6
|
+
t.bigint :user_id, null: false
|
|
7
|
+
t.date :start_date, null: false
|
|
8
|
+
t.date :end_date, null: false
|
|
9
|
+
t.text :goals, null: false
|
|
10
|
+
# active | extended | passed | failed
|
|
11
|
+
t.string :status, null: false, default: "active"
|
|
12
|
+
t.text :outcome_note
|
|
13
|
+
t.datetime :closed_at
|
|
14
|
+
t.bigint :created_by_id
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
add_index :hr_lite_performance_plans, :user_id
|
|
18
|
+
add_check_constraint :hr_lite_performance_plans,
|
|
19
|
+
"status IN ('active', 'extended', 'passed', 'failed')",
|
|
20
|
+
name: "hr_lite_performance_plans_status_check"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class CreateHrLiteAwards < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Employee of the month, quarter and year: one winner per period.
|
|
3
|
+
def change
|
|
4
|
+
create_table :hr_lite_awards do |t|
|
|
5
|
+
t.bigint :user_id, null: false
|
|
6
|
+
# month | quarter | year
|
|
7
|
+
t.string :kind, null: false
|
|
8
|
+
t.date :period_start, null: false
|
|
9
|
+
t.text :citation, null: false
|
|
10
|
+
t.bigint :created_by_id
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
add_index :hr_lite_awards, %i[kind period_start], unique: true
|
|
14
|
+
add_index :hr_lite_awards, :user_id
|
|
15
|
+
add_check_constraint :hr_lite_awards, "kind IN ('month', 'quarter', 'year')", name: "hr_lite_awards_kind_check"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -26,6 +26,9 @@ module HrLite
|
|
|
26
26
|
"kudos.mentioned" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
27
27
|
"appraisal.shared" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
28
28
|
"promotion.recorded" => { bell: true, email: true, leadership_email: true, leadership_bell: true },
|
|
29
|
+
"pip.started" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
30
|
+
"pip.closed" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
31
|
+
"award.won" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
29
32
|
"policy.changed" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
|
|
30
33
|
# An approval past its deadline. Leadership hears too — a decision
|
|
31
34
|
# nobody is making is a process problem, not just one person's inbox.
|
data/lib/hr_lite/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hr_lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.23.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -76,6 +76,7 @@ files:
|
|
|
76
76
|
- app/controllers/hr_lite/admin/assets_controller.rb
|
|
77
77
|
- app/controllers/hr_lite/admin/attendances_controller.rb
|
|
78
78
|
- app/controllers/hr_lite/admin/audit_logs_controller.rb
|
|
79
|
+
- app/controllers/hr_lite/admin/awards_controller.rb
|
|
79
80
|
- app/controllers/hr_lite/admin/base_controller.rb
|
|
80
81
|
- app/controllers/hr_lite/admin/benefits_controller.rb
|
|
81
82
|
- app/controllers/hr_lite/admin/checklists_controller.rb
|
|
@@ -96,6 +97,7 @@ files:
|
|
|
96
97
|
- app/controllers/hr_lite/admin/overview_controller.rb
|
|
97
98
|
- app/controllers/hr_lite/admin/payroll_line_items_controller.rb
|
|
98
99
|
- app/controllers/hr_lite/admin/payroll_runs_controller.rb
|
|
100
|
+
- app/controllers/hr_lite/admin/performance_plans_controller.rb
|
|
99
101
|
- app/controllers/hr_lite/admin/policies_controller.rb
|
|
100
102
|
- app/controllers/hr_lite/admin/regularization_requests_controller.rb
|
|
101
103
|
- app/controllers/hr_lite/admin/reports_controller.rb
|
|
@@ -134,6 +136,7 @@ files:
|
|
|
134
136
|
- app/controllers/hr_lite/salary_slips_controller.rb
|
|
135
137
|
- app/controllers/hr_lite/tax_declarations_controller.rb
|
|
136
138
|
- app/controllers/hr_lite/team_controller.rb
|
|
139
|
+
- app/controllers/hr_lite/timelines_controller.rb
|
|
137
140
|
- app/controllers/hr_lite/users_controller.rb
|
|
138
141
|
- app/helpers/hr_lite/application_helper.rb
|
|
139
142
|
- app/jobs/hr_lite/application_job.rb
|
|
@@ -159,6 +162,7 @@ files:
|
|
|
159
162
|
- app/models/hr_lite/asset_assignment.rb
|
|
160
163
|
- app/models/hr_lite/attendance_record.rb
|
|
161
164
|
- app/models/hr_lite/audit_log.rb
|
|
165
|
+
- app/models/hr_lite/award.rb
|
|
162
166
|
- app/models/hr_lite/benefit.rb
|
|
163
167
|
- app/models/hr_lite/benefit_enrolment.rb
|
|
164
168
|
- app/models/hr_lite/checklist_item.rb
|
|
@@ -181,6 +185,7 @@ files:
|
|
|
181
185
|
- app/models/hr_lite/office_location.rb
|
|
182
186
|
- app/models/hr_lite/payroll_line_item.rb
|
|
183
187
|
- app/models/hr_lite/payroll_run.rb
|
|
188
|
+
- app/models/hr_lite/performance_plan.rb
|
|
184
189
|
- app/models/hr_lite/policy.rb
|
|
185
190
|
- app/models/hr_lite/policy_acknowledgement.rb
|
|
186
191
|
- app/models/hr_lite/professional_tax_slab.rb
|
|
@@ -212,6 +217,7 @@ files:
|
|
|
212
217
|
- app/services/hr_lite/pdf_renderer.rb
|
|
213
218
|
- app/services/hr_lite/slip_builder.rb
|
|
214
219
|
- app/services/hr_lite/team_day.rb
|
|
220
|
+
- app/services/hr_lite/timeline.rb
|
|
215
221
|
- app/services/hr_lite/working_calendar.rb
|
|
216
222
|
- app/views/hr_lite/admin/appraisals/_form.html.erb
|
|
217
223
|
- app/views/hr_lite/admin/appraisals/edit.html.erb
|
|
@@ -222,6 +228,10 @@ files:
|
|
|
222
228
|
- app/views/hr_lite/admin/attendances/show.html.erb
|
|
223
229
|
- app/views/hr_lite/admin/attendances/week.html.erb
|
|
224
230
|
- app/views/hr_lite/admin/audit_logs/index.html.erb
|
|
231
|
+
- app/views/hr_lite/admin/awards/_form.html.erb
|
|
232
|
+
- app/views/hr_lite/admin/awards/edit.html.erb
|
|
233
|
+
- app/views/hr_lite/admin/awards/index.html.erb
|
|
234
|
+
- app/views/hr_lite/admin/awards/new.html.erb
|
|
225
235
|
- app/views/hr_lite/admin/benefits/index.html.erb
|
|
226
236
|
- app/views/hr_lite/admin/benefits/new.html.erb
|
|
227
237
|
- app/views/hr_lite/admin/checklists/index.html.erb
|
|
@@ -264,6 +274,10 @@ files:
|
|
|
264
274
|
- app/views/hr_lite/admin/payroll_runs/index.html.erb
|
|
265
275
|
- app/views/hr_lite/admin/payroll_runs/new.html.erb
|
|
266
276
|
- app/views/hr_lite/admin/payroll_runs/show.html.erb
|
|
277
|
+
- app/views/hr_lite/admin/performance_plans/_form.html.erb
|
|
278
|
+
- app/views/hr_lite/admin/performance_plans/edit.html.erb
|
|
279
|
+
- app/views/hr_lite/admin/performance_plans/index.html.erb
|
|
280
|
+
- app/views/hr_lite/admin/performance_plans/new.html.erb
|
|
267
281
|
- app/views/hr_lite/admin/policies/index.html.erb
|
|
268
282
|
- app/views/hr_lite/admin/policies/new.html.erb
|
|
269
283
|
- app/views/hr_lite/admin/regularization_requests/index.html.erb
|
|
@@ -337,6 +351,8 @@ files:
|
|
|
337
351
|
- app/views/hr_lite/shared/_pagination.html.erb
|
|
338
352
|
- app/views/hr_lite/tax_declarations/show.html.erb
|
|
339
353
|
- app/views/hr_lite/team/show.html.erb
|
|
354
|
+
- app/views/hr_lite/timelines/_events.html.erb
|
|
355
|
+
- app/views/hr_lite/timelines/show.html.erb
|
|
340
356
|
- app/views/layouts/hr_lite/application.html.erb
|
|
341
357
|
- app/views/layouts/hr_lite/mailer.html.erb
|
|
342
358
|
- app/views/layouts/hr_lite/mailer.text.erb
|
|
@@ -382,6 +398,8 @@ files:
|
|
|
382
398
|
- db/migrate/20261006090000_add_probation_until_to_hr_lite_employee_profiles.rb
|
|
383
399
|
- db/migrate/20261006120000_add_half_day_part_to_hr_lite_leave_requests.rb
|
|
384
400
|
- db/migrate/20261006120100_add_half_day_part_to_hr_lite_attendance_records.rb
|
|
401
|
+
- db/migrate/20261010090000_create_hr_lite_performance_plans.rb
|
|
402
|
+
- db/migrate/20261010090100_create_hr_lite_awards.rb
|
|
385
403
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
386
404
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
387
405
|
- lib/generators/hr_lite/install/templates/initializer.rb
|