Ir al contenido

Horas Extra

Ruta: /employee/my-portal/overtime Componente: Overtime.tsx Permiso 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.


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

Tres vistas principales:

  1. Pendientes por compensación — Horas sin aprobar
  2. Aprobadas en nómina — Compensaciones económicas
  3. Aprobadas en descanso — Compensaciones en tiempo libre

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 approved automáticamente

Si la política es automática:

  • Solo visualización del tipo asignado
  • No hay botones de acción

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”

Consulta de horas extra
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
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)
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
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_months meses
  • Configurado en overtime_policies.deadline_months
  • Por defecto: 4 meses
Tabla de base de datos
ColumnaTipoDescripción
iduuidID de la compensación
employee_iduuidEmpleado
business_iduuidNegocio
overtime_hoursnumericHoras extra acumuladas
requested_attimestamptzFecha de generación
compensation_typetext’payment’, ‘rest’, ‘pending’
statustext’pending’, ‘approved’, ‘completed’
deadline_datedateFecha límite para usar
employee_preferencetextPreferencia del empleado
notestextNotas adicionales

Estados:

  • pending — Esperando decisión/aprobación
  • approved — Aprobado, disponible para usar
  • completed — Ya consumido/pagado

Tipos de compensación:

  • payment — Pago económico en nómina
  • rest — Descanso compensatorio (bolsa de horas)
  • pending — Sin definir (si employee_choice)

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)}
/>

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>;
}
};

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”