BOC v1.0: RS256 auth, Ledger integration, Prometheus metrics, CI/CD, backup
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
} from '@/components/ui/Table'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { hrApi } from '@/lib/api'
|
||||
import {
|
||||
Users,
|
||||
Calendar,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Employee {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
department: string
|
||||
role: string
|
||||
status: string
|
||||
startDate: string
|
||||
}
|
||||
|
||||
interface Leave {
|
||||
id: string
|
||||
employeeId: string
|
||||
employeeName: string
|
||||
type: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
status: string
|
||||
days: number
|
||||
}
|
||||
|
||||
const tabs = ['Employees', 'Leaves']
|
||||
|
||||
const departmentColors: Record<string, string> = {
|
||||
Sales: 'primary',
|
||||
Engineering: 'success',
|
||||
Finance: 'warning',
|
||||
HR: 'primary',
|
||||
Legal: 'default',
|
||||
}
|
||||
|
||||
export function HRPage() {
|
||||
const [activeTab, setActiveTab] = useState('Employees')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [employees, setEmployees] = useState<Employee[]>([])
|
||||
const [leaves, setLeaves] = useState<Leave[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [employeesRes, leavesRes] = await Promise.all([
|
||||
hrApi.employees(),
|
||||
hrApi.leaves(),
|
||||
])
|
||||
setEmployees((employeesRes as { employees: Employee[] }).employees || [])
|
||||
setLeaves((leavesRes as { leaves: Leave[] }).leaves || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load HR data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const activeEmployees = employees.filter((e) => e.status === 'active').length
|
||||
const onLeave = employees.filter((e) => e.status === 'on_leave').length
|
||||
const pendingLeaves = leaves.filter((l) => l.status === 'pending').length
|
||||
|
||||
const filteredEmployees = employees.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
e.department.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
e.role.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const filteredLeaves = leaves.filter(
|
||||
(l) =>
|
||||
l.employeeName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
l.type.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
<Skeleton className="h-[80px]" />
|
||||
<Skeleton className="h-[80px]" />
|
||||
<Skeleton className="h-[80px]" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-danger">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-primary">HR</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">Employees and leave management</p>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />}>Add Employee</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Users size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{activeEmployees}</p>
|
||||
<p className="text-xs text-text-secondary">Active Employees</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{onLeave}</p>
|
||||
<p className="text-xs text-text-secondary">On Leave</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<Calendar size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{pendingLeaves}</p>
|
||||
<p className="text-xs text-text-secondary">Pending Requests</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs + Search */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-1 bg-bg rounded-xl p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<div className="relative flex-1 sm:flex-initial">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full sm:w-64 h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm text-text-primary placeholder:text-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" icon={<Filter size={14} />}>
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'Employees' && (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Employee</TableHeader>
|
||||
<TableHeader>Department</TableHeader>
|
||||
<TableHeader>Role</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader>Start Date</TableHeader>
|
||||
<TableHeader align="right"></TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredEmployees.map((employee) => (
|
||||
<TableRow key={employee.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xs font-semibold">
|
||||
{employee.name.split(' ').map((n) => n[0]).join('')}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-text-primary">{employee.name}</p>
|
||||
<p className="text-xs text-text-secondary">{employee.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={(departmentColors[employee.department] as 'primary' | 'success' | 'warning' | 'default') || 'default'}>
|
||||
{employee.department}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{employee.role}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
employee.status === 'active'
|
||||
? 'success'
|
||||
: employee.status === 'on_leave'
|
||||
? 'warning'
|
||||
: 'danger'
|
||||
}
|
||||
>
|
||||
{employee.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(employee.startDate)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredEmployees.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-text-secondary py-8">
|
||||
No employees found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'Leaves' && (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Employee</TableHeader>
|
||||
<TableHeader>Type</TableHeader>
|
||||
<TableHeader>Period</TableHeader>
|
||||
<TableHeader align="right">Days</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader align="right"></TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredLeaves.map((leave) => (
|
||||
<TableRow key={leave.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xs font-semibold">
|
||||
{leave.employeeName.split(' ').map((n) => n[0]).join('')}
|
||||
</div>
|
||||
<span className="font-medium text-text-primary">{leave.employeeName}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={leave.type === 'vacation' ? 'primary' : leave.type === 'sick' ? 'warning' : 'default'}>
|
||||
{leave.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-text-primary">
|
||||
{formatDate(leave.startDate)} — {formatDate(leave.endDate)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<span className="font-medium">{leave.days}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
leave.status === 'approved'
|
||||
? 'success'
|
||||
: leave.status === 'pending'
|
||||
? 'warning'
|
||||
: 'danger'
|
||||
}
|
||||
>
|
||||
{leave.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredLeaves.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-text-secondary py-8">
|
||||
No leave requests found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user