hr_lite 0.1.2 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38321c0b3f943e26ccdfbae2109b466196174ec5e1a60c0efcbb0b751e85b9fa
4
- data.tar.gz: ac91265eeaddf77c7b9972467b49682eac102fef136a58b2033d769ba3f7d150
3
+ metadata.gz: 5b975162e55e723ed0bd24a9e032eba889d18e89c106ce55fc051a8566ce41e0
4
+ data.tar.gz: 2a0bd3e239011f037e300f26f3e8d18e3fb9a4fe7ca17b1317e70d3318a93844
5
5
  SHA512:
6
- metadata.gz: 191a72380266416ce6d4ab981f8046369261de8d4208e96334fb2236c66854d08c86ceed9e114074b35ae59e83aada5aeb8be86dfe4cce22a977e7d59971dc43
7
- data.tar.gz: 575a1d03fc7ecf31dbda85ea7776796992cfde111725e2a94d84835ef38d96f91bb34dec4a8a1e565110ad27e27381bec08fa755eed36fa7c18329593ae21702
6
+ metadata.gz: 2405b7818d6a8028375fb614ec0d187faf01088996e8d0754b3e7882ebc4adb2846cd4bf026de0aba86504c63264a8cdbbaaad740b610ce12661718581b45e04
7
+ data.tar.gz: 632fc1794614fa739375e0fe1a4c8b55195f9a51e7b4d5d74f65eadafc157f14b8e391eb7846c84a299634f1fd1e6f5abe64e5b18c253fa0ad276ce495da2c43
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.1] - 2026-07-19
11
+
12
+ ### Added
13
+
14
+ - Email-invite onboarding: leave the starting password blank and the
15
+ welcome email carries a set-your-password link from the host's
16
+ `invite_url_for` hook; `Notifications.publish`/`EventMailer.event`
17
+ accept an absolute `link_url` for tokenized URLs. (Intended for 0.2.0;
18
+ missed the merge window.)
19
+
20
+ ## [0.2.0] - 2026-07-19
21
+
22
+ ### Added
23
+
24
+ - Resignations: employees submit/withdraw from the portal; leadership
25
+ accepts with a confirmed last working day that stamps the profile's
26
+ exit date (payroll/attendance clip to it automatically).
27
+ - Onboarding: leadership creates the sign-in with the profile via the
28
+ `onboard_user` hook (no self sign-up anywhere); offboarding stamps the
29
+ exit date and revokes access via `offboard_user` — records are never
30
+ deleted.
31
+ - `PayrollAutoDraftJob`: monthly automation that drafts + computes the
32
+ previous month's payroll from attendance and notifies leadership for
33
+ review; publishing stays human.
34
+ - Company logo (`company[:logo_url]`, data-URIs welcome) in the shell
35
+ and on salary-slip PDFs.
36
+ - Keka-style left-rail navigation on desktop with grouped sections
37
+ (My work / Team / Organisation); mobile keeps the bottom tab bar.
38
+ - New notification-matrix events: `resignation.*`, `employee.onboarded`,
39
+ `payroll.draft_ready`.
40
+
41
+ ### Fixed
42
+
43
+ - The auto-created settings row no longer emails leadership
44
+ ("Someone created Setting" noise); real settings edits stay audited.
45
+
10
46
  ## [0.1.2] - 2026-07-19
11
47
 
12
48
  ### Fixed
@@ -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,42 @@ 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
+ invite_url = HrLite.config.invite_url_for&.call(@profile.user)
40
+ Notifications.publish(
41
+ "employee.onboarded",
42
+ title: "Welcome aboard — your HR account is ready",
43
+ body: invite_url ? "Set your password with the button below, then sign in with your email."
44
+ : "Sign in with your email; your manager has your starting password.",
45
+ path: "/",
46
+ link_url: invite_url,
47
+ bell_to: [ @profile.user ],
48
+ email_to: [ @profile.user ]
49
+ )
50
+ end
51
+ redirect_to admin_employee_path(@profile), notice: "Employee #{'onboarded' if new_login} profile created."
24
52
  else
25
53
  render :new, status: :unprocessable_entity
26
54
  end
55
+ rescue ActiveRecord::RecordInvalid => e
56
+ @profile.errors.add(:base, "Could not create the login: #{e.record.errors.full_messages.to_sentence}")
57
+ render :new, status: :unprocessable_entity
58
+ rescue ActiveRecord::RecordNotUnique
59
+ @profile.errors.add(:base, "Could not create the login: that email already has an account")
60
+ render :new, status: :unprocessable_entity
27
61
  end
28
62
 
