# How to Build a HR Management System with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher (Business plan recommended for credit volume)
- Last updated: April 2026

## TL;DR

Build a comprehensive HR management system with Lovable and Supabase — covering employee directory, leave approvals, attendance tracking, and document storage. The multi-module layout uses Tabs, Supabase RLS for employee/manager/HR hierarchy, and Realtime notifications for the leave approval workflow. Expect 3–4 hours for a production-ready system.

## Before you start

- Lovable Business plan recommended due to the volume of steps and credit usage in advanced builds
- Supabase project with Storage enabled (free tier works for development)
- Supabase URL, anon key, and service role key ready for Cloud tab → Secrets
- Org structure ready: list of departments and position titles
- At least one test employee and one test manager email address for testing the approval workflow
- Familiarity with Supabase RLS concepts (policies, auth.uid(), joins)

## Step-by-step guide

### 1. Generate the full HR schema with RLS hierarchy

This is the most complex schema prompt in this guide. Take time to review the generated SQL before proceeding. The RLS policies for the manager tier require a subquery joining employees to departments — ask Lovable to generate and validate these policies explicitly.

```
Create an HR management system with Supabase. Set up these tables:

- departments: id, org_id, name, manager_id (references employees.id, nullable), created_at
- positions: id, org_id, department_id, title, level (junior|mid|senior|lead|manager|director|executive), created_at
- employees: id, org_id, user_id (references auth.users, nullable), first_name, last_name, email, phone, department_id, position_id, manager_id (self-referencing FK to employees.id), hire_date, employment_type (full_time|part_time|contractor), status (active|on_leave|terminated), avatar_url, created_at
- leave_requests: id, employee_id, type (annual|sick|maternity|paternity|unpaid|other), start_date, end_date, days_count (computed), reason, status (pending|approved|rejected|cancelled), reviewed_by (references employees.id), review_note, created_at, updated_at
- attendance: id, employee_id, date, check_in_at, check_out_at, work_hours (computed), status (present|absent|late|half_day), notes, created_at
- employee_documents: id, employee_id, name, type (contract|id|certificate|other), storage_path, uploaded_by, created_at

RLS policies:
- Employee tier: employees.SELECT WHERE user_id = auth.uid(), leave_requests.SELECT WHERE employee_id = (SELECT id FROM employees WHERE user_id = auth.uid()), attendance.SELECT WHERE employee_id = same
- Manager tier: employees.SELECT WHERE department_id IN (SELECT department_id FROM employees WHERE user_id = auth.uid()), same scope for leave_requests and attendance
- HR tier: full SELECT/INSERT/UPDATE/DELETE on all tables WHERE org_id matches
- Implement role detection via a profiles table: id, user_id, org_id, hr_role (employee|manager|hr_admin)
```

> Pro tip: Use Lovable's Plan Mode first for this step. The schema is complex enough that having Lovable reason through the RLS logic before generating code reduces the chance of policy errors.

**Expected result:** All six tables plus departments, positions, and profiles are created. TypeScript types are generated. RLS is enabled on all tables. The preview shows a basic app shell.

### 2. Build the main Tabs layout and employee directory

Ask Lovable to create the top-level Tabs navigation and the Directory tab first. The employee directory is the most-used module and makes a good visual foundation for the rest of the app.

```
Build the main HR app layout with a Tabs navigation at src/pages/HR.tsx.

Tabs structure:
- Directory: employee directory
- Leave: leave requests management
- Attendance: attendance tracking
- Documents: document storage
- Analytics: charts and reports

For the Directory tab:
- Fetch employees with joins on departments and positions
- Render as a shadcn/ui DataTable (TanStack Table v8)
- Columns: Avatar + full name (clickable), position title, department name, employment type Badge (full_time=blue, part_time=yellow, contractor=gray), status Badge (active=green, on_leave=orange, terminated=red), hire date, manager name
- Add a department filter Select above the table
- Add a search Input that filters by name or email
- Clicking a row opens an employee profile Sheet showing all details, their manager, and quick links to their leave history and documents
- HR admins see an Edit button in the Sheet; employees see read-only view of their own profile
```

