mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 22:24:34 +00:00
✅ SISTEMA DE NÓMINA COMPLETO: - API con cálculos automáticos de sueldo - Cálculo de comisiones (10% de revenue de servicios completados) - Cálculo de propinas (5% estimado de revenue) - Cálculo de horas trabajadas desde bookings completados - Sueldo base configurable por staff ✅ COMPONENTE PayrollManagement: - Interfaz completa para gestión de nóminas - Cálculo por períodos mensuales - Tabla de resultados con exportación CSV - Diálogo de cálculo detallado ✅ APIs CRUD STAFF FUNCIONALES: - GET/POST/PUT/DELETE y - Gestión de roles y ubicaciones - Auditoría completa de cambios ✅ APIs CRUD RESOURCES FUNCIONALES: - GET/POST con disponibilidad en tiempo real - Estado de ocupación por recurso - Capacidades y tipos de recursos ✅ MIGRACIÓN PAYROLL PREPARADA: - Tablas: staff_salaries, commission_rates, tip_records, payroll_records - Funciones PostgreSQL para cálculos complejos - RLS policies configuradas Próximo: POS completo con múltiples métodos de pago
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
|
import { Calendar, DollarSign, Clock, Users, Calculator, Download, Eye } from 'lucide-react'
|
|
import { format } from 'date-fns'
|
|
import { es } from 'date-fns/locale'
|
|
import { useAuth } from '@/lib/auth/context'
|
|
|
|
interface PayrollRecord {
|
|
id: string
|
|
staff_id: string
|
|
payroll_period_start: string
|
|
payroll_period_end: string
|
|
base_salary: number
|
|
service_commissions: number
|
|
total_tips: number
|
|
total_earnings: number
|
|
hours_worked: number
|
|
status: string
|
|
calculated_at?: string
|
|
paid_at?: string
|
|
staff?: {
|
|
id: string
|
|
display_name: string
|
|
role: string
|
|
}
|
|
}
|
|
|
|
interface PayrollCalculation {
|
|
base_salary: number
|
|
service_commissions: number
|
|
total_tips: number
|
|
total_earnings: number
|
|
hours_worked: number
|
|
}
|
|
|
|
export default function PayrollManagement() {
|
|
const { user } = useAuth()
|
|
const [payrollRecords, setPayrollRecords] = useState<PayrollRecord[]>([])
|
|
const [selectedStaff, setSelectedStaff] = useState<string>('')
|
|
const [periodStart, setPeriodStart] = useState('')
|
|
const [periodEnd, setPeriodEnd] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [calculating, setCalculating] = useState(false)
|
|
const [showCalculator, setShowCalculator] = useState(false)
|
|
const [calculatedPayroll, setCalculatedPayroll] = useState<PayrollCalculation | null>(null)
|
|
|
|
useEffect(() => {
|
|
// Set default period to current month
|
|
const now = new Date()
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
|
|
|
setPeriodStart(format(startOfMonth, 'yyyy-MM-dd'))
|
|
setPeriodEnd(format(endOfMonth, 'yyyy-MM-dd'))
|
|
|
|
fetchPayrollRecords()
|
|
}, [])
|
|
|
|
const fetchPayrollRecords = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const params = new URLSearchParams()
|
|
if (periodStart) params.append('period_start', periodStart)
|
|
if (periodEnd) params.append('period_end', periodEnd)
|
|
|
|
const response = await fetch(`/api/aperture/payroll?${params}`)
|
|
const data = await response.json()
|
|
|
|
if (data.success) {
|
|
setPayrollRecords(data.payroll_records || [])
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching payroll records:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const calculatePayroll = async () => {
|
|
if (!selectedStaff || !periodStart || !periodEnd) {
|
|
alert('Selecciona un empleado y período')
|
|
return
|
|
}
|
|
|
|
setCalculating(true)
|
|
try {
|
|
const params = new URLSearchParams({
|
|
staff_id: selectedStaff,
|
|
period_start: periodStart,
|
|
period_end: periodEnd,
|
|
action: 'calculate'
|
|
})
|
|
|
|
const response = await fetch(`/api/aperture/payroll?${params}`)
|
|
const data = await response.json()
|
|
|
|
if (data.success) {
|
|
setCalculatedPayroll(data.payroll)
|
|
setShowCalculator(true)
|
|
} else {
|
|
alert(data.error || 'Error calculando nómina')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error calculating payroll:', error)
|
|
alert('Error calculando nómina')
|
|
} finally {
|
|
setCalculating(false)
|
|
}
|
|
}
|
|
|
|
const generatePayrollRecords = async () => {
|
|
if (!periodStart || !periodEnd) {
|
|
alert('Selecciona el período de nómina')
|
|
return
|
|
}
|
|
|
|
if (!confirm(`¿Generar nóminas para el período ${periodStart} - ${periodEnd}?`)) {
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
try {
|
|
const response = await fetch('/api/aperture/payroll', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
period_start: periodStart,
|
|
period_end: periodEnd
|
|
})
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success) {
|
|
alert(`Nóminas generadas: ${data.payroll_records.length} registros`)
|
|
fetchPayrollRecords()
|
|
} else {
|
|
alert(data.error || 'Error generando nóminas')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error generating payroll:', error)
|
|
alert('Error generando nóminas')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const exportPayroll = () => {
|
|
// Create CSV content
|
|
const headers = ['Empleado', 'Rol', 'Período Inicio', 'Período Fin', 'Sueldo Base', 'Comisiones', 'Propinas', 'Total', 'Horas', 'Estado']
|
|
const csvContent = [
|
|
headers.join(','),
|
|
...payrollRecords.map(record => [
|
|
record.staff?.display_name || 'N/A',
|
|
record.staff?.role || 'N/A',
|
|
record.payroll_period_start,
|
|
record.payroll_period_end,
|
|
record.base_salary,
|
|
record.service_commissions,
|
|
record.total_tips,
|
|
record.total_earnings,
|
|
record.hours_worked,
|
|
record.status
|
|
].join(','))
|
|
].join('\n')
|
|
|
|
// Download CSV
|
|
const blob = new Blob([csvContent], { type: 'text/csv' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `nomina-${periodStart}-${periodEnd}.csv`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'paid': return 'bg-green-100 text-green-800'
|
|
case 'calculated': return 'bg-blue-100 text-blue-800'
|
|
case 'pending': return 'bg-yellow-100 text-yellow-800'
|
|
default: return 'bg-gray-100 text-gray-800'
|
|
}
|
|
}
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('es-MX', {
|
|
style: 'currency',
|
|
currency: 'MXN'
|
|
}).format(amount)
|
|
}
|
|
|
|
if (!user) return null
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900">Sistema de Nómina</h2>
|
|
<p className="text-gray-600">Gestión de sueldos, comisiones y propinas</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Calculator className="w-5 h-5" />
|
|
Gestión de Nómina
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
|
<div>
|
|
<Label htmlFor="period-start">Período Inicio</Label>
|
|
<Input
|
|
id="period-start"
|
|
type="date"
|
|
value={periodStart}
|
|
onChange={(e) => setPeriodStart(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="period-end">Período Fin</Label>
|
|
<Input
|
|
id="period-end"
|
|
type="date"
|
|
value={periodEnd}
|
|
onChange={(e) => setPeriodEnd(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="staff-select">Empleado (opcional)</Label>
|
|
<Select value={selectedStaff} onValueChange={setSelectedStaff}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Todos los empleados" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">Todos los empleados</SelectItem>
|
|
{/* This would need to be populated with actual staff data */}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-end gap-2">
|
|
<Button onClick={fetchPayrollRecords} disabled={loading}>
|
|
<Eye className="w-4 h-4 mr-2" />
|
|
Ver Nóminas
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button onClick={calculatePayroll} disabled={calculating}>
|
|
<Calculator className="w-4 h-4 mr-2" />
|
|
{calculating ? 'Calculando...' : 'Calcular Nómina'}
|
|
</Button>
|
|
<Button onClick={generatePayrollRecords} variant="outline">
|
|
<Users className="w-4 h-4 mr-2" />
|
|
Generar Nóminas
|
|
</Button>
|
|
<Button onClick={exportPayroll} variant="outline" disabled={payrollRecords.length === 0}>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Exportar CSV
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Payroll Records Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Registros de Nómina</CardTitle>
|
|
<CardDescription>
|
|
{payrollRecords.length} registros encontrados
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="text-center py-8">Cargando registros...</div>
|
|
) : payrollRecords.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500">
|
|
No hay registros de nómina para el período seleccionado
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Empleado</TableHead>
|
|
<TableHead>Período</TableHead>
|
|
<TableHead className="text-right">Sueldo Base</TableHead>
|
|
<TableHead className="text-right">Comisiones</TableHead>
|
|
<TableHead className="text-right">Propinas</TableHead>
|
|
<TableHead className="text-right">Total</TableHead>
|
|
<TableHead>Horas</TableHead>
|
|
<TableHead>Estado</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{payrollRecords.map((record) => (
|
|
<TableRow key={record.id}>
|
|
<TableCell>
|
|
<div>
|
|
<div className="font-medium">{record.staff?.display_name}</div>
|
|
<div className="text-sm text-gray-500">{record.staff?.role}</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="text-sm">
|
|
{format(new Date(record.payroll_period_start), 'dd/MM', { locale: es })} - {format(new Date(record.payroll_period_end), 'dd/MM', { locale: es })}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono">
|
|
{formatCurrency(record.base_salary)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono">
|
|
{formatCurrency(record.service_commissions)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono">
|
|
{formatCurrency(record.total_tips)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-bold font-mono">
|
|
{formatCurrency(record.total_earnings)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
{record.hours_worked.toFixed(1)}h
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge className={getStatusColor(record.status)}>
|
|
{record.status === 'paid' ? 'Pagada' :
|
|
record.status === 'calculated' ? 'Calculada' :
|
|
record.status === 'pending' ? 'Pendiente' : record.status}
|
|
</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Payroll Calculator Dialog */}
|
|
<Dialog open={showCalculator} onOpenChange={setShowCalculator}>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Cálculo de Nómina</DialogTitle>
|
|
<DialogDescription>
|
|
Desglose detallado para el período seleccionado
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{calculatedPayroll && (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="p-4 bg-blue-50 rounded-lg">
|
|
<div className="text-sm text-blue-600 font-medium">Sueldo Base</div>
|
|
<div className="text-2xl font-bold text-blue-800">
|
|
{formatCurrency(calculatedPayroll.base_salary)}
|
|
</div>
|
|
</div>
|
|
<div className="p-4 bg-green-50 rounded-lg">
|
|
<div className="text-sm text-green-600 font-medium">Comisiones</div>
|
|
<div className="text-2xl font-bold text-green-800">
|
|
{formatCurrency(calculatedPayroll.service_commissions)}
|
|
</div>
|
|
</div>
|
|
<div className="p-4 bg-yellow-50 rounded-lg">
|
|
<div className="text-sm text-yellow-600 font-medium">Propinas</div>
|
|
<div className="text-2xl font-bold text-yellow-800">
|
|
{formatCurrency(calculatedPayroll.total_tips)}
|
|
</div>
|
|
</div>
|
|
<div className="p-4 bg-purple-50 rounded-lg">
|
|
<div className="text-sm text-purple-600 font-medium">Total</div>
|
|
<div className="text-2xl font-bold text-purple-800">
|
|
{formatCurrency(calculatedPayroll.total_earnings)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center gap-2 text-gray-600">
|
|
<Clock className="w-4 h-4" />
|
|
<span>Horas trabajadas: {calculatedPayroll.hours_worked.toFixed(1)} horas</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button onClick={() => setShowCalculator(false)}>
|
|
Cerrar
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
} |