hr_lite 0.22.1 → 0.23.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +10 -0
  4. data/app/assets/stylesheets/hr_lite/hr_lite.css +18 -0
  5. data/app/controllers/hr_lite/admin/attendances_controller.rb +2 -0
  6. data/app/controllers/hr_lite/admin/awards_controller.rb +43 -0
  7. data/app/controllers/hr_lite/admin/performance_plans_controller.rb +46 -0
  8. data/app/controllers/hr_lite/home_controller.rb +1 -0
  9. data/app/controllers/hr_lite/timelines_controller.rb +19 -0
  10. data/app/helpers/hr_lite/application_helper.rb +4 -1
  11. data/app/models/hr_lite/audit_log.rb +1 -0
  12. data/app/models/hr_lite/award.rb +50 -0
  13. data/app/models/hr_lite/performance_plan.rb +43 -0
  14. data/app/services/hr_lite/timeline.rb +183 -0
  15. data/app/views/hr_lite/admin/attendances/show.html.erb +25 -0
  16. data/app/views/hr_lite/admin/awards/_form.html.erb +24 -0
  17. data/app/views/hr_lite/admin/awards/edit.html.erb +3 -0
  18. data/app/views/hr_lite/admin/awards/index.html.erb +30 -0
  19. data/app/views/hr_lite/admin/awards/new.html.erb +3 -0
  20. data/app/views/hr_lite/admin/employees/show.html.erb +2 -0
  21. data/app/views/hr_lite/admin/performance_plans/_form.html.erb +29 -0
  22. data/app/views/hr_lite/admin/performance_plans/edit.html.erb +3 -0
  23. data/app/views/hr_lite/admin/performance_plans/index.html.erb +30 -0
  24. data/app/views/hr_lite/admin/performance_plans/new.html.erb +3 -0
  25. data/app/views/hr_lite/home/index.html.erb +19 -0
  26. data/app/views/hr_lite/org/_node.html.erb +1 -1
  27. data/app/views/hr_lite/timelines/_events.html.erb +20 -0
  28. data/app/views/hr_lite/timelines/show.html.erb +36 -0
  29. data/config/routes.rb +4 -0
  30. data/db/migrate/20261010090000_create_hr_lite_performance_plans.rb +22 -0
  31. data/db/migrate/20261010090100_create_hr_lite_awards.rb +17 -0
  32. data/lib/hr_lite/notifications.rb +3 -0
  33. data/lib/hr_lite/version.rb +1 -1
  34. metadata +19 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b224591c52bc41368791e59547fe81b69fe9765462b998f9e23ffc47ee286a27
4
- data.tar.gz: 62300f5cad84aa9c3c710e533943ebb84c9775c20941f40fbbb80b6eeee2e2a8
3
+ metadata.gz: 8f73917345c95226e4a223cbe4e3e13b4819e4ca6ea3831cbc4c129eff64bbe1
4
+ data.tar.gz: '03581e4aa969660467a7b7ecfd04938677398dbf57fe49b43beaee3b82c635e9'
5
5
  SHA512:
6
- metadata.gz: ad4011c24f0534b96301e647c1e3c6a908036917f4640ec934acd543787464554dbadaf94ce20724f7f8826c48dd81b89c2c450addf63ebf3750e8fcda070b5d
7
- data.tar.gz: 564668224c10513104b7f5b05326a7c3a1c1e052e5f0ad4f045b19c23a50d9f6f4c2d67b387fb291bc4a45610eb6f0384d9b861a0a51556393603f56f5acabb5
6
+ metadata.gz: ce7315e67fa6cafd5beb9cba391e05545281e3d4af17bf1ef27576cbccef1195e0b6788f80cc9c702de68bbed0e1e9a1d754bb175f1557d8023932c22073cb66
7
+ data.tar.gz: 73934a9be7d733498691e8f35cf6705501992a28163040133165e599287793e0810ca194f6b0e744ab23f6dee9c4742f3a957e1a30efb4672dc3a9451e5828e8
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.23.1] - 2026-10-10
11
+
12
+ No migrations.
13
+
14
+ ### Added
15
+ - An employee's admin month (Team attendance → name) lists "Where they punched": each day's check-in and check-out time and place, a Map link that opens the exact spot in Google Maps, and how far apart the two were (warning badge over 500 m).
16
+
17
+ ## [0.23.0] - 2026-10-10
18
+
19
+ Two migrations: `hr_lite_performance_plans` and `hr_lite_awards`. No new permission keys.
20
+
21
+ ### Added
22
+ - 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.
23
+ - 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.
24
+ - 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.
25
+ - 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.
26
+
10
27
  ## [0.22.1] - 2026-10-09