> Pro tip: Ask Lovable to use a nested select in the Supabase query: employees.select('*, department:departments(name), position:positions(title), manager:employees!manager_id(first_name, last_name)') to get all related names in a single query.

**Expected result:** The HR app shows a Tabs layout. The Directory tab renders a filterable employee table with Avatars, Badges, and working search. Clicking a row opens the profile Sheet.

### 3. Build the leave request workflow with Realtime notifications

The leave module is the most complex feature: employees submit requests, managers see pending requests in their queue, and approval or rejection triggers a notification in the requester's browser. Build this step by step.

```
Build the Leave tab module. It should show different UIs based on the user's hr_role:

For employees (hr_role = 'employee'):
- Show their own leave history as a DataTable: type Badge, dates, days count, status Badge (pending=yellow, approved=green, rejected=red, cancelled=gray), review note if rejected
- 'Request Leave' Button that opens a Dialog
- Dialog form (react-hook-form + zod): leave type Select, start date Calendar Popover, end date Calendar Popover (auto-calculates days_count excluding weekends), reason Textarea
- On submit, insert into leave_requests with status = 'pending'
- Subscribe to Realtime on leave_requests WHERE id = request.id — when status changes to approved or rejected, show a Sonner toast: 'Your leave request was approved' or 'Your leave request was rejected: [review_note]'

For managers (hr_role = 'manager'):
- Show a DataTable of pending leave requests from their team
- Each row has Approve Button (green) and Reject Button (red)
- Reject opens a small Dialog to enter a review_note
- On approve/reject, update leave_requests with status and reviewed_by
- Show a Badge count on the Leave tab label showing total pending requests

For HR admins: show all requests with ability to filter by department and export as CSV
```

> Pro tip: Ask Lovable to add a leave balance summary card above the employee's request history showing total annual leave days, used days, and remaining days — calculated from approved leave requests for the current year.

**Expected result:** Employees can submit leave requests. Managers see pending requests with approve/reject actions. The requesting employee's browser shows a toast when the status changes.

### 4. Build the attendance tracking module

Ask Lovable to build the attendance tab with check-in/check-out functionality and a monthly calendar view. The attendance data feeds the Analytics charts built in the next step.

```
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/integrations/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Calendar } from '@/components/ui/calendar'
import { format, isToday, parseISO } from 'date-fns'

type AttendanceRecord = {
  id: string
  date: string
  check_in_at: string | null
  check_out_at: string | null
  work_hours: number | null
  status: 'present' | 'absent' | 'late' | 'half_day'
}

export function AttendanceTab({ employeeId }: { employeeId: string }) {
  const [month, setMonth] = useState(new Date())
  const queryClient = useQueryClient()

  const { data: records = [] } = useQuery({
    queryKey: ['attendance', employeeId, format(month, 'yyyy-MM')],
    queryFn: async () => {
      const start = format(new Date(month.getFullYear(), month.getMonth(), 1), 'yyyy-MM-dd')
      const end = format(new Date(month.getFullYear(), month.getMonth() + 1, 0), 'yyyy-MM-dd')
      const { data } = await supabase
        .from('attendance')
        .select('*')
        .eq('employee_id', employeeId)
        .gte('date', start)
        .lte('date', end)
        .order('date', { ascending: false })
      return (data ?? []) as AttendanceRecord[]
    },
  })

  const todayRecord = records.find((r) => isToday(parseISO(r.date)))

  const checkIn = useMutation({
    mutationFn: async () => {
      const now = new Date().toISOString()
      const hour = new Date().getHours()
      const status = hour > 9 ? 'late' : 'present'
      await supabase.from('attendance').upsert({
        employee_id: employeeId,
        date: format(new Date(), 'yyyy-MM-dd'),
        check_in_at: now,
        status,
      })
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['attendance'] }),
  })

  const checkOut = useMutation({
    mutationFn: async () => {
      await supabase
        .from('attendance')
        .update({ check_out_at: new Date().toISOString() })
        .eq('id', todayRecord!.id)
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['attendance'] }),
  })

  const presentDates = records.filter((r) => r.status === 'present' || r.status === 'late').map((r) => parseISO(r.date))

  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
      <Card>
        <CardHeader><CardTitle className="text-base">Today</CardTitle></CardHeader>
        <CardContent className="space-y-3">
          {todayRecord?.check_in_at ? (
            <>
              <p className="text-sm">Checked in: {format(parseISO(todayRecord.check_in_at), 'h:mm a')}</p>
              {todayRecord.check_out_at
                ? <p className="text-sm">Checked out: {format(parseISO(todayRecord.check_out_at), 'h:mm a')}</p>
                : <Button onClick={() => checkOut.mutate()} variant="outline" className="w-full">Check Out</Button>
              }
            </>
          ) : (
            <Button onClick={() => checkIn.mutate()} className="w-full">Check In</Button>
          )}
          {todayRecord && <Badge variant="outline">{todayRecord.status}</Badge>}
        </CardContent>
      </Card>
      <Card className="md:col-span-2">
        <CardHeader><CardTitle className="text-base">Monthly View</CardTitle></CardHeader>
        <CardContent>
          <Calendar mode="multiple" selected={presentDates} month={month} onMonthChange={setMonth} className="rounded-md" />
        </CardContent>
      </Card>
    </div>
  )
}
```

