hr_lite 0.13.0 → 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8202157b6f35309884c9df17895d711553e114a57925c8c2e381e51a7be863ba
4
- data.tar.gz: 3cb55e413c6bcc70bd3aa39a5c212690a21c9cf747b17bfdf5894ef6d73945de
3
+ metadata.gz: '04966d2c7a57a06e49c64c6a375b148c497f83340a79445eb0f243dd783246f0'
4
+ data.tar.gz: 1112874bf22548910a651e53123056fd1341e045598060660c12f93b284789bb
5
5
  SHA512:
6
- metadata.gz: 0ddf504c20a84c58a712fd4efe695e9b90b27eea8acc8883bfb37d33df398778169c35eb2c11f37830e91c9ceec63560df57c631246b2b22381b52df5ce30144
7
- data.tar.gz: 4a724d4a55068129237e010aca71ce94c392656bb56953a6f1ec5329ddf5d33b6ffd91f76c82229a152f85d134a60ca29308634133f0a3a72779cff3cc6dafdb
6
+ metadata.gz: '00219df8c7244175712e7a87768f50301fa2f8b0110ae201f343409064bdb1083cce3eef587ad046cf7e1831a1981eb7d3c6e816e2a9d431cbbb86aaf5ef8c89'
7
+ data.tar.gz: 53af8da6feddf2be67301f9754f2912a08fe246bada0b68eb89b2eb49b70a3279db4ded5645f3438bc859e1a57be3cde29e6c1fc59526db2747d1cc98a5525c6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.0] - 2026-08-18
11
+
12
+ The admin half, plus the two ends of employment that were still manual.
13
+ **Adds one migration.**
14
+
15
+ ### Added
16
+
17
+ - **Admin screens for everything shipped since 0.11.0**: decide a claim and
18
+ record the payroll month it was paid with, answer or hand on a help-desk
19
+ request, hand out and take back an asset. The engine had all of this; there
20
+ was no way to do any of it.
21
+ - **Assets.** One live holder at a time, enforced by a partial unique index —
22
+ two open assignments would mean the laptop is in two places, which it is
23
+ not. Assignments are append-only and record what condition it came back in,
24
+ because "screen cracked" is the difference between an asset returned and an
25
+ asset written off.
26
+ - **Joining and leaving checklists**, from templates. The joining list opens
27
+ when a profile is created and the leaving list when an exit date is
28
+ stamped — the checklist nobody remembers to start is the one that does not
29
+ happen. Opening is idempotent, so adding a template later adds only what is
30
+ new. A failure to build one can never stop an employee being created.
31
+ - Each step names the permission its owner should hold, so IT taking back a
32
+ laptop and HR collecting a document are not the same person by accident.
33
+ - Permissions `asset.view`, `asset.manage`, `checklist.manage`.
34
+
35
+ ### Why it matters
36
+
37
+ An asset that is never returned is a cost nobody notices, and access that is
38
+ never revoked is a security problem that outlives the employment. Both were
39
+ previously somebody's memory.
40
+
10
41
  ## [0.13.0] - 2026-08-18
11
42
 
12
43
  The screens. No migration.
@@ -762,7 +793,8 @@ Initial release.
762
793
  bus with per-event channel matrix (bell, email, leadership email/bell),
763
794
  daily leadership digest and an append-only audit trail.
764
795
 
765
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.13.0...HEAD
796
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.14.0...HEAD
797
+ [0.14.0]: https://github.com/kshtzkr/hr_lite/compare/v0.13.0...v0.14.0
766
798
  [0.13.0]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...v0.13.0
767
799
  [0.12.0]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...v0.12.0
