Horas Extra
Exportar contenido
Ruta:
/employee/my-portal/overtimeComponente:Overtime.tsxPermiso requerido:my_portal
Visualiza tus horas extraordinarias acumuladas, elige el tipo de compensación (económica o descanso) y consulta el estado de compensaciones aprobadas y usadas.
Funcionalidades para el empleado
Sección titulada «Funcionalidades para el empleado»1. Resumen de horas
Sección titulada «1. Resumen de horas»Cards de estadísticas:
- Horas Pendientes — Total por compensar
- En Nómina — Horas aprobadas para pago económico
- En Descanso — Horas en bolsa de descanso compensatorio
2. Sistema de pestañas
Sección titulada «2. Sistema de pestañas»Tres vistas principales:
- Pendientes por compensación — Horas sin aprobar
- Aprobadas en nómina — Compensaciones económicas
- Aprobadas en descanso — Compensaciones en tiempo libre
3. Elección de compensación
Sección titulada «3. Elección de compensación»Si la política es employee_choice:
- Botón verde 💶 — Enviar a nómina
- Botón azul 📅 — Añadir a bolsa de descanso
- Confirmación antes de aplicar
- Cambio de estado a
approvedautomáticamente
Si la política es automática:
- Solo visualización del tipo asignado
- No hay botones de acción
4. Detalle de compensaciones
Sección titulada «4. Detalle de compensaciones»Por cada compensación:
- Fecha de generación
- Cantidad de horas (con decimales)
- Tipo (pago o descanso)
- Plazo restante para usar
- Estado (pendiente, aprobado, usado)
- Botón “Ver detalle del fichaje”
Detalles técnicos
Sección titulada «Detalles técnicos»Consulta de horas extra
Query principal
Sección titulada «Query principal»const { data: allCompensations, isLoading } = useQuery({ queryKey: ["employee-overtime-all", employee?.id], queryFn: async () => { const { data, error } = await supabase .from("overtime_compensations") .select(` id, employee_id, overtime_hours, requested_at, compensation_type, status, deadline_date, employee_preference, notes `) .eq("employee_id", employee.id) .order("requested_at", { ascending: false });
if (error) throw error; return data; }, enabled: !!employee?.id,});Filtrado por estado:
const pendingCompensations = allCompensations?.filter(c => c.status === "pending") || [];
const paymentCompensations = allCompensations?.filter(c => (c.status === "approved" || c.status === "completed") && c.compensation_type === "payment") || [];
const restCompensations = allCompensations?.filter(c => (c.status === "approved" || c.status === "completed") && c.compensation_type === "rest") || [];Políticas de compensación
Query de política
Sección titulada «Query de política»const { data: policy } = useQuery({ queryKey: ["overtime-policy", currentBusinessId], queryFn: async () => { const { data, error } = await supabase .from("overtime_policies") .select("policy_type, deadline_months") .eq("business_id", currentBusinessId) .single();
if (error && error.code !== "PGRST116") throw error; return data; }, enabled: !!currentBusinessId,});Tipos de política:
type PolicyType = 'employee_choice' | 'automatic_payment' | 'automatic_rest';Permisos de elección:
const canChoosePreference = policy?.policy_type === "employee_choice";Acción de empleado (employee_choice)
Handler de compensación
Sección titulada «Handler de compensación»const handleEmployeeAction = async () => { if (!confirmAction) return;
setIsProcessing(true); try { const { error } = await supabase .from("overtime_compensations") .update({ compensation_type: confirmAction.type, // 'payment' o 'rest' employee_preference: confirmAction.type, status: "approved", }) .eq("id", confirmAction.comp.id);
if (error) throw error;
queryClient.invalidateQueries({ queryKey: ["employee-overtime-all"] });
toast.success( confirmAction.type === "payment" ? `${confirmAction.comp.overtime_hours}h enviadas a nómina` : `${confirmAction.comp.overtime_hours}h añadidas a bolsa de descanso` ); } catch (error) { toast.error("Error al procesar la compensación"); } finally { setIsProcessing(false); setConfirmAction(null); }};Confirmación previa:
<AlertDialog open={confirmAction !== null}> <AlertDialogContent> <AlertDialogTitle> {confirmAction?.type === "payment" ? "¿Enviar a nómina?" : "¿Añadir a bolsa de descanso?"} </AlertDialogTitle> <AlertDialogDescription> {confirmAction?.type === "payment" ? ( <>Se enviarán <strong>{confirmAction?.comp.overtime_hours}h</strong> para compensación económica.</> ) : ( <>Se añadirán <strong>{confirmAction?.comp.overtime_hours}h</strong> a tu bolsa de descanso.</> )} </AlertDialogDescription> <AlertDialogFooter> <AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogAction onClick={handleEmployeeAction}> Confirmar </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent></AlertDialog>Cálculo de plazo restante
Función getDaysRemaining
Sección titulada «Función getDaysRemaining»const getDaysRemaining = (deadlineDate: string | null) => { if (!deadlineDate) return null;
const daysUntil = differenceInDays( new Date(deadlineDate), new Date() );
return daysUntil;};Visualización en tabla:
{(() => { const daysRemaining = getDaysRemaining(comp.deadline_date); if (daysRemaining === null) return null;
if (daysRemaining <= 0) { return <span className="text-destructive">Expirada</span>; }
return ( <div className="flex flex-col"> <span> {daysRemaining === 1 ? "1 día restante para usar" : `${daysRemaining} días restantes para usar`} </span> <span className="text-xs">Plazo: 4 meses</span> </div> );})()}Cálculo del plazo:
deadline_date=requested_at+deadline_monthsmeses- Configurado en
overtime_policies.deadline_months - Por defecto: 4 meses
Tabla de base de datos
overtime_compensations
Sección titulada «overtime_compensations»| Columna | Tipo | Descripción |
|---|---|---|
id | uuid | ID de la compensación |
employee_id | uuid | Empleado |
business_id | uuid | Negocio |
overtime_hours | numeric | Horas extra acumuladas |
requested_at | timestamptz | Fecha de generación |
compensation_type | text | ’payment’, ‘rest’, ‘pending’ |
status | text | ’pending’, ‘approved’, ‘completed’ |
deadline_date | date | Fecha límite para usar |
employee_preference | text | Preferencia del empleado |
notes | text | Notas adicionales |
Estados:
pending— Esperando decisión/aprobaciónapproved— Aprobado, disponible para usarcompleted— Ya consumido/pagado
Tipos de compensación:
payment— Pago económico en nóminarest— Descanso compensatorio (bolsa de horas)pending— Sin definir (siemployee_choice)
Detalle de fichaje
Sección titulada «Detalle de fichaje»OvertimeDetailDialog
Sección titulada «OvertimeDetailDialog»Al hacer clic en “Ver detalle del fichaje”:
Información mostrada:
- Fecha del fichaje que generó las horas extra
- Timeline de entrada, pausas y salida
- Total de horas trabajadas vs contractuales
- Diferencia (horas extra generadas)
- Tipo de día (laboral, festivo, fin de semana)
- Notas del fichaje
<OvertimeDetailDialog compensation={detailCompensation} open={!!detailCompensation} onOpenChange={(open) => !open && setDetailCompensation(null)}/>Badges de estado
Sección titulada «Badges de estado»Estados visuales
Sección titulada «Estados visuales»const getStatusBadge = (status: string) => { switch (status) { case "pending": return ( <Badge className="bg-amber-50 text-amber-700"> Pendiente </Badge> ); case "approved": return ( <Badge className="bg-green-50 text-green-700"> Aprobado </Badge> ); case "completed": return ( <Badge className="bg-green-50 text-green-700 gap-1"> <CheckCircle2 className="h-3 w-3" /> Usada </Badge> ); default: return <Badge variant="outline">{status}</Badge>; }};Empty states
Sección titulada «Empty states»Por cada pestaña si no hay datos:
const renderEmptyState = (message: string) => ( <div className="text-center py-12 text-muted-foreground"> <Clock className="h-12 w-12 mx-auto mb-4 opacity-50" /> <p>{message}</p> </div>);Mensajes:
- “No tienes horas extra pendientes de compensación”
- “No tienes horas extra aprobadas en nómina”
- “No tienes horas extra aprobadas en descanso”