**Expected result:** Employees can check in and check out from the Attendance tab. The calendar shows days worked highlighted. Late arrivals are automatically tagged.

### 5. Build secure document storage and the analytics dashboard

Ask Lovable to set up Supabase Storage for employee documents with per-employee access control, and build the Analytics tab with Recharts charts showing headcount and leave trends.

```
Add two final modules:

1. Documents tab:
- Create a Supabase Storage bucket called 'employee-docs' set to PRIVATE
- Storage path pattern: employee-docs/{employee_id}/{filename}
- Storage RLS: employees can upload/download only paths starting with their employee_id. HR admins can access all paths.
- Build a document list component showing files per employee: name, type Badge, upload date, and a Download button
- Download button calls supabase.storage.from('employee-docs').createSignedUrl(path, 3600) and opens the URL in a new tab
- Add a file upload Input (accept pdf, jpg, png, docx) that uploads to the correct path and inserts a row into employee_documents
- HR admins see a Select to choose which employee's documents they are viewing

2. Analytics tab (HR admins only):
- Headcount by department: fetch COUNT(*) from employees grouped by department_id, joined to departments.name. Render as a Recharts BarChart.
- Leave utilization: fetch approved leave_requests, group by type, sum days_count. Render as a Recharts PieChart.
- Attendance rate this month: (present + late count) / total working days. Show as a big percentage number with a Progress bar.
- Recent hires: DataTable of last 10 employees ordered by hire_date desc.
```

> Pro tip: For the storage RLS, ask Lovable to create a storage policy in Supabase using the dashboard path: Storage → Policies → New Policy. The condition is: (storage.foldername(name))[1] = (SELECT id::text FROM employees WHERE user_id = auth.uid()).

**Expected result:** Employees can upload and download their own documents. HR admins see documents for any employee. The Analytics tab renders department headcount and leave type charts.

## Complete code example

File: `src/components/hr/LeaveRequestDialog.tsx`

