hr_lite 0.1.1 → 0.2.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: 57ee911f7b4340c811de3636a4b9bef1a206e722fa1c6ff0efebdfa176e6c8cb
4
- data.tar.gz: adbadd872c07e237addd070608991d3316db58dda3ce9fd57dee7943b546deda
3
+ metadata.gz: a85f50e1151c664825892bd973f34842d1330e4878e39bb4984233880258c446
4
+ data.tar.gz: 95012fa0d0b7332e35e1fdef90ace239a95b415cbdc9d3101cb86fac18de9f7c
5
5
  SHA512:
6
- metadata.gz: c6db60f81387a610b0a9de87b19f07873f135e8872f8683ba07e2eeaef62481ab582f523296c779f3a7d43ca1d665c498a614ca2abd8ecc33500ed9228c58a4d
7
- data.tar.gz: 2beba306b8543851c13b3a41c5a1805b1c09b35af57a88eb650b7eb14a31c4744c6b47bedc09c15100bc373762c32194301ce38fc149e9efb3b678dcd3c83621
6
+ metadata.gz: 6b532c9f0fe3b5408eea1e847c40329ab28da2a4c996d7cb229d1d745187fe33b5ace2fb17b8d1ea1ec1fc2e326cfc91dfd8881d296197a9d80bb40ded90a6f2
7
+ data.tar.gz: 64dfd72f81d71bd48ce01a4815c95360994dfb6b06235933ba893bb8245bdf4bd79ab384d7f7ca441fcec3ab158861c73d9bf8485b2478811e7df90d67443bb9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-07-19
11
+
12
+ ### Added
13
+
14
+ - Resignations: employees submit/withdraw from the portal; leadership
15
+ accepts with a confirmed last working day that stamps the profile's
16
+ exit date (payroll/attendance clip to it automatically).
17
+ - Onboarding: leadership creates the sign-in with the profile via the
18
+ `onboard_user` hook (no self sign-up anywhere); offboarding stamps the
19
+ exit date and revokes access via `offboard_user` — records are never
20
+ deleted.
21
+ - `PayrollAutoDraftJob`: monthly automation that drafts + computes the
22
+ previous month's payroll from attendance and notifies leadership for
23
+ review; publishing stays human.
24
+ - Company logo (`company[:logo_url]`, data-URIs welcome) in the shell
25
+ and on salary-slip PDFs.
26
+ - Keka-style left-rail navigation on desktop with grouped sections
27
+ (My work / Team / Organisation); mobile keeps the bottom tab bar.
28
+ - New notification-matrix events: `resignation.*`, `employee.onboarded`,
29
+ `payroll.draft_ready`.
30
+
31
+ ### Fixed
32
+
33
+ - The auto-created settings row no longer emails leadership
34
+ ("Someone created Setting" noise); real settings edits stay audited.
35
+
36
+ ## [0.1.2] - 2026-07-19
37
+
38
+ ### Fixed
39
+
40
+ - Slip PDF template no longer 500s when rendered by host code
41
+ (`config.render_pdf`): amount-in-words is now a PORO
42
+ (`HrLite::AmountInWords`), not a view helper, since engine helpers are
43
+ not in scope under a host renderer. Caught live against a host app;
44
+ regression spec renders the template through a bare controller.
45
+
10
46
  ## [0.1.1] - 2026-07-19
11
47
 
12
48
  ### Added
@@ -33,6 +33,8 @@
33
33
  overflow-x: hidden;
34
34
  }
35
35
  .hrl-body a { color: var(--hrl-accent); text-decoration: none; }
36
+ .hrl-shell { display: flex; min-height: 100vh; }
37
+ .hrl-content { flex: 1; min-width: 0; }
36
38
  .hrl-main {
37
39
  padding: .85rem;
38
40
  padding-bottom: calc(4.4rem + env(safe-area-inset-bottom));
@@ -41,30 +43,26 @@
41
43
  }
42
44
  .hrl-main > * { min-width: 0; }
43
45
 
44
- /* App bar */
45
- .hrl-appbar {
46
- background: var(--hrl-card);
47
- border-bottom: 1px solid var(--hrl-line);
48
- padding-top: env(safe-area-inset-top);
49
- position: sticky; top: 0; z-index: 20;
50
- }
51
- .hrl-appbar__inner {
52
- display: flex; align-items: center; gap: .75rem;
53
- padding: .6rem .85rem; max-width: 68rem; margin: 0 auto;
54
- }
55
- .hrl-appbar__brand { font-weight: 700; font-size: 1.02rem; color: var(--hrl-ink); }
56
- .hrl-appbar__user { margin-left: auto; color: var(--hrl-muted); font-size: .82rem; white-space: nowrap; }
57
-
58
- /* Nav: hidden inline on phones (tabbar covers it), row on >=768px */
59
- .hrl-nav--top { display: none; }
60
- .hrl-nav__sep { width: 1px; align-self: stretch; background: var(--hrl-line); margin: 0 .2rem; }
46
+ /* Left rail (Keka-style) — hidden on phones, fixed column on >=768px */
47
+ .hrl-side { display: none; }
61
48
  .hrl-nav__link {
62
49
  color: var(--hrl-muted); font-size: .88rem; padding: .5rem .6rem;
63
50
  border-radius: var(--hrl-radius-sm); min-height: 44px;
64
51
  display: inline-flex; align-items: center;
65
52
  }
66
53
  .hrl-nav__link--active { color: var(--hrl-accent); background: var(--hrl-info-bg); font-weight: 600; }