29
63
  def edit
@@ -39,6 +73,22 @@ module HrLite
39
73
  end
40
74
  end
41
75
 
76
+ # Exit: stamps the date (attendance/payroll clip on it) and revokes
77
+ # access via the host hook. Audited via the profile update.
78
+ def offboard
79
+ @profile = EmployeeProfile.find(params[:id])
80
+ exit_date = params[:date_of_exit].presence&.to_date || Date.current
81
+
82
+ @profile.update!(date_of_exit: exit_date)
83
+ begin
84
+ HrLite.config.offboard_user.call(@profile.user)
85
+ rescue => e
86
+ Rails.logger.error("[hr_lite] offboard_user failed: #{e.class}: #{e.message}")
87
+ end
88
+ redirect_to admin_employee_path(@profile),
89
+ notice: "Offboarded — last day #{exit_date.strftime('%d %b %Y')}, access revoked."
90
+ end
91
+
42
92
  private
43
93
 
44
94
  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
@@ -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
@@ -3,11 +3,11 @@ module HrLite
3
3
  # body, detail lines, diff table) is assembled by the Notifications bus so
4
4
  # adding an event never means adding a template.
5
5
  class EventMailer < ApplicationMailer
6
- def event(to:, subject:, heading:, body: nil, lines: [], path: nil)
6
+ def event(to:, subject:, heading:, body: nil, lines: [], path: nil, link_url: nil)
7
7
  @heading = heading
8
8
  @body = body
9
9
  @lines = Array(lines)
10
- @cta_url = HrLite::EventMailer.link_for(path)
10
+ @cta_url = link_url.presence || HrLite::EventMailer.link_for(path)
11
11
  mail(to: to, from: HrLite.config.mailer_from, subject: subject)
12
12
  end
13
13
 
@@ -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,29 @@
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 (optional)" %>
25
+ <%= f.text_field :new_user_password %>
26
+ <p class="hrl-hint">Leave blank (recommended): they get an email invite and set their own
27
+ password. Fill it only if you want to hand a password over yourself.</p>
28
+ </div>
29
+ </fieldset>
10
30
  <% end %>
11
31
  <div class="hrl-field"><%= f.label :employee_code %><%= f.text_field :employee_code %></div>
12
32
  <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>
@@ -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
@@ -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, :invite_url_for
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,27 @@ 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) { }
61
+ # Optional: return an absolute set-your-password URL for a freshly
62
+ # onboarded user (e.g. a Devise reset link). When present, the welcome
63
+ # email carries it and leadership never needs to hand over a password.
64
+ @invite_url_for = nil
43
65
  end
44
66
  end
45
67
 
@@ -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
@@ -32,7 +37,7 @@ module HrLite
32
37
  HrLite.config.notification_matrix || DEFAULT_MATRIX
33
38
  end
34
39
 
35
- def publish(event, title:, body: nil, path: nil, bell_to: [], email_to: [], lines: [], diff: nil)
40
+ def publish(event, title:, body: nil, path: nil, bell_to: [], email_to: [], lines: [], diff: nil, link_url: nil)
36
41
  row = matrix[event.to_s]
37
42
  unless row
38
43
  Rails.logger.warn("[hr_lite] unknown notification event #{event}")
@@ -40,7 +45,7 @@ module HrLite
40
45
  end
41
46
 
42
47
  deliver_bells(event, row, bell_to, title, body, path)
43
- deliver_emails(row, email_to, title, body, path, lines)
48
+ deliver_emails(row, email_to, title, body, path, lines, link_url)
44
49
  deliver_leadership_email(event, row, title, body, path, lines, diff)
45
50
  deliver_leadership_bells(event, row, bell_to, title, body, path)
46
51
  nil
@@ -56,14 +61,15 @@ module HrLite
56
61
  end
57
62
  end
58
63
 
59
- def deliver_emails(row, email_to, title, body, path, lines)
64
+ def deliver_emails(row, email_to, title, body, path, lines, link_url = nil)
60
65
  return unless row[:email]
61
66
 
62
67
  Array(email_to).compact.uniq.each do |user|
63
68
  next if user.email.blank?
64
69
 
65
70
  EventMailer.event(to: user.email, subject: title, heading: title,
66
- body: body, lines: lines, path: path).deliver_later
71
+ body: body, lines: lines, path: path,
72
+ link_url: link_url).deliver_later
67
73
  end
68
74
  rescue => e
69
75
  Rails.logger.error("[hr_lite] event email failed: #{e.class}: #{e.message}")
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.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.1.2
4
+ version: 0.2.1
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,6 +210,7 @@ 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