```typescript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { addDays, differenceInBusinessDays, format } from 'date-fns'
import { supabase } from '@/integrations/supabase/client'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Calendar } from '@/components/ui/calendar'
import { CalendarIcon } from 'lucide-react'
import { toast } from 'sonner'

const TYPES = ['annual','sick','maternity','paternity','unpaid','other'] as const
const schema = z.object({
  type: z.enum(TYPES), reason: z.string().min(10),
  start_date: z.date({ required_error: 'Start date required' }),
  end_date: z.date({ required_error: 'End date required' }),
}).refine(d => d.end_date >= d.start_date, { message: 'End after start', path: ['end_date'] })

type F = z.infer<typeof schema>
type Props = { open: boolean; onClose: () => void; employeeId: string }

export function LeaveRequestDialog({ open, onClose, employeeId }: Props) {
  const form = useForm<F>({ resolver: zodResolver(schema), defaultValues: { type: 'annual', reason: '' } })
  const [s, e] = [form.watch('start_date'), form.watch('end_date')]
  const days = s && e ? differenceInBusinessDays(addDays(e, 1), s) : 0

  async function onSubmit(v: F) {
    const { error } = await supabase.from('leave_requests').insert({
      employee_id: employeeId, type: v.type, reason: v.reason, days_count: days, status: 'pending',
      start_date: format(v.start_date, 'yyyy-MM-dd'), end_date: format(v.end_date, 'yyyy-MM-dd'),
    })
    if (error) { toast.error('Failed to submit'); return }
    toast.success('Leave request submitted'); form.reset(); onClose()
  }

  const DatePicker = (name: 'start_date' | 'end_date', label: string) => (
    <FormField control={form.control} name={name} render={({ field }) => (
      <FormItem><FormLabel>{label}</FormLabel>
        <Popover><PopoverTrigger asChild><FormControl>
          <Button variant="outline" className="w-full justify-start">
            <CalendarIcon className="mr-2 h-4 w-4" />{field.value ? format(field.value, 'MMM d') : 'Pick date'}
          </Button>
        </FormControl></PopoverTrigger>
          <PopoverContent className="w-auto p-0">
            <Calendar mode="single" selected={field.value} onSelect={field.onChange}
              disabled={d => d < (name === 'end_date' ? s ?? new Date() : new Date())} />
          </PopoverContent>
        </Popover><FormMessage /></FormItem>
    )} />
  )

  return (
    <Dialog open={open} onOpenChange={onClose}>
      <DialogContent className="max-w-md">
        <DialogHeader><DialogTitle>Request Leave</DialogTitle></DialogHeader>
        <Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
          <FormField control={form.control} name="type" render={({ field }) => (
            <FormItem><FormLabel>Leave Type</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
                <SelectContent>{TYPES.map(t => <SelectItem key={t} value={t}>{t[0].toUpperCase()+t.slice(1)}</SelectItem>)}</SelectContent>
              </Select><FormMessage /></FormItem>
          )} />
          <div className="grid grid-cols-2 gap-3">{DatePicker('start_date','Start')}{DatePicker('end_date','End')}</div>
          {days > 0 && <p className="text-sm text-muted-foreground">{days} business day{days !== 1 ? 's' : ''}</p>}
          <FormField control={form.control} name="reason" render={({ field }) => (
            <FormItem><FormLabel>Reason</FormLabel>
              <FormControl><Textarea placeholder="Brief reason..." {...field} /></FormControl>
              <FormMessage /></FormItem>
          )} />
          <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>Submit Request</Button>
        </form></Form>
      </DialogContent>
    </Dialog>
  )
}
```

## Common mistakes

- **Using the same RLS policy for managers and HR without distinguishing access scope** — If the manager policy allows SELECT on all employees without filtering by department, a manager can query any employee in the organization. Fix: The manager SELECT policy on employees must include: department_id IN (SELECT department_id FROM employees WHERE user_id = auth.uid()). Test by logging in as a manager and trying to query an employee in a different department — it should return zero rows.
- **Storing private documents in a public Supabase Storage bucket** — Files in a public bucket are accessible to anyone who knows the URL, including employment contracts and ID copies. Fix: Create the storage bucket as PRIVATE. Use supabase.storage.createSignedUrl(path, expirySeconds) to generate time-limited download URLs. Set expiry to 3600 seconds (1 hour) maximum for sensitive documents.
- **Not adding cleanup to the leave request Realtime subscription** — When the employee navigates away from the Leave tab, the Realtime subscription keeps running. If they return, a duplicate subscription forms. Fix: Return a cleanup from useEffect: return () => { supabase.removeChannel(channel) }. Scope the subscription to the specific leave request ID using .filter('id=eq.' + requestId) to avoid receiving events for other employees.
- **Calculating work_hours client-side instead of in the database** — Client-side calculations can be manipulated and create inconsistencies if multiple clients interact with the same record. Fix: Add a PostgreSQL generated column: work_hours NUMERIC GENERATED ALWAYS AS (EXTRACT(EPOCH FROM (check_out_at - check_in_at)) / 3600) STORED. This calculation is automatic and cannot be overridden from the client.
- **Building all five modules at once in a single Lovable prompt** — Very long prompts describing multiple complex features often result in Lovable generating incomplete or conflicting code. The AI may drop requirements from earlier in the prompt. Fix: Build one module per prompt, verify it works in the preview, then move to the next. Use Lovable's Plan Mode to review the approach for complex modules like the leave workflow before generating code.