67
- .hrl-nav__link--back { margin-left: .25rem; border: 1px solid var(--hrl-line); }
54
+
55
+ /* Mobile app bar */
56
+ .hrl-appbar--mobile {
57
+ background: var(--hrl-card);
58
+ border-bottom: 1px solid var(--hrl-line);
59
+ padding: calc(.6rem + env(safe-area-inset-top)) .85rem .6rem;
60
+ position: sticky; top: 0; z-index: 20;
61
+ display: flex; align-items: center; gap: .75rem;
62
+ }
63
+ .hrl-appbar__brand { font-weight: 700; font-size: 1.02rem; color: var(--hrl-ink); display: inline-flex; align-items: center; }
64
+ .hrl-appbar__logo { height: 28px; width: auto; display: block; }
65
+ .hrl-appbar__user { margin-left: auto; color: var(--hrl-muted); font-size: .82rem; white-space: nowrap; }
68
66
 
69
67
  /* Bottom tab bar (phone) */
70
68
  .hrl-tabbar {
@@ -76,9 +74,33 @@
76
74
  .hrl-tabbar .hrl-nav__link { flex: 1; justify-content: center; font-size: .78rem; }
77
75
 
78
76
  @media (min-width: 768px) {
79
- .hrl-nav--top { display: flex; flex-wrap: wrap; gap: .1rem; align-items: center; }
80
77
  .hrl-tabbar { display: none; }
81
- .hrl-main { padding: 1.25rem; padding-bottom: 2rem; }
78
+ .hrl-appbar--mobile { display: none; }
79
+ .hrl-main { padding: 1.4rem 1.6rem 2rem; }
80
+
81
+ .hrl-side {
82
+ display: flex; flex-direction: column;
83
+ width: 232px; flex: 0 0 232px;
84
+ background: var(--hrl-card); border-right: 1px solid var(--hrl-line);
85
+ position: sticky; top: 0; height: 100vh; overflow-y: auto;
86
+ padding: 1rem .75rem;
87
+ }
88
+ .hrl-side__brand { display: block; padding: .25rem .5rem 1rem; }
89
+ .hrl-side__logo { max-height: 40px; max-width: 100%; display: block; }
90
+ .hrl-side__brandname { font-weight: 800; font-size: 1.1rem; color: var(--hrl-ink); }
91
+ .hrl-side__nav { display: flex; flex-direction: column; gap: 2px; }
92
+ .hrl-side__nav .hrl-nav__link { display: flex; min-height: 38px; padding: .45rem .6rem; }
93
+ .hrl-side__group {
94
+ margin: .9rem .5rem .25rem; font-size: .68rem; font-weight: 700;
95
+ letter-spacing: .08em; text-transform: uppercase; color: var(--hrl-muted);
96
+ }
97
+ .hrl-side__group:first-child { margin-top: 0; }
98
+ .hrl-side__foot {
99
+ margin-top: auto; padding: .75rem .5rem 0; border-top: 1px solid var(--hrl-line);
100
+ display: flex; flex-direction: column; gap: .35rem;
101
+ }
102
+ .hrl-side__user { font-size: .82rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
103
+ .hrl-side__back { font-size: .8rem; }
82
104
  }
83
105
 
84
106
  /* Flash */
@@ -1,16 +1,20 @@
1
1
  module HrLite
2
2
  module Admin
3
- # Employee HR profiles (leadership). No destroy exits are a date;
3
+ # Employee HR profiles (leadership). Onboarding can create the login
4
+ # itself (config.onboard_user); offboarding stamps the exit date and
5
+ # revokes access (config.offboard_user) — nothing is ever deleted,
4
6
  # payroll history is a statutory record.
5
7
  class EmployeesController < LeadershipController
6
8
  def index
7
9
  @profiles = paginate(EmployeeProfile.includes(:user).order(:employee_code))
8
10
  @users_without_profile = HrLite.employees.reject { |u| EmployeeProfile.exists?(user_id: u.id) }
11
+ @pending_resignations = Resignation.pending.includes(:user).recent_first
9
12
  end
10
13
 
11
14
  def show
12
15
  @profile = EmployeeProfile.includes(:user).find(params[:id])
13
16
  @structures = SalaryStructure.where(user_id: @profile.user_id).order(effective_from: :desc)
17
+ @pending_resignation = Resignation.pending.find_by(user_id: @profile.user_id)
14
18
  end
15
19
 
16
20
  def new
@@ -18,12 +22,39 @@ module HrLite
18
22
  end
19
23
 
20
24
  def create
25
+ new_login = params.dig(:employee_profile, :new_user_email).present?
21
26
  @profile = EmployeeProfile.new(profile_params)
27
+
28
+ if new_login
29
+ user = HrLite.config.onboard_user.call(
30
+ name: params.dig(:employee_profile, :new_user_name).to_s,
31
+ email: params.dig(:employee_profile, :new_user_email).to_s.strip,
32
+ password: params.dig(:employee_profile, :new_user_password).to_s
33
+ )
34
+ @profile.user_id = user.id
35
+ end
36
+
22
37
  if @profile.save
23
- redirect_to admin_employee_path(@profile), notice: "Employee profile created."
38
+ if new_login
39
+ Notifications.publish(
40
+ "employee.onboarded",
41
+ title: "Welcome aboard — your HR account is ready",
42
+ body: "Sign in with your email; your manager has your starting password.",
43
+ path: "/",
44
+ bell_to: [ @profile.user ],
45
+ email_to: [ @profile.user ]
46
+ )
47
+ end
48
+ redirect_to admin_employee_path(@profile), notice: "Employee #{'onboarded' if new_login} profile created."
24
49
  else
25
50
  render :new, status: :unprocessable_entity
26
51
  end
52
+ rescue ActiveRecord::RecordInvalid => e
53
+ @profile.errors.add(:base, "Could not create the login: #{e.record.errors.full_messages.to_sentence}")
54
+ render :new, status: :unprocessable_entity
55
+ rescue ActiveRecord::RecordNotUnique
56
+ @profile.errors.add(:base, "Could not create the login: that email already has an account")
57
+ render :new, status: :unprocessable_entity
27
58
  end
28
59
 
29
60
  def edit
@@ -39,6 +70,22 @@ module HrLite
39
70
  end
40
71
  end
41
72
 
73
+ # Exit: stamps the date (attendance/payroll clip on it) and revokes
74
+ # access via the host hook. Audited via the profile update.
75
+ def offboard
76
+ @profile = EmployeeProfile.find(params[:id])
77
+ exit_date = params[:date_of_exit].presence&.to_date || Date.current
78
+
79
+ @profile.update!(date_of_exit: exit_date)
80
+ begin
81
+ HrLite.config.offboard_user.call(@profile.user)
82
+ rescue => e
83
+ Rails.logger.error("[hr_lite] offboard_user failed: #{e.class}: #{e.message}")
84
+ end
85
+ redirect_to admin_employee_path(@profile),
86
+ notice: "Offboarded — last day #{exit_date.strftime('%d %b %Y')}, access revoked."
87
+ end
88
+
42
89
  private
43
90
 
44
91
  def profile_params
@@ -0,0 +1,18 @@
1
+ module HrLite
2
+ module Admin
3
+ class ResignationsController < LeadershipController
4
+ def accept
5
+ resignation = Resignation.find(params[:id])
6
+ resignation.accept!(
7
+ actor: hr_current_user,
8
+ last_day: params[:last_day].presence&.to_date,
9
+ note: params[:note]
10
+ )
11
+ redirect_to admin_employees_path,
12
+ notice: "Resignation accepted — exit date recorded on the profile."
13
+ rescue ActiveRecord::RecordInvalid
14
+ redirect_to admin_employees_path, alert: "Only pending resignations can be accepted."
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,40 @@
1
+ module HrLite
2
+ # Employee self-service resignation: submit, see status, withdraw while
3
+ # pending. Always scoped to hr_current_user.
4
+ class ResignationsController < ApplicationController
5
+ def show
6
+ @resignation = own.recent_first.first
7
+ @new_resignation = Resignation.new(proposed_last_day: Date.current + 30) unless @resignation&.pending?
8
+ end
9
+
10
+ def create
11
+ @resignation = Resignation.new(resignation_params.merge(user_id: hr_current_user.id))
12
+ if @resignation.save
13
+ redirect_to resignation_path, notice: "Resignation submitted — leadership has been notified."
14
+ else
15
+ @new_resignation = @resignation
16
+ render :show, status: :unprocessable_entity
17
+ end
18
+ end
19
+
20
+ def withdraw
21
+ resignation = own.pending.first
22
+ if resignation
23
+ resignation.withdraw!(actor: hr_current_user)
24
+ redirect_to resignation_path, notice: "Resignation withdrawn."
25
+ else
26
+ redirect_to resignation_path, alert: "Nothing pending to withdraw."
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def own
33
+ Resignation.where(user_id: hr_current_user.id)
34
+ end
35
+
36
+ def resignation_params
37
+ params.require(:resignation).permit(:reason, :proposed_last_day)
38
+ end
39
+ end
40
+ end
@@ -80,30 +80,10 @@ module HrLite
80
80
  "#{sign}#{HrLite.config.currency_symbol}#{whole}.#{fraction.ljust(2, '0')}"
81
81
  end
82
82
 
83
- ONES = %w[zero one two three four five six seven eight nine ten eleven twelve thirteen fourteen
84
- fifteen sixteen seventeen eighteen nineteen].freeze
85
- TENS = %w[zero ten twenty thirty forty fifty sixty seventy eighty ninety].freeze
86
-
87
- # Indian-system amount in words for the slip footer.
83
+ # Indian-system amount in words for the slip footer. Delegates to the
84
+ # PORO so host-rendered templates (config.render_pdf) work too.
88
85
  def hrl_amount_in_words(amount)
89
- rupees = HrLite::Money.round_rupee(amount).to_i
90
- return "Zero rupees" if rupees.zero?
91
-
92
- parts = []
93
- [ [ 10_000_000, "crore" ], [ 100_000, "lakh" ], [ 1000, "thousand" ], [ 100, "hundred" ] ].each do |divisor, label|
94
- next unless rupees >= divisor
95
-
96
- parts << "#{hrl_two_digit_words(rupees / divisor)} #{label}"
97
- rupees %= divisor
98
- end
99
- parts << hrl_two_digit_words(rupees) if rupees.positive?
100
- "#{parts.join(' ').capitalize} rupees"
101
- end
102
-
103
- def hrl_two_digit_words(number)
104
- return ONES[number] if number < 20
105
-
106
- [ TENS[number / 10], number % 10 == 0 ? nil : ONES[number % 10] ].compact.join("-")
86
+ HrLite::AmountInWords.words(amount)
107
87
  end
108
88
  end
109
89
  end
@@ -0,0 +1,23 @@
1
+ module HrLite
2
+ # Monthly automation: on the 1st, draft + compute the previous month's
3
+ # payroll from attendance and the policy, then tell leadership it is
4
+ # waiting for review. Publishing stays a deliberate human action —
5
+ # the system prepares, people approve.
6
+ class PayrollAutoDraftJob < ActiveJob::Base
7
+ queue_as :default
8
+
9
+ def perform(month: Date.current.prev_month.beginning_of_month)
10
+ return unless EmployeeProfile.active_for(month).exists?
11
+
12
+ run = PayrollRun.find_or_create_by!(period_month: month)
13
+ return unless run.draft? || run.review?
14
+
15
+ run.compute!(actor: nil)
16
+ Notifications.publish(
17
+ "payroll.draft_ready",
18
+ title: "Payroll #{run.label} computed from attendance — #{run.salary_slips.count} slips await review",
19
+ path: "/admin/payroll_runs/#{run.id}"
20
+ )
21
+ end
22
+ end
23
+ end
@@ -14,6 +14,10 @@ module HrLite
14
14
 
15
15
  TAX_REGIMES = %w[new old].freeze
16
16
 
17
+ # Onboarding-form virtuals (the controller hands them to
18
+ # config.onboard_user; never persisted, never audited).
19
+ attr_accessor :new_user_name, :new_user_email, :new_user_password
20
+
17
21
  validates :employee_code, presence: true, uniqueness: true
18
22
  validates :user_id, uniqueness: true
19
23
  validates :date_of_joining, presence: true
@@ -0,0 +1,84 @@
1
+ module HrLite
2
+ # Employee-initiated exit. One open resignation per person; accepting it
3
+ # stamps the employee profile's exit date (which payroll and attendance
4
+ # already respect) and notifies both sides. Withdrawal is allowed while
5
+ # pending — leaving should never need an email thread.
6
+ class Resignation < ApplicationRecord
7
+ STATUSES = %w[pending accepted withdrawn].freeze
8
+
9
+ belongs_to :user, class_name: HrLite.config.user_class
10
+ belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
11
+
12
+ validates :proposed_last_day, presence: true
13
+ validates :status, inclusion: { in: STATUSES }
14
+ validate :last_day_not_past, on: :create
15
+ validate :single_open_resignation, on: :create
16
+
17
+ scope :pending, -> { where(status: "pending") }
18
+ scope :recent_first, -> { order(created_at: :desc) }
19
+
20
+ STATUSES.each { |s| define_method("#{s}?") { status == s } }
21
+
22
+ after_create :notify_submitted
23
+
24
+ def accept!(actor:, last_day: nil, note: nil)
25
+ raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
26
+
27
+ final_day = last_day.presence || proposed_last_day
28
+ transaction do
29
+ update!(status: "accepted", decided_by_id: actor.id, decided_at: Time.current,
30
+ decision_note: note.presence, proposed_last_day: final_day)
31
+ profile = EmployeeProfile.find_by(user_id: user_id)
32
+ profile&.update!(date_of_exit: final_day)
33
+ end
34
+
35
+ Notifications.publish(
36
+ "resignation.accepted",
37
+ title: "Resignation accepted — last working day #{final_day.strftime('%d %b %Y')}",
38
+ body: note.presence,
39
+ path: "/resignation",
40
+ bell_to: [ user ],
41
+ email_to: [ user ]
42
+ )
43
+ true
44
+ end
45
+
46
+ def withdraw!(actor:)
47
+ raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending? && actor.id == user_id
48
+
49
+ update!(status: "withdrawn", decided_at: Time.current)
50
+ Notifications.publish(
51
+ "resignation.withdrawn",
52
+ title: "#{HrLite.display_name(user)} withdrew their resignation",
53
+ path: "/admin/employees",
54
+ bell_to: HrLite.admin_users
55
+ )
56
+ true
57
+ end
58
+
59
+ private
60
+
61
+ def notify_submitted
62
+ Notifications.publish(
63
+ "resignation.submitted",
64
+ title: "#{HrLite.display_name(user)} submitted their resignation " \
65
+ "(last day proposed: #{proposed_last_day.strftime('%d %b %Y')})",
66
+ body: reason.presence,
67
+ path: "/admin/employees",
68
+ bell_to: HrLite.admin_users
69
+ )
70
+ end
71
+
72
+ def last_day_not_past
73
+ return unless proposed_last_day
74
+
75
+ errors.add(:proposed_last_day, "cannot be in the past") if proposed_last_day < Date.current
76
+ end
77
+
78
+ def single_open_resignation
79
+ if self.class.pending.where(user_id: user_id).exists?
80
+ errors.add(:base, "You already have a pending resignation")
81
+ end
82
+ end
83
+ end
84
+ end
@@ -10,5 +10,16 @@ module HrLite
10
10
  def self.instance
11
11
  first_or_create!
12
12
  end
13
+
14
+ private
15
+
16
+ # The defaults row bootstraps itself on first read — a system action,
17
+ # not a governing change; auditing it would email leadership noise.
18
+ # Real edits (updates) stay fully audited.
19
+ def hr_lite_audit!(action)
20
+ return if action == "create" && HrLite::Current.actor.nil?
21
+
22
+ super
23
+ end
13
24
  end
14
25
  end
@@ -4,9 +4,27 @@
4
4
  <%= f.hidden_field :user_id %>
5
5
  <% if profile.new_record? && profile.user_id.blank? %>
6
6
  <div class="hrl-field">
7
- <%= f.label :user_id, "Staff member" %>
8
- <%= f.select :user_id, HrLite.employees.map { |u| [ HrLite.display_name(u), u.id ] } %>
7
+ <%= f.label :user_id, "Existing staff member" %>
8
+ <%= f.select :user_id, HrLite.employees.map { |u| [ HrLite.display_name(u), u.id ] },
9
+ include_blank: "— onboard a brand-new person below —" %>
9
10
  </div>
11
+ <fieldset class="hrl-card" style="margin-bottom:.8rem;">
12
+ <legend class="hrl-card__title">New login (onboarding)</legend>
13
+ <p class="hrl-hint">Fill these ONLY for someone who has no account yet — their sign-in is
14
+ created with the profile; there is no self sign-up.</p>
15
+ <div class="hrl-field">
16
+ <%= f.label :new_user_name, "Full name" %>
17
+ <%= f.text_field :new_user_name %>
18
+ </div>
19
+ <div class="hrl-field">
20
+ <%= f.label :new_user_email, "Work email (their sign-in)" %>
21
+ <%= f.email_field :new_user_email %>
22
+ </div>
23
+ <div class="hrl-field">
24
+ <%= f.label :new_user_password, "Starting password (share it with them; they can change it later)" %>
25
+ <%= f.text_field :new_user_password %>
26
+ </div>
27
+ </fieldset>
10
28
  <% end %>
11
29
  <div class="hrl-field"><%= f.label :employee_code %><%= f.text_field :employee_code %></div>
12
30
  <div class="hrl-field"><%= f.label :designation %><%= f.text_field :designation %></div>
@@ -28,6 +28,23 @@
28
28
  <% end %>
29
29
  </section>
30
30
 
31
+ <% if @pending_resignations.any? %>
32
+ <section class="hrl-card" style="border-color: var(--hrl-warn);">
33
+ <h2 class="hrl-card__title">Pending resignations</h2>
34
+ <% @pending_resignations.each do |resignation| %>
35
+ <div class="hrl-feed__item">
36
+ <div class="hrl-feed__body">
37
+ <strong><%= hr_display_name(resignation.user) %></strong>
38
+ — proposed last day <%= resignation.proposed_last_day.strftime("%d %b %Y") %>
39
+ <% if resignation.reason.present? %><span class="hrl-small hrl-muted"><%= resignation.reason.truncate(80) %></span><% end %>
40
+ </div>
41
+ <% profile = HrLite::EmployeeProfile.find_by(user_id: resignation.user_id) %>
42
+ <% if profile %><a class="hrl-btn" href="<%= hr_lite.admin_employee_path(profile) %>">Decide</a><% end %>
43
+ </div>
44
+ <% end %>
45
+ </section>
46
+ <% end %>
47
+
31
48
  <% if @users_without_profile.any? %>
32
49
  <section class="hrl-card">
33
50
  <h2 class="hrl-card__title">Staff without an HR profile</h2>
@@ -9,6 +9,48 @@
9
9
  </div>
10
10
  </div>
11
11
 
12
+ <% if @pending_resignation %>
13
+ <section class="hrl-card" style="border-color: var(--hrl-warn);">
14
+ <h2 class="hrl-card__title">Pending resignation</h2>
15
+ <dl class="hrl-deflist">
16
+ <dt>Proposed last day</dt><dd><%= @pending_resignation.proposed_last_day.strftime("%d %B %Y") %></dd>
17
+ <% if @pending_resignation.reason.present? %><dt>Reason</dt><dd><%= @pending_resignation.reason %></dd><% end %>
18
+ </dl>
19
+ <%= form_with url: hr_lite.accept_admin_resignation_path(@pending_resignation), method: :post, local: true do %>
20
+ <div class="hrl-field">
21
+ <label for="res_last_day">Confirm last working day</label>
22
+ <input type="date" id="res_last_day" name="last_day" class="hrl-input"
23
+ value="<%= @pending_resignation.proposed_last_day %>">
24
+ </div>
25
+ <div class="hrl-field">
26
+ <label for="res_note">Note to the employee (optional)</label>
27
+ <input type="text" id="res_note" name="note" class="hrl-input">
28
+ </div>
29
+ <div class="hrl-form-actions">
30
+ <button class="hrl-btn hrl-btn--primary" type="submit">Accept resignation</button>
31
+ </div>
32
+ <% end %>
33
+ </section>
34
+ <% end %>
35
+
36
+ <% if @profile.date_of_exit.nil? %>
37
+ <section class="hrl-card">
38
+ <h2 class="hrl-card__title">Offboard</h2>
39
+ <p class="hrl-small hrl-muted">Stamps the last day (attendance and payroll clip to it) and revokes sign-in.
40
+ Records are never deleted.</p>
41
+ <%= form_with url: hr_lite.offboard_admin_employee_path(@profile), method: :post, local: true,
42
+ html: { data: { turbo_confirm: "Offboard #{hr_display_name(@profile.user)}?" } } do %>
43
+ <div class="hrl-field">
44
+ <label for="off_date">Last working day</label>
45
+ <input type="date" id="off_date" name="date_of_exit" class="hrl-input" value="<%= Date.current %>">
46
+ </div>
47
+ <div class="hrl-form-actions">
48
+ <button class="hrl-btn hrl-btn--danger" type="submit">Offboard employee</button>
49
+ </div>
50
+ <% end %>
51
+ </section>
52
+ <% end %>
53
+
12
54
  <div class="hrl-grid hrl-grid--2">
13
55
  <section class="hrl-card">
14
56
  <h2 class="hrl-card__title">Profile</h2>
@@ -16,6 +16,7 @@
16
16
  <dt>Bank</dt><dd><%= [ @profile.bank_name, @profile.masked_account ].compact.join(" ").presence || "—" %></dd>
17
17
  </dl>
18
18
  <p class="hrl-small hrl-muted hrl-mt">Something wrong? Ask leadership to correct it — changes are audited.</p>
19
+ <a class="hrl-btn hrl-mt" href="<%= hr_lite.resignation_path %>">Resignation</a>
19
20
  </section>
20
21
  <% else %>
21
22
  <p class="hrl-card hrl-card--empty">Your HR profile has not been set up yet.</p>
@@ -0,0 +1,42 @@
1
+ <% content_for(:page_title) { "Resignation" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Resignation</h1>
4
+ </div>
5
+
6
+ <% if @resignation %>
7
+ <section class="hrl-card">
8
+ <dl class="hrl-deflist">
9
+ <dt>Status</dt>
10
+ <dd><span class="hrl-badge <%= { 'accepted' => 'hrl-badge--warn', 'withdrawn' => 'hrl-badge--muted' }[@resignation.status] %>"><%= @resignation.status.humanize %></span></dd>
11
+ <dt>Last working day</dt><dd><%= @resignation.proposed_last_day.strftime("%d %B %Y") %></dd>
12
+ <% if @resignation.reason.present? %><dt>Reason</dt><dd><%= @resignation.reason %></dd><% end %>
13
+ <% if @resignation.decision_note.present? %><dt>Leadership note</dt><dd><%= @resignation.decision_note %></dd><% end %>
14
+ </dl>
15
+ <% if @resignation.pending? %>
16
+ <%= button_to "Withdraw resignation", hr_lite.withdraw_resignation_path, method: :post,
17
+ class: "hrl-btn hrl-btn--danger hrl-mt",
18
+ form: { data: { turbo_confirm: "Withdraw your resignation?" } } %>
19
+ <% end %>
20
+ </section>
21
+ <% end %>
22
+
23
+ <% if @new_resignation %>
24
+ <section class="hrl-card">
25
+ <h2 class="hrl-card__title">Submit resignation</h2>
26
+ <p class="hrl-small hrl-muted">Leadership is notified immediately and will confirm your last working day.</p>
27
+ <%= form_with model: @new_resignation, url: hr_lite.resignation_path, local: true do |f| %>
28
+ <%= render "hr_lite/shared/form_errors", record: @new_resignation %>
29
+ <div class="hrl-field">
30
+ <%= f.label :proposed_last_day, "Proposed last working day" %>
31
+ <%= f.date_field :proposed_last_day %>
32
+ </div>
33
+ <div class="hrl-field">
34
+ <%= f.label :reason, "Reason (optional)" %>
35
+ <%= f.text_area :reason %>
36
+ </div>
37
+ <div class="hrl-form-actions">
38
+ <%= f.submit "Submit resignation", class: "hrl-btn hrl-btn--primary" %>
39
+ </div>
40
+ <% end %>
41
+ </section>
42
+ <% end %>
@@ -2,6 +2,9 @@
2
2
  <div class="pdf-card">
3
3
  <div class="pdf-head">
4
4
  <div>
5
+ <% if company[:logo_url].present? %>
6
+ <img src="<%= company[:logo_url] %>" alt="<%= company[:name] %>" style="height:34px; display:block; margin-bottom:6px;">
7
+ <% end %>
5
8
  <div class="pdf-company"><%= company[:name] %></div>
6
9
  <% if company[:address].present? %><div class="pdf-muted"><%= company[:address] %></div><% end %>
7
10
  </div>
@@ -61,8 +64,10 @@
61
64
  </div>
62
65
 
63
66
  <div class="pdf-net">
67
+ <%# PORO, not a helper — this template is rendered by HOST code via
68
+ config.render_pdf, where engine helper modules are not in scope. %>
64
69
  Net pay: <%= HrLite.config.currency_symbol %><%= @slip.net_pay.to_s("F") %>
65
- (<%= hrl_amount_in_words(@slip.net_pay) %>)
70
+ (<%= HrLite::AmountInWords.words(@slip.net_pay) %>)
66
71
  </div>
67
72
 
68
73
  <p class="pdf-foot">
@@ -1,7 +1,8 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
- <title><%= [ content_for(:page_title), "#{HrLite.config.company.call[:name].presence || 'HR'} — HR" ].compact.join(" · ") %></title>
4
+ <% company = HrLite.config.company.call %>
5
+ <title><%= [ content_for(:page_title), "#{company[:name].presence || 'HR'} — HR" ].compact.join(" · ") %></title>
5
6
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
7
  <%= csrf_meta_tags %>
7
8
  <%= csp_meta_tag %>
@@ -12,37 +13,65 @@
12
13
  <% end %>
13
14
  </head>
14
15
  <body class="hrl-body">
15
- <header class="hrl-appbar">
16
- <div class="hrl-appbar__inner">
17
- <a class="hrl-appbar__brand" href="<%= hr_lite.root_path %>"><%= HrLite.config.company.call[:name].presence || "HR" %></a>
18
- <nav class="hrl-nav hrl-nav--top" aria-label="HR">
16
+ <div class="hrl-shell">
17
+ <%# Keka-style left rail on desktop; slides away on phones (tabbar takes over). %>
18
+ <aside class="hrl-side" aria-label="HR navigation">
19
+ <a class="hrl-side__brand" href="<%= hr_lite.root_path %>">
20
+ <% if company[:logo_url].present? %>
21
+ <img class="hrl-side__logo" src="<%= company[:logo_url] %>" alt="<%= company[:name] %>">
22
+ <% else %>
23
+ <span class="hrl-side__brandname"><%= company[:name].presence || "HR" %></span>
24
+ <% end %>
25
+ </a>
26
+
27
+ <nav class="hrl-side__nav">
28
+ <span class="hrl-side__group">My work</span>
19
29
  <% hrl_nav_items(HrLite::ApplicationHelper::NAV_ITEMS).each do |item| %>
20
30
  <%= hrl_nav_link(item) %>
21
31
  <% end %>
32
+
22
33
  <% if hr_admin? || hr_leadership? %>
23
- <span class="hrl-nav__sep" aria-hidden="true"></span>
34
+ <span class="hrl-side__group">Team</span>
24
35
  <% hrl_nav_items(HrLite::ApplicationHelper::ADMIN_NAV_ITEMS).each do |item| %>
25
36
  <%= hrl_nav_link(item) %>
26
37
  <% end %>
27
38
  <% end %>
39
+
28
40
  <% if hr_leadership? %>
41
+ <span class="hrl-side__group">Organisation</span>
29
42
  <% hrl_nav_items(HrLite::ApplicationHelper::LEADERSHIP_NAV_ITEMS).each do |item| %>
30
43
  <%= hrl_nav_link(item) %>
31
44
  <% end %>
32
45
  <% end %>
46
+ </nav>
47
+
48
+ <div class="hrl-side__foot">
49
+ <span class="hrl-side__user" title="<%= hr_current_user&.email %>"><%= hr_display_name(hr_current_user) %></span>
33
50
  <% if HrLite.config.back_link %>
34
- <a class="hrl-nav__link hrl-nav__link--back" href="<%= HrLite.config.back_link[:url] %>"><%= HrLite.config.back_link[:label] %></a>
51
+ <a class="hrl-side__back" href="<%= HrLite.config.back_link[:url] %>"><%= HrLite.config.back_link[:label] %></a>
35
52
  <% end %>
36
- </nav>
37
- <span class="hrl-appbar__user"><%= hr_display_name(hr_current_user) %></span>
38
- </div>
39
- </header>
53
+ </div>
54
+ </aside>
40
55
 
41
- <main class="hrl-main">
42
- <% if flash[:notice].present? %><div class="hrl-flash hrl-flash--ok" role="status"><%= flash[:notice] %></div><% end %>
43
- <% if flash[:alert].present? %><div class="hrl-flash hrl-flash--warn" role="alert"><%= flash[:alert] %></div><% end %>
44
- <%= yield %>
45
- </main>
56
+ <div class="hrl-content">
57
+ <header class="hrl-appbar hrl-appbar--mobile">
58
+ <a class="hrl-appbar__brand" href="<%= hr_lite.root_path %>">
59
+ <% if company[:logo_url].present? %>
60
+ <img class="hrl-appbar__logo" src="<%= company[:logo_url] %>" alt="<%= company[:name] %>">
61
+ <% else %>
62
+ <%= company[:name].presence || "HR" %>
63
+ <% end %>
64
+ </a>
65
+ <span class="hrl-appbar__user"><%= hr_display_name(hr_current_user) %></span>
66
+ </header>
67
+
68
+ <main class="hrl-main">
69
+ <% if flash[:notice].present? %><div class="hrl-flash hrl-flash--ok" role="status"><%= flash[:notice] %></div><% end %>
70
+ <% if flash[:alert].present? %><div class="hrl-flash hrl-flash--warn" role="alert"><%= flash[:alert] %></div><% end %>
71
+ <%= yield %>
72
+ </main>
73
+ </div>
74
+ </div>
46
75
 
47
76
  <nav class="hrl-tabbar" aria-label="HR sections">
48
77
  <% hrl_nav_items(HrLite::ApplicationHelper::NAV_ITEMS).first(5).each do |item| %>
data/config/routes.rb CHANGED
@@ -17,6 +17,9 @@ HrLite::Engine.routes.draw do
17
17
  get "calendar", to: "calendar#show"
18
18
  resources :salary_slips, only: %i[index show]
19
19
  resource :employee_profile, only: :show, path: "profile"
20
+ resource :resignation, only: %i[show create], controller: "resignations" do
21
+ post :withdraw
22
+ end
20
23
  get "career", to: "career#show"
21
24
  resources :appraisals, only: %i[index show]
22
25
 
@@ -34,7 +37,11 @@ HrLite::Engine.routes.draw do
34
37
  resources :holidays, only: %i[index create update destroy] do
35
38
  collection { post :bulk_create }
36
39
  end
40
+ resources :resignations, only: [] do
41
+ member { post :accept }
42
+ end
37
43
  resources :employees, except: :destroy do
44
+ member { post :offboard }
38
45
  resources :salary_structures, only: %i[new create edit update]
39
46
  resources :appraisals, only: %i[new create edit update destroy] do
40
47
  member { post :share }
@@ -0,0 +1,16 @@
1
+ class CreateHrLiteResignations < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :hr_lite_resignations do |t|
4
+ t.bigint :user_id, null: false
5
+ t.text :reason
6
+ t.date :proposed_last_day, null: false
7
+ t.string :status, null: false, default: "pending"
8
+ t.bigint :decided_by_id
9
+ t.datetime :decided_at
10
+ t.text :decision_note
11
+
12
+ t.timestamps
13
+ end
14
+ add_index :hr_lite_resignations, [ :user_id, :status ]
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ module HrLite
2
+ # Indian-system amount in words (crore/lakh/thousand). A PORO — not a view
3
+ # helper — because the slip PDF template is rendered by HOST code
4
+ # (config.render_pdf), where engine helper modules are not in scope.
5
+ module AmountInWords
6
+ ONES = %w[zero one two three four five six seven eight nine ten eleven twelve thirteen fourteen
7
+ fifteen sixteen seventeen eighteen nineteen].freeze
8
+ TENS = %w[zero ten twenty thirty forty fifty sixty seventy eighty ninety].freeze
9
+ SCALES = [ [ 10_000_000, "crore" ], [ 100_000, "lakh" ], [ 1000, "thousand" ], [ 100, "hundred" ] ].freeze
10
+
11
+ module_function
12
+
13
+ def words(amount)
14
+ rupees = Money.round_rupee(amount).to_i
15
+ return "Zero rupees" if rupees.zero?
16
+
17
+ parts = []
18
+ SCALES.each do |divisor, label|
19
+ next unless rupees >= divisor
20
+
21
+ parts << "#{two_digit(rupees / divisor)} #{label}"
22
+ rupees %= divisor
23
+ end
24
+ parts << two_digit(rupees) if rupees.positive?
25
+ "#{parts.join(' ').capitalize} rupees"
26
+ end
27
+
28
+ def two_digit(number)
29
+ return ONES[number] if number < 20
30
+
31
+ [ TENS[number / 10], number % 10 == 0 ? nil : ONES[number % 10] ].compact.join("-")
32
+ end
33
+ end
34
+ end
@@ -8,7 +8,8 @@ module HrLite
8
8
  :employees_scope, :mentionable_users, :notify, :render_pdf, :company,
9
9
  :time_zone, :currency_symbol, :on_designation_change,
10
10
  :leadership_emails, :leadership_check, :extra_stylesheets,
11
- :mailer_from, :public_url_base, :notification_matrix, :back_link
11
+ :mailer_from, :public_url_base, :notification_matrix, :back_link,
12
+ :onboard_user, :offboard_user
12
13
 
13
14
  # 0.1.0 pre-release name for public_url_base; kept as an alias so early
14
15
  # adopters' initializers don't break.
@@ -40,6 +41,23 @@ module HrLite
40
41
  @public_url_base = nil # e.g. "https://hr.example.com" — enables email links + HrLite.public_url
41
42
  @notification_matrix = nil # resolved lazily to Notifications::DEFAULT_MATRIX
42
43
  @back_link = nil # optional {label:, url:} for the shell nav
44
+
45
+ # Leadership onboarding/offboarding. onboard_user must return a saved
46
+ # user record (default: create on user_class with whatever of
47
+ # name/email/password it supports). offboard_user should revoke the
48
+ # person's access — the engine never deletes anything (statutory
49
+ # records), it only stamps the exit date.
50
+ @onboard_user = ->(name:, email:, password:) {
51
+ klass = HrLite.user_klass
52
+ attributes = { email: email }
53
+ attributes[:name] = name if klass.column_names.include?("name")
54
+ if klass.method_defined?(:password=)
55
+ attributes[:password] = password
56
+ attributes[:password_confirmation] = password if klass.method_defined?(:password_confirmation=)
57
+ end
58
+ klass.create!(attributes)
59
+ }
60
+ @offboard_user = ->(user) { }
43
61
  end
44
62
  end
45
63
 
@@ -24,7 +24,12 @@ module HrLite
24
24
  "appraisal.shared" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
25
25
  "promotion.recorded" => { bell: true, email: true, leadership_email: true, leadership_bell: true },
26
26
  "policy.changed" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
27
- "digest.daily" => { bell: false, email: false, leadership_email: true, leadership_bell: false }
27
+ "digest.daily" => { bell: false, email: false, leadership_email: true, leadership_bell: false },
28
+ "resignation.submitted" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
29
+ "resignation.accepted" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
30
+ "resignation.withdrawn" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
31
+ "employee.onboarded" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
32
+ "payroll.draft_ready" => { bell: false, email: false, leadership_email: true, leadership_bell: true }
28
33
  }.freeze
29
34
 
30
35
  class << self
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/hr_lite.rb CHANGED
@@ -7,6 +7,7 @@ require "hr_lite/notifications"
7
7
  require "hr_lite/seeds"
8
8
  require "hr_lite/geo"
9
9
  require "hr_lite/money"
10
+ require "hr_lite/amount_in_words"
10
11
  require "hr_lite/statutory_rate_card"
11
12
 
12
13
  module HrLite
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.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -68,6 +68,7 @@ files:
68
68
  - app/controllers/hr_lite/admin/office_locations_controller.rb
69
69
  - app/controllers/hr_lite/admin/overview_controller.rb
70
70
  - app/controllers/hr_lite/admin/payroll_runs_controller.rb
71
+ - app/controllers/hr_lite/admin/resignations_controller.rb
71
72
  - app/controllers/hr_lite/admin/salary_slips_controller.rb
72
73
  - app/controllers/hr_lite/admin/salary_structures_controller.rb
73
74
  - app/controllers/hr_lite/admin/settings_controller.rb
@@ -82,12 +83,14 @@ files:
82
83
  - app/controllers/hr_lite/kudos_controller.rb
83
84
  - app/controllers/hr_lite/leave_balances_controller.rb
84
85
  - app/controllers/hr_lite/leave_requests_controller.rb
86
+ - app/controllers/hr_lite/resignations_controller.rb
85
87
  - app/controllers/hr_lite/salary_slips_controller.rb
86
88
  - app/controllers/hr_lite/users_controller.rb
87
89
  - app/helpers/hr_lite/application_helper.rb
88
90
  - app/jobs/hr_lite/application_job.rb
89
91
  - app/jobs/hr_lite/daily_digest_job.rb
90
92
  - app/jobs/hr_lite/leave_year_rollover_job.rb
93
+ - app/jobs/hr_lite/payroll_auto_draft_job.rb
91
94
  - app/mailers/hr_lite/application_mailer.rb
92
95
  - app/mailers/hr_lite/event_mailer.rb
93
96
  - app/models/concerns/hr_lite/audited.rb
@@ -106,6 +109,7 @@ files:
106
109
  - app/models/hr_lite/leave_type.rb
107
110
  - app/models/hr_lite/office_location.rb
108
111
  - app/models/hr_lite/payroll_run.rb
112
+ - app/models/hr_lite/resignation.rb
109
113
  - app/models/hr_lite/salary_slip.rb
110
114
  - app/models/hr_lite/salary_structure.rb
111
115
  - app/models/hr_lite/setting.rb
@@ -178,6 +182,7 @@ files:
178
182
  - app/views/hr_lite/leave_requests/index.html.erb
179
183
  - app/views/hr_lite/leave_requests/new.html.erb
180
184
  - app/views/hr_lite/leave_requests/show.html.erb
185
+ - app/views/hr_lite/resignations/show.html.erb
181
186
  - app/views/hr_lite/salary_slips/_slip_detail.html.erb
182
187
  - app/views/hr_lite/salary_slips/index.html.erb
183
188
  - app/views/hr_lite/salary_slips/pdf.html.erb
@@ -205,10 +210,12 @@ files:
205
210
  - db/migrate/20260718194537_create_hr_lite_salary_slips.rb
206
211
  - db/migrate/20260718195953_create_hr_lite_appraisals.rb
207
212
  - db/migrate/20260718195954_create_hr_lite_designation_changes.rb
213
+ - db/migrate/20260719065841_create_hr_lite_resignations.rb
208
214
  - lib/generators/hr_lite/install/install_generator.rb
209
215
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
210
216
  - lib/generators/hr_lite/install/templates/initializer.rb
211
217
  - lib/hr_lite.rb
218
+ - lib/hr_lite/amount_in_words.rb
212
219
  - lib/hr_lite/configuration.rb
213
220
  - lib/hr_lite/current.rb
214
221
  - lib/hr_lite/engine.rb