Files
boc/web-v2/src/pages/HRPage.tsx
T
Bernt 0124fc0186
BOC CI/CD / Test (push) Failing after 1s
BOC CI/CD / Security Scan (push) Has been skipped
BOC CI/CD / Build & Push (push) Has been skipped
BOC CI/CD / Deploy to Staging (push) Has been skipped
BOC CI/CD / Deploy to Production (push) Has been skipped
feat(agent): integrate agents into all BOC modules
- Add AgentFAB floating button to CRM, Sales, Finance, HR, Legal, Marketing, Support, Automation
- Update AgentChat to use /api/v1/agents/chat with auth token
- Add error handling and fallback to mock responses
- Update Anthropic model to claude-sonnet-4-6
- Show agent context in chat footer (title + competencies)
- Build fresh web-v2 dist
2026-08-12 09:04:42 +00:00

357 lines
12 KiB
TypeScript

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'
import { AgentFAB } from '@/components/agent/AgentFAB'
interface Employee {
id: string
first_name: string
last_name: string
name?: string
email: string
department: string
position: string
role?: string
status: string
start_date?: 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 || `${e.first_name} ${e.last_name}`).toLowerCase().includes(searchQuery.toLowerCase()) ||
e.department.toLowerCase().includes(searchQuery.toLowerCase()) ||
(e.role || e.position).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) => {
const empName = employee.name || `${employee.first_name} ${employee.last_name}`
const empRole = employee.role || employee.position
const empStartDate = employee.startDate || employee.start_date
return (
<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">
{empName.split(' ').map((n) => n[0]).join('')}
</div>
<div>
<p className="font-medium text-text-primary">{empName}</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>{empRole}</TableCell>
<TableCell>
<Badge
variant={
employee.status === 'active'
? 'success'
: employee.status === 'on_leave'
? 'warning'
: 'danger'
}
>
{employee.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell>{formatDate(empStartDate || '')}</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>
)}
{/* HR Agent */}
<AgentFAB rum="hr" />
</div>
)
}