768
800
  [0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
@@ -0,0 +1,53 @@
1
+ module HrLite
2
+ module Admin
3
+ # Who has the laptop.
4
+ class AssetsController < BaseController
5
+ skip_before_action :require_operations_access!
6
+ before_action :require_assets!
7
+
8
+ def index
9
+ @assets = paginate(Asset.order(:status, :name).includes(asset_assignments: :user))
10
+ @outstanding = AssetAssignment.live.includes(:asset, :user)
11
+ end
12
+
13
+ def new
14
+ @asset = Asset.new
15
+ end
16
+
17
+ def create
18
+ @asset = Asset.new(asset_params)
19
+ if @asset.save
20
+ redirect_to admin_assets_path, notice: "#{@asset.name} added."
21
+ else
22
+ render :new, status: :unprocessable_entity
23
+ end
24
+ end
25
+
26
+ def assign
27
+ asset = Asset.find(params[:id])
28
+ user = HrLite.user_klass.find(params[:user_id])
29
+ asset.assign_to!(user, actor: hr_current_user)
30
+ redirect_to admin_assets_path, notice: "#{asset.name} given to #{HrLite.display_name(user)}."
31
+ rescue ActiveRecord::RecordInvalid
32
+ redirect_to admin_assets_path, alert: "#{asset.name} is already with somebody."
33
+ end
34
+
35
+ def take_back
36
+ asset = Asset.find(params[:id])
37
+ asset.return!(actor: hr_current_user, condition: params[:condition_note],
38
+ status: params[:status].presence_in(Asset::STATUSES) || "available")
39
+ redirect_to admin_assets_path, notice: "#{asset.name} taken back."
40
+ rescue ActiveRecord::RecordInvalid
41
+ redirect_to admin_assets_path, alert: "#{asset.name} is not with anybody."
42
+ end
43
+
44
+ private
45
+
46
+ def require_assets! = hr_require_permission!("asset.manage", scope: :all)
47
+
48
+ def asset_params
49
+ params.require(:asset).permit(:name, :category, :serial_number, :purchased_on, :notes)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,32 @@
1
+ module HrLite
2
+ module Admin
3
+ # Joining and leaving, step by step, with what is overdue at the top.
4
+ class ChecklistsController < BaseController
5
+ skip_before_action :require_operations_access!
6
+ before_action :require_checklists!
7
+
8
+ def index
9
+ @kind = params[:kind].presence_in(ChecklistTemplate::KINDS) || "onboarding"
10
+ @items = ChecklistItem.for_kind(@kind).outstanding.includes(:user)
11
+ .order(Arel.sql("due_on IS NULL, due_on"))
12
+ @overdue = @items.select(&:overdue?)
13
+ end
14
+
15
+ def complete
16
+ item = ChecklistItem.find(params[:id])
17
+ item.complete!(actor: hr_current_user, note: params[:note])
18
+ redirect_to admin_checklists_path(kind: item.kind), notice: "#{item.title} — done."
19
+ end
20
+
21
+ def reopen
22
+ item = ChecklistItem.find(params[:id])
23
+ item.reopen!(actor: hr_current_user)
24
+ redirect_to admin_checklists_path(kind: item.kind), notice: "#{item.title} — reopened."
25
+ end
26
+
27
+ private
28
+
29
+ def require_checklists! = hr_require_permission!("checklist.manage", scope: :all)
30
+ end
31
+ end
32
+ end
@@ -93,6 +93,10 @@ module HrLite
93
93
  exit_date = params[:date_of_exit].present? ? parse_date_param(params[:date_of_exit]) : Date.current
94
94
 
95
95
  @profile.update!(date_of_exit: exit_date)
96
+ # The last day has a list too — and it is the one that matters most,
97
+ # since an unreturned laptop and un-revoked access both outlive the
98
+ # employment.
99
+ ChecklistItem.open_for!(@profile.user, kind: "offboarding", anchor_date: exit_date)
96
100
  begin
97
101
  HrLite.config.offboard_user.call(@profile.user)
98
102
  rescue => e
@@ -0,0 +1,45 @@
1
+ module HrLite
2
+ module Admin
3
+ # The other side of the claim: decide it, then say when it was paid.
4
+ class ExpensesController < BaseController
5
+ skip_before_action :require_operations_access!
6
+ before_action :require_deciding!
7
+
8
+ def index
9
+ @status = params[:status].presence_in(Expense::STATUSES) || "submitted"
10
+ scope = Expense.where(status: @status).includes(:user, :category).recent_first
11
+ @expenses = paginate(hr_scope(scope, "expense.approve"))
12
+ end
13
+
14
+ def approve
15
+ expense = decidable.find(params[:id])
16
+ expense.approve!(actor: hr_current_user, note: params[:decision_note].presence)
17
+ redirect_to admin_expenses_path, notice: "Claim approved."
18
+ end
19
+
20
+ def reject
21
+ expense = decidable.find(params[:id])
22
+ note = params[:decision_note].to_s.strip
23
+ return redirect_to admin_expenses_path, alert: "A note is required to reject." if note.blank?
24
+
25
+ expense.reject!(actor: hr_current_user, note: note)
26
+ redirect_to admin_expenses_path, notice: "Claim rejected."
27
+ end
28
+
29
+ def reimburse
30
+ expense = decidable.find(params[:id])
31
+ month = parse_month_param(params[:period_month])
32
+ expense.reimburse!(actor: hr_current_user, period_month: month)
33
+ redirect_to admin_expenses_path, notice: "Marked reimbursed with #{month.strftime('%B %Y')}."
34
+ rescue ActiveRecord::RecordInvalid
35
+ redirect_to admin_expenses_path, alert: "Only an approved claim can be reimbursed."
36
+ end
37
+
38
+ private
39
+
40
+ def require_deciding! = hr_require_permission!("expense.approve", scope: :team)
41
+
42
+ def decidable = hr_scope(Expense.all, "expense.approve")
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ module HrLite
2
+ module Admin
3
+ # The desk itself.
4
+ class HrRequestsController < BaseController
5
+ skip_before_action :require_operations_access!
6
+ before_action :require_desk!
7
+
8
+ def index
9
+ @status = params[:status].presence_in(HrRequest::STATUSES) || "open"
10
+ @requests = paginate(HrRequest.where(status: @status).includes(:user, :assigned_to)
11
+ .recent_first)
12
+ end
13
+
14
+ def show
15
+ @request = HrRequest.find(params[:id])
16
+ end
17
+
18
+ def assign
19
+ request = HrRequest.find(params[:id])
20
+ assignee = HrLite.user_klass.find(params[:assignee_id])
21
+ request.assign!(actor: hr_current_user, assignee: assignee)
22
+ redirect_to admin_hr_request_path(request), notice: "Assigned to #{HrLite.display_name(assignee)}."
23
+ end
24
+
25
+ def resolve
26
+ request = HrRequest.find(params[:id])
27
+ request.resolve!(actor: hr_current_user, resolution: params[:resolution].to_s.strip)
28
+ redirect_to admin_hr_requests_path, notice: "Answered."
29
+ rescue ArgumentError
30
+ redirect_to admin_hr_request_path(request), alert: "Write an answer before resolving."
31
+ end
32
+
33
+ private
34
+
35
+ def require_desk! = hr_require_permission!("hr_request.manage", scope: :all)
36
+ end
37
+ end
38
+ end
@@ -26,7 +26,11 @@ module HrLite
26
26
  { label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
27
27
  { label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
28
28
  { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
29
- { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] }
29
+ { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] },
30
+ { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ] },
31
+ { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
32
+ { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
33
+ { label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
30
34
  ].freeze
31
35
 
32
36
  LEADERSHIP_NAV_ITEMS = [
@@ -0,0 +1,57 @@
1
+ module HrLite
2
+ # A laptop, a phone, a SIM. An asset nobody tracks is a cost nobody
3
+ # notices, and the moment it matters is somebody's last day.
4
+ class Asset < ApplicationRecord
5
+ include Audited
6
+
7
+ STATUSES = %w[available assigned returned lost damaged retired].freeze
8
+ CATEGORIES = %w[laptop phone sim id_card accessory vehicle other].freeze
9
+
10
+ has_many :asset_assignments, class_name: "HrLite::AssetAssignment", dependent: :destroy
11
+
12
+ validates :name, presence: true
13
+ validates :status, inclusion: { in: STATUSES }
14
+ validates :category, inclusion: { in: CATEGORIES }
15
+ validates :serial_number, uniqueness: { allow_blank: true }
16
+
17
+ scope :available, -> { where(status: "available") }
18
+ scope :out, -> { where(status: "assigned") }
19
+
20
+ STATUSES.each { |s| define_method("#{s}?") { status == s } }
21
+
22
+ def live_assignment = asset_assignments.find_by(returned_on: nil)
23
+
24
+ def holder = live_assignment&.user
25
+
26
+ def assign_to!(user, actor: nil, on: Date.current)
27
+ raise ActiveRecord::RecordInvalid.new(self), "already assigned" if live_assignment
28
+
29
+ transaction do
30
+ asset_assignments.create!(user_id: user.id, assigned_on: on, assigned_by_id: actor&.id)
31
+ update!(status: "assigned")
32
+ end
33
+ true
34
+ end
35
+
36
+ # `condition` records what came back — "screen cracked" is the difference
37
+ # between an asset returned and an asset written off.
38
+ def return!(actor: nil, on: Date.current, condition: nil, status: "available")
39
+ assignment = live_assignment
40
+ raise ActiveRecord::RecordInvalid.new(self), "not assigned" if assignment.nil?
41
+
42
+ transaction do
43
+ assignment.update!(returned_on: on, condition_note: condition.presence)
44
+ update!(status: status)
45
+ end
46
+ true
47
+ end
48
+
49
+ # Everything still out with somebody who has left — the report that
50
+ # should exist before an exit, not after.
51
+ def self.outstanding_for(user)
52
+ joins(:asset_assignments)
53
+ .where(hr_lite_asset_assignments: { user_id: user.id, returned_on: nil })
54
+ .distinct
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,24 @@
1
+ module HrLite
2
+ # Who had it, when, and what state it came back in. Append-only: editing an
3
+ # assignment away is how a missing laptop stops being anybody's.
4
+ class AssetAssignment < ApplicationRecord
5
+ belongs_to :asset, class_name: "HrLite::Asset"
6
+ belongs_to :user, class_name: HrLite.config.user_class
7
+ belongs_to :assigned_by, class_name: HrLite.config.user_class, optional: true
8
+
9
+ validates :assigned_on, presence: true
10
+ validate :returned_after_it_was_given
11
+
12
+ scope :live, -> { where(returned_on: nil) }
13
+
14
+ def live? = returned_on.nil?
15
+
16
+ private
17
+
18
+ def returned_after_it_was_given
19
+ return if returned_on.nil? || assigned_on.nil? || returned_on >= assigned_on
20
+
21
+ errors.add(:returned_on, "cannot be before the day it was handed over")
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,43 @@
1
+ module HrLite
2
+ # One step for one person. Kept even when its template is later deleted —
3
+ # what a company DID on somebody's first day is history, and history should
4
+ # not change because a template was tidied up.
5
+ class ChecklistItem < ApplicationRecord
6
+ include Audited
7
+
8
+ belongs_to :template, class_name: "HrLite::ChecklistTemplate", optional: true
9
+ belongs_to :user, class_name: HrLite.config.user_class
10
+ belongs_to :completed_by, class_name: HrLite.config.user_class, optional: true
11
+
12
+ validates :kind, inclusion: { in: ChecklistTemplate::KINDS }
13
+ validates :title, presence: true
14
+
15
+ scope :outstanding, -> { where(completed_at: nil) }
16
+ scope :for_kind, ->(kind) { where(kind: kind.to_s) }
17
+ scope :overdue, ->(on = Date.current) { outstanding.where(due_on: ...on) }
18
+
19
+ def done? = completed_at.present?
20
+
21
+ def overdue?(on = Date.current) = !done? && due_on.present? && due_on < on
22
+
23
+ def complete!(actor:, note: nil)
24
+ update!(completed_at: Time.current, completed_by_id: actor&.id, note: note.presence)
25
+ end
26
+
27
+ def reopen!(actor:)
28
+ update!(completed_at: nil, completed_by_id: nil)
29
+ end
30
+
31
+ # Builds somebody's list from the active templates. Idempotent: running
32
+ # it twice does not double the list, so a re-run after adding a template
33
+ # adds only what is new.
34
+ def self.open_for!(user, kind:, anchor_date:)
35
+ ChecklistTemplate.for_kind(kind).filter_map do |template|
36
+ next if exists?(user_id: user.id, kind: kind.to_s, title: template.title)
37
+
38
+ create!(user_id: user.id, template: template, kind: kind.to_s, title: template.title,
39
+ due_on: anchor_date + template.due_offset_days)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,50 @@
1
+ module HrLite
2
+ # "Collect the signed offer letter", "Revoke email access". The steps a
3
+ # company means to take every time and forgets once.
4
+ class ChecklistTemplate < ApplicationRecord
5
+ include Audited
6
+
7
+ KINDS = %w[onboarding offboarding].freeze
8
+
9
+ validates :kind, inclusion: { in: KINDS }
10
+ validates :title, presence: true
11
+ validate :owner_permission_is_declared
12
+
13
+ scope :active, -> { where(active: true) }
14
+ scope :for_kind, ->(kind) { active.where(kind: kind.to_s).order(:position, :id) }
15
+
16
+ def self.seed_defaults!
17
+ [
18
+ { kind: "onboarding", title: "Collect signed offer letter", position: 10,
19
+ owner_permission: "document.manage" },
20
+ { kind: "onboarding", title: "Collect PAN and Aadhaar", position: 20,
21
+ owner_permission: "document.manage" },
22
+ { kind: "onboarding", title: "Create payroll record and salary structure",
23
+ position: 30, owner_permission: "salary.manage", due_offset_days: 3 },
24
+ { kind: "onboarding", title: "Hand over laptop and access", position: 40,
25
+ owner_permission: "profile.manage" },
26
+ { kind: "offboarding", title: "Collect laptop and accessories", position: 10,
27
+ owner_permission: "profile.manage" },
28
+ { kind: "offboarding", title: "Revoke email and system access", position: 20,
29
+ owner_permission: "profile.manage" },
30
+ { kind: "offboarding", title: "Full and final settlement", position: 30,
31
+ owner_permission: "payroll.manage", due_offset_days: 30 },
32
+ { kind: "offboarding", title: "Issue relieving and experience letters",
33
+ position: 40, owner_permission: "document.manage", due_offset_days: 7 }
34
+ ].filter_map do |attributes|
35
+ next if exists?(kind: attributes[:kind], title: attributes[:title])
36
+
37
+ create!(attributes)
38
+ "#{attributes[:kind]}: #{attributes[:title]}"
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def owner_permission_is_declared
45
+ return if owner_permission.blank? || Permissions.valid?(owner_permission)
46
+
47
+ errors.add(:owner_permission, "is not a known permission")
48
+ end
49
+ end
50
+ end
@@ -6,6 +6,7 @@ module HrLite
6
6
  class EmployeeProfile < ApplicationRecord
7
7
  include EncryptedMoney
8
8
  include Audited
9
+ after_create_commit :open_onboarding_checklist
9
10
 
10
11
  belongs_to :user, class_name: HrLite.config.user_class
11
12
  # Reporting line (L1). Optional; the org chart treats manager-less
@@ -152,5 +153,17 @@ module HrLite
152
153
 
153
154
  errors.add(:date_of_exit, "must be after joining")
154
155
  end
156
+
157
+ private
158
+
159
+ # Day one has a list, and it is the same list every time. Opened on
160
+ # create rather than by hand, because the one nobody remembers to start
161
+ # is the one that does not happen.
162
+ def open_onboarding_checklist
163
+ ChecklistItem.open_for!(user, kind: "onboarding", anchor_date: date_of_joining)
164
+ rescue => e
165
+ # A checklist must never be the reason an employee cannot be created.
166
+ Rails.logger.error("[hr_lite] onboarding checklist failed: #{e.class}: #{e.message}")
167
+ end
155
168
  end
156
169
  end
@@ -0,0 +1,59 @@
1
+ <% content_for(:page_title) { "Assets" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Assets</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_asset_path %>">Add asset</a>
6
+ </div>
7
+ </div>
8
+
9
+ <% if @outstanding.any? %>
10
+ <section class="hrl-card">
11
+ <h2 class="hrl-card__title">Currently out</h2>
12
+ <ul>
13
+ <% @outstanding.each do |assignment| %>
14
+ <li class="hrl-small">
15
+ <strong><%= assignment.asset.name %></strong> —
16
+ <%= hr_display_name(assignment.user) %>,
17
+ since <%= assignment.assigned_on.strftime("%d %b %Y") %>
18
+ </li>
19
+ <% end %>
20
+ </ul>
21
+ </section>
22
+ <% end %>
23
+
24
+ <section class="hrl-card">
25
+ <div class="hrl-table-wrap">
26
+ <table class="hrl-table hrl-table--stack">
27
+ <thead><tr><th>Asset</th><th>Status</th><th>With</th><th></th></tr></thead>
28
+ <tbody>
29
+ <% @assets.each do |asset| %>
30
+ <tr>
31
+ <td data-label="Asset">
32
+ <strong><%= asset.name %></strong>
33
+ <div class="hrl-small hrl-muted">
34
+ <%= asset.category.humanize %><%= " · #{asset.serial_number}" if asset.serial_number.present? %>
35
+ </div>
36
+ </td>
37
+ <td data-label="Status"><%= hrl_request_status_badge(asset.status) %></td>
38
+ <td data-label="With"><%= asset.holder ? hr_display_name(asset.holder) : "—" %></td>
39
+ <td data-label="">
40
+ <% if asset.assigned? %>
41
+ <%= form_with url: hr_lite.take_back_admin_asset_path(asset), method: :post do %>
42
+ <%= text_field_tag :condition_note, nil, placeholder: "Condition" %>
43
+ <%= submit_tag "Take back", class: "hrl-btn" %>
44
+ <% end %>
45
+ <% elsif asset.available? %>
46
+ <%= form_with url: hr_lite.assign_admin_asset_path(asset), method: :post do %>
47
+ <%= select_tag :user_id,
48
+ options_for_select(HrLite.employees.map { |u| [ hr_display_name(u), u.id ] }) %>
49
+ <%= submit_tag "Hand over", class: "hrl-btn" %>
50
+ <% end %>
51
+ <% end %>
52
+ </td>
53
+ </tr>
54
+ <% end %>
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ <%= hrl_pagination %>
59
+ </section>
@@ -0,0 +1,23 @@
1
+ <% content_for(:page_title) { "Add asset" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Add asset</h1></div>
3
+
4
+ <section class="hrl-card">
5
+ <%= form_with model: @asset, url: hr_lite.admin_assets_path, method: :post, local: true do |f| %>
6
+ <%= render "hr_lite/shared/form_errors", record: @asset %>
7
+ <div class="hrl-field"><%= f.label :name %><%= f.text_field :name %></div>
8
+ <div class="hrl-field">
9
+ <%= f.label :category %>
10
+ <%= f.select :category, HrLite::Asset::CATEGORIES.map { |c| [ c.humanize, c ] } %>
11
+ </div>
12
+ <div class="hrl-field">
13
+ <%= f.label :serial_number, "Serial number (optional)" %>
14
+ <%= f.text_field :serial_number %>
15
+ </div>
16
+ <div class="hrl-field"><%= f.label :purchased_on %><%= f.date_field :purchased_on %></div>
17
+ <div class="hrl-field"><%= f.label :notes %><%= f.text_field :notes %></div>
18
+ <div class="hrl-form-actions">
19
+ <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
20
+ <a class="hrl-btn" href="<%= hr_lite.admin_assets_path %>">Cancel</a>
21
+ </div>
22
+ <% end %>
23
+ </section>
@@ -0,0 +1,50 @@
1
+ <% content_for(:page_title) { "Joining & exits" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Joining &amp; exits</h1></div>
3
+
4
+ <section class="hrl-card">
5
+ <div class="hrl-row">
6
+ <% HrLite::ChecklistTemplate::KINDS.each do |kind| %>
7
+ <a class="hrl-btn <%= "hrl-btn--primary" if kind == @kind %>"
8
+ href="<%= hr_lite.admin_checklists_path(kind: kind) %>"><%= kind.humanize %></a>
9
+ <% end %>
10
+ </div>
11
+ </section>
12
+
13
+ <% if @overdue.any? %>
14
+ <section class="hrl-card">
15
+ <h2 class="hrl-card__title"><%= @overdue.size %> overdue</h2>
16
+ <p class="hrl-small hrl-muted">
17
+ An unreturned laptop and un-revoked access both outlive the employment.
18
+ </p>
19
+ </section>
20
+ <% end %>
21
+
22
+ <section class="hrl-card">
23
+ <% if @items.empty? %>
24
+ <p class="hrl-muted">Nothing outstanding.</p>
25
+ <% else %>
26
+ <div class="hrl-table-wrap">
27
+ <table class="hrl-table hrl-table--stack">
28
+ <thead><tr><th>Step</th><th>Who</th><th>Due</th><th></th></tr></thead>
29
+ <tbody>
30
+ <% @items.each do |item| %>
31
+ <tr>
32
+ <td data-label="Step">
33
+ <%= item.title %>
34
+ <% if item.overdue? %><span class="hrl-badge hrl-badge--bad">Overdue</span><% end %>
35
+ </td>
36
+ <td data-label="Who"><%= hr_display_name(item.user) %></td>
37
+ <td data-label="Due"><%= item.due_on&.strftime("%d %b %Y") || "—" %></td>
38
+ <td data-label="">
39
+ <%= form_with url: hr_lite.complete_admin_checklist_path(item), method: :post do %>
40
+ <%= text_field_tag :note, nil, placeholder: "Note" %>
41
+ <%= submit_tag "Done", class: "hrl-btn hrl-btn--primary" %>
42
+ <% end %>
43
+ </td>
44
+ </tr>
45
+ <% end %>
46
+ </tbody>
47
+ </table>
48
+ </div>
49
+ <% end %>
50
+ </section>
@@ -0,0 +1,54 @@
1
+ <% content_for(:page_title) { "Claims" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Expense claims</h1></div>
3
+
4
+ <section class="hrl-card">
5
+ <div class="hrl-row">
6
+ <% HrLite::Expense::STATUSES.each do |status| %>
7
+ <a class="hrl-btn <%= "hrl-btn--primary" if status == @status %>"
8
+ href="<%= hr_lite.admin_expenses_path(status: status) %>"><%= status.humanize %></a>
9
+ <% end %>
10
+ </div>
11
+ </section>
12
+
13
+ <section class="hrl-card">
14
+ <% if @expenses.empty? %>
15
+ <p class="hrl-muted">Nothing <%= @status %>.</p>
16
+ <% else %>
17
+ <div class="hrl-table-wrap">
18
+ <table class="hrl-table hrl-table--stack">
19
+ <thead><tr><th>Who</th><th>What</th><th>Amount</th><th></th></tr></thead>
20
+ <tbody>
21
+ <% @expenses.each do |expense| %>
22
+ <tr>
23
+ <td data-label="Who"><%= hr_display_name(expense.user) %></td>
24
+ <td data-label="What">
25
+ <%= expense.category.name %>
26
+ <div class="hrl-small hrl-muted">
27
+ <%= expense.description %> · <%= expense.spent_on.strftime("%d %b %Y") %>
28
+ </div>
29
+ </td>
30
+ <td data-label="Amount" class="hrl-num"><%= hrl_money(expense.amount) %></td>
31
+ <td data-label="">
32
+ <% if expense.submitted? %>
33
+ <%= form_with url: hr_lite.approve_admin_expense_path(expense), method: :post do %>
34
+ <%= submit_tag "Approve", class: "hrl-btn hrl-btn--primary" %>
35
+ <% end %>
36
+ <%= form_with url: hr_lite.reject_admin_expense_path(expense), method: :post do %>
37
+ <%= text_field_tag :decision_note, nil, placeholder: "Why not" %>
38
+ <%= submit_tag "Reject", class: "hrl-btn" %>
39
+ <% end %>
40
+ <% elsif expense.approved? %>
41
+ <%= form_with url: hr_lite.reimburse_admin_expense_path(expense), method: :post do %>
42
+ <%= text_field_tag :period_month, Date.current.strftime("%Y-%m"), size: 8 %>
43
+ <%= submit_tag "Mark reimbursed", class: "hrl-btn" %>
44
+ <% end %>
45
+ <% end %>
46
+ </td>
47
+ </tr>
48
+ <% end %>
49
+ </tbody>
50
+ </table>
51
+ </div>
52
+ <%= hrl_pagination %>
53
+ <% end %>
54
+ </section>
@@ -0,0 +1,41 @@
1
+ <% content_for(:page_title) { "Help desk" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Help desk</h1></div>
3
+
4
+ <section class="hrl-card">
5
+ <div class="hrl-row">
6
+ <% HrLite::HrRequest::STATUSES.each do |status| %>
7
+ <a class="hrl-btn <%= "hrl-btn--primary" if status == @status %>"
8
+ href="<%= hr_lite.admin_hr_requests_path(status: status) %>"><%= status.humanize %></a>
9
+ <% end %>
10
+ </div>
11
+ </section>
12
+
13
+ <section class="hrl-card">
14
+ <% if @requests.empty? %>
15
+ <p class="hrl-muted">Nothing <%= @status.humanize.downcase %>.</p>
16
+ <% else %>
17
+ <div class="hrl-table-wrap">
18
+ <table class="hrl-table hrl-table--stack">
19
+ <thead><tr><th>Request</th><th>Who</th><th>With</th><th></th></tr></thead>
20
+ <tbody>
21
+ <% @requests.each do |request| %>
22
+ <tr>
23
+ <td data-label="Request">
24
+ <a href="<%= hr_lite.admin_hr_request_path(request) %>"><%= request.subject %></a>
25
+ <div class="hrl-small hrl-muted"><%= request.category_label %></div>
26
+ </td>
27
+ <td data-label="Who"><%= hr_display_name(request.user) %></td>
28
+ <td data-label="With">
29
+ <%= request.assigned_to ? hr_display_name(request.assigned_to) : "—" %>
30
+ </td>
31
+ <td data-label="">
32
+ <a class="hrl-small" href="<%= hr_lite.admin_hr_request_path(request) %>">Open</a>
33
+ </td>
34
+ </tr>
35
+ <% end %>
36
+ </tbody>
37
+ </table>
38
+ </div>
39
+ <%= hrl_pagination %>
40
+ <% end %>
41
+ </section>
@@ -0,0 +1,47 @@
1
+ <% content_for(:page_title) { @request.subject } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title"><%= @request.subject %></h1></div>
3
+
4
+ <section class="hrl-card">
5
+ <p class="hrl-small hrl-muted">
6
+ <%= @request.category_label %> · <%= hr_display_name(@request.user) %> ·
7
+ <%= @request.created_at.to_date.strftime("%d %b %Y") %> ·
8
+ <%= hrl_request_status_badge(@request.status) %>
9
+ </p>
10
+ <% if @request.body.present? %>
11
+ <div class="hrl-prose"><%= simple_format(@request.body) %></div>
12
+ <% end %>
13
+ </section>
14
+
15
+ <% unless @request.resolved? || @request.closed? || @request.cancelled? %>
16
+ <section class="hrl-card">
17
+ <h2 class="hrl-card__title">Answer</h2>
18
+ <%= form_with url: hr_lite.resolve_admin_hr_request_path(@request), method: :post do %>
19
+ <div class="hrl-field">
20
+ <%= label_tag :resolution, "What you did" %>
21
+ <%= text_area_tag :resolution, nil, rows: 4 %>
22
+ </div>
23
+ <div class="hrl-form-actions">
24
+ <%= submit_tag "Send answer", class: "hrl-btn hrl-btn--primary" %>
25
+ </div>
26
+ <% end %>
27
+
28
+ <%= form_with url: hr_lite.assign_admin_hr_request_path(@request), method: :post do %>
29
+ <div class="hrl-field">
30
+ <%= label_tag :assignee_id, "Or hand it to somebody" %>
31
+ <%= select_tag :assignee_id,
32
+ options_for_select(HrLite.users_holding("hr_request.manage")
33
+ .map { |u| [ hr_display_name(u), u.id ] }) %>
34
+ </div>
35
+ <div class="hrl-form-actions">
36
+ <%= submit_tag "Assign", class: "hrl-btn" %>
37
+ </div>
38
+ <% end %>
39
+ </section>
40
+ <% end %>
41
+
42
+ <% if @request.resolution.present? %>
43
+ <section class="hrl-card">
44
+ <h2 class="hrl-card__title">Answered</h2>
45
+ <div class="hrl-prose"><%= simple_format(@request.resolution) %></div>
46
+ </section>
47
+ <% end %>
data/config/routes.rb CHANGED
@@ -62,6 +62,18 @@ HrLite::Engine.routes.draw do
62
62
  end
63
63
  resources :statutory_rate_cards, only: %i[index new create edit update]
64
64
  resources :reports, only: %i[index show]
65
+ resources :expenses, only: :index do
66
+ member { post :approve; post :reject; post :reimburse }
67
+ end
68
+ resources :hr_requests, only: %i[index show] do
69
+ member { post :assign; post :resolve }
70
+ end
71
+ resources :assets, only: %i[index new create] do
72
+ member { post :assign; post :take_back }
73
+ end
74
+ resources :checklists, only: :index do
75
+ member { post :complete; post :reopen }
76
+ end
65
77
  resources :leave_types, except: :show
66
78
  resources :office_locations, except: :show
67
79
  resources :holidays, only: %i[index create update destroy] do
@@ -0,0 +1,76 @@
1
+ class CreateHrLiteAssetsAndOnboarding < ActiveRecord::Migration[8.1]
2
+ # The two ends of employment that were still manual: the laptop somebody is
3
+ # given on day one, and the checklist nobody remembers on their last.
4
+ #
5
+ # An asset that is never returned is a cost nobody notices, and access that
6
+ # is never revoked is a security problem that outlives the employment.
7
+ def change
8
+ create_table :hr_lite_assets do |t|
9
+ t.string :name, null: false
10
+ # laptop | phone | sim | id_card | accessory | vehicle | other
11
+ t.string :category, null: false, default: "other"
12
+ t.string :serial_number
13
+ t.date :purchased_on
14
+ # available | assigned | returned | lost | damaged | retired
15
+ t.string :status, null: false, default: "available"
16
+ t.text :notes
17
+ t.timestamps
18
+ end
19
+ add_index :hr_lite_assets, :status
20
+ add_index :hr_lite_assets, :serial_number, unique: true,
21
+ where: "serial_number IS NOT NULL"
22
+ add_check_constraint :hr_lite_assets,
23
+ "status IN ('available', 'assigned', 'returned', 'lost', 'damaged', 'retired')",
24
+ name: "hr_lite_assets_status_check"
25
+
26
+ # Append-only: who had it, when, and what state it came back in. Editing
27
+ # an assignment away is how a missing laptop stops being anybody's.
28
+ create_table :hr_lite_asset_assignments do |t|
29
+ t.references :asset, null: false, index: true,
30
+ foreign_key: { to_table: :hr_lite_assets, on_delete: :cascade }
31
+ t.bigint :user_id, null: false
32
+ t.date :assigned_on, null: false
33
+ t.date :returned_on
34
+ t.string :condition_note
35
+ t.bigint :assigned_by_id
36
+ t.timestamps
37
+ end
38
+ add_index :hr_lite_asset_assignments, %i[user_id returned_on]
39
+ # One live holder at a time — two open assignments means the asset is in
40
+ # two places, which it is not.
41
+ add_index :hr_lite_asset_assignments, :asset_id,
42
+ unique: true, where: "returned_on IS NULL",
43
+ name: "index_hr_lite_asset_assignments_one_live_per_asset"
44
+
45
+ create_table :hr_lite_checklist_templates do |t|
46
+ # onboarding | offboarding
47
+ t.string :kind, null: false
48
+ t.string :title, null: false
49
+ t.integer :position, null: false, default: 0
50
+ # Which permission the person who ticks it should hold — IT hands back
51
+ # a laptop, HR collects a document, and neither should be the other.
52
+ t.string :owner_permission
53
+ # Days from the joining or leaving date this is due.
54
+ t.integer :due_offset_days, null: false, default: 0
55
+ t.boolean :active, null: false, default: true
56
+ t.timestamps
57
+ end
58
+ add_index :hr_lite_checklist_templates, %i[kind position]
59
+ add_check_constraint :hr_lite_checklist_templates, "kind IN ('onboarding', 'offboarding')",
60
+ name: "hr_lite_checklist_templates_kind_check"
61
+
62
+ create_table :hr_lite_checklist_items do |t|
63
+ t.bigint :user_id, null: false
64
+ t.references :template, index: true,
65
+ foreign_key: { to_table: :hr_lite_checklist_templates, on_delete: :nullify }
66
+ t.string :kind, null: false
67
+ t.string :title, null: false
68
+ t.date :due_on
69
+ t.datetime :completed_at
70
+ t.bigint :completed_by_id
71
+ t.string :note
72
+ t.timestamps
73
+ end
74
+ add_index :hr_lite_checklist_items, %i[user_id kind completed_at]
75
+ end
76
+ end
@@ -56,6 +56,9 @@ module HrLite
56
56
  "hr_request.manage" => [ "Help desk", "Answer and assign HR requests" ],
57
57
  "policy.view" => [ "Policies", "Read published policies" ],
58
58
  "policy.manage" => [ "Policies", "Write, publish and re-issue policies" ],
59
+ "asset.view" => [ "Assets", "See company assets and who holds them" ],
60
+ "asset.manage" => [ "Assets", "Assign and take back company assets" ],
61
+ "checklist.manage" => [ "Lifecycle", "Work through joining and leaving checklists" ],
59
62
  "settings.manage" => [ "Administration", "Change company settings, offices and policy" ],
60
63
  "audit.view" => [ "Administration", "Read the audit trail" ],
61
64
  "audit.view_money" => [ "Administration", "Read audit rows about pay, appraisals and promotions" ],
@@ -19,7 +19,7 @@ module HrLite
19
19
  "profile.view" => "self", "appraisal.view" => "self",
20
20
  "resignation.view" => "self", "document.view" => "self", "tax.view" => "self",
21
21
  "expense.claim" => "self", "benefit.view" => "self",
22
- "hr_request.raise" => "self", "policy.view" => "all"
22
+ "hr_request.raise" => "self", "policy.view" => "all", "asset.view" => "self"
23
23
  }
24
24
  },
25
25
  Role::MANAGER => {
@@ -40,7 +40,8 @@ module HrLite
40
40
  "appraisal.view" => "self", "resignation.view" => "all",
41
41
  "document.view" => "all", "tax.view" => "self",
42
42
  "expense.claim" => "self", "benefit.view" => "all", "benefit.manage" => "all",
43
- "hr_request.raise" => "self", "hr_request.manage" => "all", "policy.view" => "all"
43
+ "hr_request.raise" => "self", "hr_request.manage" => "all", "policy.view" => "all",
44
+ "asset.view" => "all", "asset.manage" => "all", "checklist.manage" => "all"
44
45
  }
45
46
  },
46
47
  Role::FINANCE => {
@@ -65,7 +66,8 @@ module HrLite
65
66
  "resignation.view" => "all", "resignation.manage" => "all",
66
67
  "settings.manage" => "all", "audit.view" => "all", "payroll.view" => "self",
67
68
  "document.view" => "all", "expense.approve" => "all", "benefit.manage" => "all",
68
- "hr_request.manage" => "all", "policy.view" => "all", "policy.manage" => "all"
69
+ "hr_request.manage" => "all", "policy.view" => "all", "policy.manage" => "all",
70
+ "asset.view" => "all", "asset.manage" => "all", "checklist.manage" => "all"
69
71
  }
70
72
  },
71
73
  Role::SUPER_ADMIN => {
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.13.0"
2
+ VERSION = "0.14.0"
3
3
  end
@@ -5,7 +5,8 @@ namespace :hr_lite do
5
5
  require "hr_lite/role_seeds"
6
6
  require "hr_lite/statutory_seeds"
7
7
  created = HrLite::Seeds.run! + HrLite::RoleSeeds.call + HrLite::StatutorySeeds.call +
8
- HrLite::SalaryComponent.seed_defaults!
8
+ HrLite::SalaryComponent.seed_defaults! +
9
+ HrLite::ChecklistTemplate.seed_defaults!
9
10
  puts created.any? ? "hr_lite:seed created: #{created.join(', ')}" : "hr_lite:seed — nothing to do"
10
11
  end
11
12
 
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.13.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -56,13 +56,17 @@ files:
56
56
  - app/assets/stylesheets/hr_lite/application.css
57
57
  - app/assets/stylesheets/hr_lite/hr_lite.css
58
58
  - app/controllers/hr_lite/admin/appraisals_controller.rb
59
+ - app/controllers/hr_lite/admin/assets_controller.rb
59
60
  - app/controllers/hr_lite/admin/attendances_controller.rb
60
61
  - app/controllers/hr_lite/admin/audit_logs_controller.rb
61
62
  - app/controllers/hr_lite/admin/base_controller.rb
63
+ - app/controllers/hr_lite/admin/checklists_controller.rb
62
64
  - app/controllers/hr_lite/admin/comp_off_requests_controller.rb
63
65
  - app/controllers/hr_lite/admin/designation_changes_controller.rb
64
66
  - app/controllers/hr_lite/admin/employees_controller.rb
67
+ - app/controllers/hr_lite/admin/expenses_controller.rb
65
68
  - app/controllers/hr_lite/admin/holidays_controller.rb
69
+ - app/controllers/hr_lite/admin/hr_requests_controller.rb
66
70
  - app/controllers/hr_lite/admin/leadership_controller.rb
67
71
  - app/controllers/hr_lite/admin/leave_balances_controller.rb
68
72
  - app/controllers/hr_lite/admin/leave_requests_controller.rb
@@ -121,10 +125,14 @@ files:
121
125
  - app/models/hr_lite/approval_delegation.rb
122
126
  - app/models/hr_lite/approval_flow.rb
123
127
  - app/models/hr_lite/approval_step.rb
128
+ - app/models/hr_lite/asset.rb
129
+ - app/models/hr_lite/asset_assignment.rb
124
130
  - app/models/hr_lite/attendance_record.rb
125
131
  - app/models/hr_lite/audit_log.rb
126
132
  - app/models/hr_lite/benefit.rb
127
133
  - app/models/hr_lite/benefit_enrolment.rb
134
+ - app/models/hr_lite/checklist_item.rb
135
+ - app/models/hr_lite/checklist_template.rb
128
136
  - app/models/hr_lite/comp_off_request.rb
129
137
  - app/models/hr_lite/designation_change.rb
130
138
  - app/models/hr_lite/document.rb
@@ -178,9 +186,12 @@ files:
178
186
  - app/views/hr_lite/admin/appraisals/_form.html.erb
179
187
  - app/views/hr_lite/admin/appraisals/edit.html.erb
180
188
  - app/views/hr_lite/admin/appraisals/new.html.erb
189
+ - app/views/hr_lite/admin/assets/index.html.erb
190
+ - app/views/hr_lite/admin/assets/new.html.erb
181
191
  - app/views/hr_lite/admin/attendances/index.html.erb
182
192
  - app/views/hr_lite/admin/attendances/show.html.erb
183
193
  - app/views/hr_lite/admin/audit_logs/index.html.erb
194
+ - app/views/hr_lite/admin/checklists/index.html.erb
184
195
  - app/views/hr_lite/admin/comp_off_requests/index.html.erb
185
196
  - app/views/hr_lite/admin/comp_off_requests/show.html.erb
186
197
  - app/views/hr_lite/admin/designation_changes/new.html.erb
@@ -189,7 +200,10 @@ files:
189
200
  - app/views/hr_lite/admin/employees/index.html.erb
190
201
  - app/views/hr_lite/admin/employees/new.html.erb
191
202
  - app/views/hr_lite/admin/employees/show.html.erb
203
+ - app/views/hr_lite/admin/expenses/index.html.erb
192
204
  - app/views/hr_lite/admin/holidays/index.html.erb
205
+ - app/views/hr_lite/admin/hr_requests/index.html.erb
206
+ - app/views/hr_lite/admin/hr_requests/show.html.erb
193
207
  - app/views/hr_lite/admin/leave_balances/index.html.erb
194
208
  - app/views/hr_lite/admin/leave_requests/index.html.erb
195
209
  - app/views/hr_lite/admin/leave_requests/show.html.erb
@@ -306,6 +320,7 @@ files:
306
320
  - db/migrate/20260818002627_create_hr_lite_payroll_components_and_loans.rb
307
321
  - db/migrate/20260818003616_create_hr_lite_documents_and_declarations.rb
308
322
  - db/migrate/20260818004707_create_hr_lite_employee_services.rb
323
+ - db/migrate/20260818010401_create_hr_lite_assets_and_onboarding.rb
309
324
  - lib/generators/hr_lite/install/install_generator.rb
310
325
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
311
326
  - lib/generators/hr_lite/install/templates/initializer.rb