## Best practices

- Design the RLS hierarchy before writing any application code. Draw the access matrix on paper: rows = tables, columns = roles. Fill in which operations each role can perform. Then translate each cell to a Supabase policy.
- Use Supabase's generate column feature for computed fields like work_hours and days_count instead of calculating in application code. Generated columns are always consistent and reduce the risk of bugs.
- Limit Realtime subscriptions to specific rows using filters. For the leave approval workflow, each manager subscribes to leave_requests WHERE department_id IN (...) rather than the entire table. This reduces unnecessary traffic.
- Store document metadata (name, type, path, uploader) in the employee_documents table and the actual file in Supabase Storage. Never store files as base64 in PostgreSQL — it destroys query performance.
- Build a separate admin-only seed page that lets HR managers import employees from a CSV. This is far more practical than entering 50+ employees manually via form. Ask Lovable to add a Papa Parse-powered CSV importer.
- Add soft-delete (status = 'terminated' + terminated_at timestamp) instead of DELETE for employees. Employment records have legal retention requirements in many jurisdictions.
- Test the three-tier RLS by creating three Supabase Auth users with different roles and running queries in the Supabase SQL editor as each user using SET LOCAL role = ... to simulate the auth context.

## Frequently asked questions

### How do I import existing employee data from a spreadsheet?

Ask Lovable to add a CSV import feature to the Directory tab. It will generate a file input, Papa Parse integration to read the CSV, a column-mapping step, a preview DataTable, and a batch insert into the employees table. For large imports (500+ employees), ask Lovable to use a Supabase Edge Function for the insert so the browser doesn't time out.

### Can I use this system with employees in multiple countries?

Yes. Add a country column to employees and a public_holidays table with (country, date, name). Update the leave days_count calculation to exclude country-specific public holidays in addition to weekends. The Supabase RPC function for calculating business days between two dates can take country as a parameter.

### How do I make the leave approval notification appear even if the manager has the app closed?

Supabase Realtime only delivers to connected browsers. For push notifications when the app is closed, add a Supabase Database Webhook on leave_requests INSERT that triggers an Edge Function. The function sends an email via Resend to the manager's email address. Store the Resend API key in Cloud tab → Secrets.

### Is this secure enough for real employee data?

With correctly configured RLS, Supabase provides strong row-level isolation. For production HR systems, also: enable Supabase's Point-in-Time Recovery (Pro plan), set up Storage private buckets for documents, enable MFA for HR admin accounts via Supabase Auth, and add audit logging (a trigger that records every UPDATE on sensitive tables). Do not store sensitive fields like salary in plain text — encrypt them at the application layer.

### Can I test the three-tier access control in Lovable?

Create three separate user accounts in your Supabase Auth dashboard with emails like employee@test.com, manager@test.com, and hradmin@test.com. Set their hr_role in the profiles table accordingly. Log in to your Lovable app as each user in separate browser tabs (or incognito windows) and verify that each role sees only the data they should access.

### How do I add a payroll export feature?

Ask Lovable to add a Payroll Export button in the Analytics tab (visible to HR admins only). The button calls a Supabase RPC function that aggregates attendance hours, leave days, and employee details for a selected pay period. The function returns a JSON array which Lovable converts to CSV using PapaParse and triggers a browser download with the correct Content-Disposition header.

### Is there a team that can help build a custom HR system in Lovable?

RapidDev builds production Lovable apps including HR systems with custom approval workflows, payroll integrations, and compliance-grade access control. Get in touch if your requirements go beyond this guide.

### How do I deploy the HR system so my team can access it?

Click the Publish icon in the top-right corner of Lovable to deploy to a public URL. For a custom company domain (e.g. hr.yourcompany.com), go to Publish → Settings → Custom Domain on a paid Lovable plan. Make sure Supabase RLS is configured before sharing the URL — without it, all employee data is visible to every authenticated user.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/hr-management-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/hr-management-system