11
28
 
12
29
  ### 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; }
@@ -52,6 +52,8 @@ module HrLite
52
52
  @counts[:absent] -= 1 if @month.all_month.cover?(Date.current) && @day_status.for(Date.current).kind == :absent
53
53
  @edit_date = params[:date].present? ? parse_date_param(params[:date]) : nil
54
54
  @edit_record = @edit_date && AttendanceRecord.find_or_initialize_by(user_id: @employee.id, date: @edit_date)
55
+ @punches = AttendanceRecord.for_month(@month).where(user_id: @employee.id).where.not(check_in_at: nil).order(:date)
56
+ @offices = OfficeLocation.active.to_a
55
57
  end
56
58
 
57
59
  # Regularization: fix punches with a mandatory note, fully audited.
@@ -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
@@ -24,6 +24,31 @@
24
24
  <p class="hrl-small hrl-muted hrl-mt">Tap a date to fix its punches.</p>
25
25
  </section>
26
26
 
27
+ <% if @punches.any? %>
28
+ <section class="hrl-card">
29
+ <h2 class="hrl-card__title">Where they punched</h2>
30
+ <% @punches.each do |record| %>
31
+ <div class="hrl-feed__item hrl-small">
32
+ <div class="hrl-feed__body">
33
+ <strong><%= record.date.strftime("%a %-d %b") %></strong>
34
+ <% [ [ "In", record.check_in_at, record.check_in_lat, record.check_in_lng ],
35
+ [ "Out", record.check_out_at, record.check_out_lat, record.check_out_lng ] ].each do |label, at, lat, lng| %>
36
+ <% next unless at %>
37
+ <div><%= label %> <%= at.strftime("%H:%M") %> · <%= HrLite::OfficeLocation.place(lat, lng, @offices) || "No GPS" %>
38
+ <% if lat && lng %>
39
+ · <a class="hrl-card__link" href="https://www.google.com/maps?q=<%= lat %>,<%= lng %>" target="_blank" rel="noopener noreferrer">Map<span class="hrl-sr-only"> of <%= label.downcase %> <%= record.date.strftime("%-d %b") %></span></a>
40
+ <% end %>
41
+ </div>
42
+ <% end %>
43
+ <% if (gap = record.punch_gap_m) %>
44
+ <div class="<%= gap > HrLite::AttendanceRecord::PUNCH_GAP_ALERT_M ? "hrl-badge hrl-badge--warn" : "hrl-muted" %>"><%= (gap / 1000.0).round(1) %> km apart</div>
45
+ <% end %>
46
+ </div>
47
+ </div>
48
+ <% end %>
49
+ </section>
50
+ <% end %>
51
+
27
52
  <% if @edit_date %>
28
53
  <section class="hrl-card" id="regularize">
29
54
  <h2 class="hrl-card__title">Fix <%= @edit_date.strftime("%A, %d %B") %></h2>
@@ -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,3 @@
1
+ <% content_for(:page_title) { "Edit award" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title"><%= @award.title %> — <%= @award.period_label %></h1></div>
3
+ <%= render "form", award: @award, url: hr_lite.admin_award_path(@award), method: :patch %>
@@ -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>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "New award" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Pick a winner</h1></div>
3
+ <%= render "form", award: @award, url: hr_lite.admin_awards_path, method: :post %>
@@ -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>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "Start a PIP" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Start a performance improvement plan</h1></div>
3
+ <%= render "form", plan: @plan, url: hr_lite.admin_performance_plans_path, method: :post %>
@@ -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
- <span class="hrl-tree__name"><%= hr_display_name(profile.user) %></span>
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) %>">&larr; <%= @year - 1 %></a>
19
+ <strong><%= @year %></strong>
20
+ <a class="hrl-btn" href="<%= year_path.(@year + 1) %>"><%= @year + 1 %> &rarr;</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.
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.22.1"
2
+ VERSION = "0.23.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.22.1
4
+ version: 0.23.1
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